mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-06 15:32:20 +00:00
Merge branch 'master' into ep/privacy
This commit is contained in:
@@ -111,6 +111,7 @@ jobs:
|
||||
arch: x86_64
|
||||
runner: "ubuntu-22.04"
|
||||
ghc: "8.10.7"
|
||||
hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5'
|
||||
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
@@ -118,24 +119,28 @@ jobs:
|
||||
runner: "ubuntu-22.04"
|
||||
should_run: true
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5'
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: x86_64
|
||||
runner: "ubuntu-24.04"
|
||||
should_run: true
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237'
|
||||
- os: 22.04
|
||||
os_underscore: 22_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-22.04-arm"
|
||||
should_run: true
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3'
|
||||
- os: 24.04
|
||||
os_underscore: 24_04
|
||||
arch: aarch64
|
||||
runner: "ubuntu-24.04-arm"
|
||||
should_run: true
|
||||
ghc: ${{ needs.variables.outputs.GHC_VER }}
|
||||
hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab'
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
if: matrix.should_run == true
|
||||
@@ -182,6 +187,7 @@ jobs:
|
||||
tags: build/${{ matrix.os }}:latest
|
||||
build-args: |
|
||||
TAG=${{ matrix.os }}
|
||||
HASH=${{ matrix.hash }}
|
||||
GHC=${{ matrix.ghc }}
|
||||
USER_UID=${{ steps.ids.outputs.uid }}
|
||||
USER_GID=${{ steps.ids.outputs.gid }}
|
||||
|
||||
@@ -55,6 +55,7 @@ website/src/img/images/
|
||||
website/src/images/
|
||||
website/src/js/lottie.min.js
|
||||
website/src/js/ethers*
|
||||
website/src/file-assets/
|
||||
website/src/privacy.md
|
||||
# Generated files
|
||||
website/package/generated*
|
||||
|
||||
+2
-1
@@ -1,6 +1,7 @@
|
||||
# syntax=docker/dockerfile:1.7.0-labs
|
||||
ARG TAG=24.04
|
||||
FROM ubuntu:${TAG} AS build
|
||||
ARG HASH=sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237
|
||||
FROM ubuntu:${TAG}@${HASH} AS build
|
||||
|
||||
### Build stage
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
|
||||
[<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
|
||||
|
||||
**[Why we are building SimpleX Network](./docs/WHY.md)**
|
||||
|
||||
## Welcome to SimpleX Chat!
|
||||
|
||||
1. 📲 [Install the app](#install-the-app).
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
# Coding and building
|
||||
|
||||
You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context.
|
||||
|
||||
## Three-Layer Documentation Architecture
|
||||
|
||||
### Why this structure exists
|
||||
|
||||
LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results.
|
||||
|
||||
The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves.
|
||||
|
||||
### The layers
|
||||
|
||||
| Layer | Contains | Question it answers |
|
||||
|-------|----------|-------------------|
|
||||
| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? |
|
||||
| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? |
|
||||
| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? |
|
||||
| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? |
|
||||
|
||||
Each layer links to the next:
|
||||
- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point
|
||||
- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents
|
||||
- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec
|
||||
- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift.
|
||||
- Reverse direction: the Document Map (end of this file) maps source → spec → product
|
||||
|
||||
### Navigation workflow
|
||||
|
||||
When the user requests any change, you MUST follow these steps before writing any code:
|
||||
|
||||
1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas.
|
||||
|
||||
2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract.
|
||||
|
||||
3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees.
|
||||
|
||||
4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior.
|
||||
|
||||
5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information.
|
||||
|
||||
For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6.
|
||||
|
||||
6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below.
|
||||
|
||||
### Key navigation documents
|
||||
|
||||
| Document | Purpose | When to read |
|
||||
|----------|---------|-------------|
|
||||
| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change |
|
||||
| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior |
|
||||
| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms |
|
||||
| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature |
|
||||
| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change |
|
||||
| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation |
|
||||
|
||||
---
|
||||
|
||||
## Code Security
|
||||
|
||||
When designing code and planning implementations, you MUST:
|
||||
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries.
|
||||
- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
**Follow existing code patterns — you MUST:**
|
||||
- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise.
|
||||
- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness.
|
||||
- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs.
|
||||
|
||||
**Comments policy — you MUST:**
|
||||
- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code.
|
||||
- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments.
|
||||
- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value.
|
||||
|
||||
**Diff and refactoring — you MUST:**
|
||||
- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff.
|
||||
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit.
|
||||
- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert.
|
||||
|
||||
**Document and code structure — you MUST:**
|
||||
- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame.
|
||||
- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability.
|
||||
- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult.
|
||||
|
||||
**Code analysis and review — you MUST:**
|
||||
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs.
|
||||
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior.
|
||||
- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs.
|
||||
|
||||
---
|
||||
|
||||
## Plans
|
||||
|
||||
When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was.
|
||||
|
||||
### Plan requirements
|
||||
|
||||
1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions.
|
||||
|
||||
2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent.
|
||||
|
||||
3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth.
|
||||
|
||||
4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation.
|
||||
|
||||
---
|
||||
|
||||
## Change Protocol
|
||||
|
||||
### The rule
|
||||
|
||||
Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change.
|
||||
|
||||
### What to update
|
||||
|
||||
1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification.
|
||||
|
||||
2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion.
|
||||
|
||||
3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value.
|
||||
|
||||
4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work.
|
||||
|
||||
5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates.
|
||||
|
||||
6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context.
|
||||
|
||||
7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt.
|
||||
|
||||
8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later.
|
||||
|
||||
9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable.
|
||||
|
||||
### Adversarial self-review
|
||||
|
||||
After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers.
|
||||
|
||||
**Within-layer coherence — you MUST verify:**
|
||||
- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact
|
||||
- product/ is internally consistent — flows match views, rules match behavior descriptions
|
||||
|
||||
**Across-layer coherence — you MUST verify:**
|
||||
- Every new or changed function in source appears in the corresponding spec/ document
|
||||
- Every user-visible behavior change in source appears in the relevant product/ document
|
||||
- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines
|
||||
- All cross-references resolve — product → spec links, spec → source links
|
||||
- `spec/impact.md` covers all affected product concepts for the changed source files
|
||||
- `product/concepts.md` rows are current for any affected concepts
|
||||
|
||||
**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent.
|
||||
|
||||
---
|
||||
|
||||
## Document Map
|
||||
|
||||
### iOS Swift Sources
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md |
|
||||
| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md |
|
||||
| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md |
|
||||
| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md |
|
||||
| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/ChannelMembersView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/ChannelRelaysView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md |
|
||||
| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md |
|
||||
| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md |
|
||||
| Shared/Views/Call/CallController.swift | spec/services/calls.md | product/flows/calling.md |
|
||||
| Shared/Views/Call/WebRTCClient.swift | spec/services/calls.md | product/flows/calling.md |
|
||||
| Shared/Views/UserSettings/SettingsView.swift | spec/client/navigation.md | product/views/settings.md |
|
||||
| Shared/Views/UserSettings/AppearanceSettings.swift | spec/services/theme.md | product/views/settings.md |
|
||||
| Shared/Views/UserSettings/NetworkAndServers/ | spec/architecture.md | product/views/settings.md |
|
||||
| Shared/Views/UserSettings/UserProfilesView.swift | spec/client/navigation.md | product/views/user-profiles.md |
|
||||
| Shared/Views/Onboarding/ | spec/client/navigation.md | product/views/onboarding.md |
|
||||
| Shared/Views/LocalAuth/ | spec/architecture.md | product/views/settings.md |
|
||||
| Shared/Views/Database/ | spec/database.md | product/views/settings.md |
|
||||
| Shared/Views/Migration/ | spec/database.md | product/flows/onboarding.md |
|
||||
| Shared/Model/ChatModel.swift | spec/state.md | product/concepts.md |
|
||||
| Shared/Model/SimpleXAPI.swift | spec/api.md, spec/architecture.md | product/concepts.md |
|
||||
| Shared/Model/AppAPITypes.swift | spec/api.md | product/concepts.md |
|
||||
| Shared/Model/NtfManager.swift | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| Shared/Model/BGManager.swift | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| Shared/Theme/ThemeManager.swift | spec/services/theme.md | product/views/settings.md |
|
||||
| SimpleXChat/ChatTypes.swift | spec/state.md, spec/api.md | product/glossary.md |
|
||||
| SimpleXChat/APITypes.swift | spec/api.md | product/concepts.md |
|
||||
| SimpleXChat/CallTypes.swift | spec/services/calls.md | product/flows/calling.md |
|
||||
| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md |
|
||||
| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md |
|
||||
| Shared/Views/Chat/ChatItemsMerger.swift | spec/client/chat-view.md | product/views/chat.md |
|
||||
| SimpleX SE/ShareAPI.swift | spec/api.md | product/flows/messaging.md |
|
||||
|
||||
### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`)
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md |
|
||||
| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md |
|
||||
| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md |
|
||||
| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md |
|
||||
| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md |
|
||||
| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md |
|
||||
| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md |
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 30/03/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 17/01/2022.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import Intents
|
||||
@@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#ContentView
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
// Spec: spec/client/navigation.md#AppSheetState
|
||||
@ObservedObject var appSheetState = AppSheetState.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
|
||||
// Spec: spec/client/navigation.md#contentAccessAuthenticationExtended
|
||||
var contentAccessAuthenticationExtended: Bool
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@@ -161,6 +165,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#contentView
|
||||
@ViewBuilder private func contentView() -> some View {
|
||||
if let status = chatModel.chatDbStatus, status != .ok {
|
||||
DatabaseErrorView(status: status)
|
||||
@@ -176,6 +181,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#callView
|
||||
@ViewBuilder private func callView(_ call: Call) -> some View {
|
||||
if CallController.useCallKit() {
|
||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||
@@ -193,6 +199,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#callBanner
|
||||
private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
HStack {
|
||||
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||
@@ -227,6 +234,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#lockButton
|
||||
private func lockButton() -> some View {
|
||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
@@ -339,6 +347,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#unlockedRecently
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
@@ -426,6 +435,7 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#connectViaUrl
|
||||
func connectViaUrl() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
@@ -441,7 +451,12 @@ struct ContentView: View {
|
||||
func connectViaUrl_(_ url: URL) {
|
||||
dismissAllSheets() {
|
||||
var path = url.path
|
||||
if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
|
||||
if path == "/r" {
|
||||
showAlert(
|
||||
NSLocalizedString("Relay address", comment: "alert title"),
|
||||
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
|
||||
)
|
||||
} else 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(
|
||||
|
||||
@@ -5,11 +5,13 @@
|
||||
// Created by EP on 01/05/2025.
|
||||
// Copyright © 2025 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/api.md
|
||||
|
||||
import SimpleXChat
|
||||
import SwiftUI
|
||||
|
||||
// some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised
|
||||
// Spec: spec/api.md#ChatCommand
|
||||
enum ChatCommand: ChatCmdProtocol {
|
||||
case showActiveUser
|
||||
case createActiveUser(profile: Profile?, pastTimestamp: Bool)
|
||||
@@ -43,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String)
|
||||
case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?)
|
||||
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 apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
|
||||
case apiCreateChatTag(tag: ChatTagData)
|
||||
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
|
||||
case apiDeleteChatTag(tagId: Int64)
|
||||
@@ -59,7 +61,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
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(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 apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, 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,6 +70,8 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiGetNtfConns(nonce: String, encNtfInfo: String)
|
||||
case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq])
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile)
|
||||
case apiGetGroupRelays(groupId: Int64)
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
|
||||
@@ -87,6 +91,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent)
|
||||
case apiAcceptMemberContact(contactId: Int64)
|
||||
case apiTestProtoServer(userId: Int64, server: String)
|
||||
case apiTestChatRelay(userId: Int64, address: String)
|
||||
case apiGetServerOperators
|
||||
case apiSetServerOperators(operators: [ServerOperator])
|
||||
case apiGetUserServers(userId: Int64)
|
||||
@@ -105,6 +110,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case reconnectServer(userId: Int64, smpServer: String)
|
||||
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
|
||||
case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
|
||||
case apiGetUpdatedGroupLinkData(groupId: Int64)
|
||||
case apiContactInfo(contactId: Int64)
|
||||
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
|
||||
case apiContactQueueInfo(contactId: Int64)
|
||||
@@ -124,7 +130,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
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 apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData)
|
||||
case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64)
|
||||
case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64)
|
||||
case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?)
|
||||
@@ -228,10 +234,11 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)")
|
||||
case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))"
|
||||
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):
|
||||
case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages):
|
||||
let msgs = encodeJSON(composedMessages)
|
||||
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
|
||||
return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
|
||||
let asGroup = sendAsGroup ? "(as_group=on)" : ""
|
||||
return "/_send \(ref(type, id, scope: scope))\(asGroup) 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, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
|
||||
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
|
||||
@@ -250,9 +257,10 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
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, 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):
|
||||
case let .apiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl):
|
||||
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
|
||||
return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)"
|
||||
let asGroup = sendAsGroup ? " as_group=on" : ""
|
||||
return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(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)"
|
||||
@@ -261,6 +269,8 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
|
||||
case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))"
|
||||
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
|
||||
case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))"
|
||||
case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)"
|
||||
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)"
|
||||
@@ -280,6 +290,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
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 let .apiTestChatRelay(userId, address): return "/_relay test \(userId) \(address)"
|
||||
case .apiGetServerOperators: return "/_operators"
|
||||
case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))"
|
||||
case let .apiGetUserServers(userId): return "/_servers \(userId)"
|
||||
@@ -298,6 +309,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)"
|
||||
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 .apiGetUpdatedGroupLinkData(groupId): return "/_get group link data #\(groupId)"
|
||||
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
|
||||
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
|
||||
case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)"
|
||||
@@ -327,7 +339,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
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 .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(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))"
|
||||
@@ -447,6 +459,8 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiGetNtfConns: return "apiGetNtfConns"
|
||||
case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
|
||||
case .apiNewGroup: return "apiNewGroup"
|
||||
case .apiNewPublicGroup: return "apiNewPublicGroup"
|
||||
case .apiGetGroupRelays: return "apiGetGroupRelays"
|
||||
case .apiAddMember: return "apiAddMember"
|
||||
case .apiJoinGroup: return "apiJoinGroup"
|
||||
case .apiAcceptMember: return "apiAcceptMember"
|
||||
@@ -466,6 +480,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation"
|
||||
case .apiAcceptMemberContact: return "apiAcceptMemberContact"
|
||||
case .apiTestProtoServer: return "apiTestProtoServer"
|
||||
case .apiTestChatRelay: return "apiTestChatRelay"
|
||||
case .apiGetServerOperators: return "apiGetServerOperators"
|
||||
case .apiSetServerOperators: return "apiSetServerOperators"
|
||||
case .apiGetUserServers: return "apiGetUserServers"
|
||||
@@ -484,6 +499,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .reconnectServer: return "reconnectServer"
|
||||
case .apiSetChatSettings: return "apiSetChatSettings"
|
||||
case .apiSetMemberSettings: return "apiSetMemberSettings"
|
||||
case .apiGetUpdatedGroupLinkData: return "apiGetUpdatedGroupLinkData"
|
||||
case .apiContactInfo: return "apiContactInfo"
|
||||
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
|
||||
case .apiContactQueueInfo: return "apiContactQueueInfo"
|
||||
@@ -643,6 +659,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
}
|
||||
|
||||
// ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient.
|
||||
// Spec: spec/api.md#ChatResponse0
|
||||
enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
case activeUser(user: User)
|
||||
case usersList(users: [UserInfo])
|
||||
@@ -655,13 +672,15 @@ enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
case chatTags(user: UserRef, userTags: [ChatTag])
|
||||
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
|
||||
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
|
||||
case chatRelayTestResult(user: UserRef, relayProfile: RelayProfile?, relayTestFailure: RelayTestFailure?)
|
||||
case serverOperatorConditions(conditions: ServerOperatorConditions)
|
||||
case userServers(user: UserRef, userServers: [UserOperatorServers])
|
||||
case userServersValidation(user: UserRef, serverErrors: [UserServersError])
|
||||
case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning])
|
||||
case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?)
|
||||
case chatItemTTL(user: UserRef, chatItemTTL: Int64?)
|
||||
case networkConfig(networkConfig: NetCfg)
|
||||
case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?)
|
||||
case groupInfo(user: UserRef, groupInfo: GroupInfo)
|
||||
case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?)
|
||||
case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo)
|
||||
case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats)
|
||||
@@ -688,6 +707,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
case .chatTags: "chatTags"
|
||||
case .chatItemInfo: "chatItemInfo"
|
||||
case .serverTestResult: "serverTestResult"
|
||||
case .chatRelayTestResult: "chatRelayTestResult"
|
||||
case .serverOperatorConditions: "serverOperators"
|
||||
case .userServers: "userServers"
|
||||
case .userServersValidation: "userServersValidation"
|
||||
@@ -695,6 +715,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
case .chatItemTTL: "chatItemTTL"
|
||||
case .networkConfig: "networkConfig"
|
||||
case .contactInfo: "contactInfo"
|
||||
case .groupInfo: "groupInfo"
|
||||
case .groupMemberInfo: "groupMemberInfo"
|
||||
case .queueInfo: "queueInfo"
|
||||
case .contactSwitchStarted: "contactSwitchStarted"
|
||||
@@ -723,13 +744,15 @@ enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
|
||||
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
|
||||
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
|
||||
case let .chatRelayTestResult(u, relayProfile, relayTestFailure): return withUser(u, "relayProfile: \(String(describing: relayProfile))\nresult: \(String(describing: relayTestFailure))")
|
||||
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
|
||||
case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))")
|
||||
case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))")
|
||||
case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))")
|
||||
case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))"
|
||||
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))
|
||||
case let .networkConfig(networkConfig): return String(describing: networkConfig)
|
||||
case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))")
|
||||
case let .groupInfo(u, groupInfo): return withUser(u, "groupInfo: \(String(describing: groupInfo))")
|
||||
case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))")
|
||||
case let .queueInfo(u, rcvMsgInfo, queueInfo):
|
||||
let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" }
|
||||
@@ -764,6 +787,7 @@ enum ChatResponse0: Decodable, ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatResponse1
|
||||
enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection)
|
||||
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
@@ -775,7 +799,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
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 startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo, relayResults: [RelayConnectionResult])
|
||||
case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?)
|
||||
case contactAlreadyExists(user: UserRef, contact: Contact)
|
||||
case contactDeleted(user: UserRef, contact: Contact)
|
||||
@@ -896,16 +920,19 @@ enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
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 .startedConnectionToGroup(u, groupInfo, relayResults): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nrelayResults: \(String(describing: relayResults))")
|
||||
case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact))
|
||||
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatResponse2
|
||||
enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
// group responses
|
||||
case groupCreated(user: UserRef, groupInfo: GroupInfo)
|
||||
case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
|
||||
case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay])
|
||||
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
|
||||
@@ -956,6 +983,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
var responseType: String {
|
||||
switch self {
|
||||
case .groupCreated: "groupCreated"
|
||||
case .publicGroupCreated: "publicGroupCreated"
|
||||
case .groupRelays: "groupRelays"
|
||||
case .sentGroupInvitation: "sentGroupInvitation"
|
||||
case .userAcceptedGroupSent: "userAcceptedGroupSent"
|
||||
case .userDeletedMembers: "userDeletedMembers"
|
||||
@@ -1002,6 +1031,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
var details: String {
|
||||
switch self {
|
||||
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
|
||||
case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)")
|
||||
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
|
||||
@@ -1046,6 +1077,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatEvent
|
||||
enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case chatSuspended
|
||||
case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress)
|
||||
@@ -1080,10 +1112,12 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool)
|
||||
case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case userJoinedGroup(user: UserRef, groupInfo: GroupInfo)
|
||||
case userJoinedGroup(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
|
||||
case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
|
||||
case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?)
|
||||
case groupUpdated(user: UserRef, toGroup: GroupInfo)
|
||||
case groupLinkDataUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay], relaysChanged: Bool)
|
||||
case groupRelayUpdated(user: UserRef, groupInfo: GroupInfo, member: GroupMember, groupRelay: GroupRelay)
|
||||
case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember)
|
||||
// receiving file events
|
||||
case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
|
||||
@@ -1160,6 +1194,8 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case .joinedGroupMember: "joinedGroupMember"
|
||||
case .connectedToGroupMember: "connectedToGroupMember"
|
||||
case .groupUpdated: "groupUpdated"
|
||||
case .groupLinkDataUpdated: "groupLinkDataUpdated"
|
||||
case .groupRelayUpdated: "groupRelayUpdated"
|
||||
case .newMemberContactReceivedInv: "newMemberContactReceivedInv"
|
||||
case .rcvFileAccepted: "rcvFileAccepted"
|
||||
case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled"
|
||||
@@ -1236,10 +1272,12 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)")
|
||||
case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo))
|
||||
case let .userJoinedGroup(u, groupInfo, _): return withUser(u, String(describing: groupInfo))
|
||||
case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))")
|
||||
case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
|
||||
case let .groupLinkDataUpdated(u, groupInfo, groupLink, groupRelays, relaysChanged): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)\nrelaysChanged: \(relaysChanged)")
|
||||
case let .groupRelayUpdated(u, groupInfo, member, groupRelay): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\ngroupRelay: \(groupRelay)")
|
||||
case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)")
|
||||
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case .rcvFileAcceptedSndCancelled: return noDetails
|
||||
@@ -1278,6 +1316,7 @@ enum ChatEvent: Decodable, ChatAPIResult {
|
||||
struct NewUser: Encodable {
|
||||
var profile: Profile?
|
||||
var pastTimestamp: Bool
|
||||
var userChatRelay: Bool = false
|
||||
}
|
||||
|
||||
enum ChatPagination {
|
||||
@@ -1325,8 +1364,14 @@ enum ContactAddressPlan: Decodable, Hashable {
|
||||
case contactViaAddress(contact: Contact)
|
||||
}
|
||||
|
||||
public struct GroupShortLinkInfo: Decodable, Hashable {
|
||||
public var direct: Bool
|
||||
public var groupRelays: [String]
|
||||
public var publicGroupId: String?
|
||||
}
|
||||
|
||||
enum GroupLinkPlan: Decodable, Hashable {
|
||||
case ok(groupSLinkData_: GroupShortLinkData?)
|
||||
case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?)
|
||||
case ownLink(groupInfo: GroupInfo)
|
||||
case connectingConfirmReconnect
|
||||
case connectingProhibit(groupInfo_: GroupInfo?)
|
||||
@@ -1706,6 +1751,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
|
||||
var `operator`: ServerOperator?
|
||||
var smpServers: [UserServer]
|
||||
var xftpServers: [UserServer]
|
||||
var chatRelays: [UserChatRelay]
|
||||
|
||||
var id: String {
|
||||
if let op = self.operator {
|
||||
@@ -1735,21 +1781,28 @@ struct UserOperatorServers: Identifiable, Equatable, Codable {
|
||||
static var sampleData1 = UserOperatorServers(
|
||||
operator: ServerOperator.sampleData1,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
xftpServers: [UserServer.sampleData.xftpPreset],
|
||||
chatRelays: []
|
||||
)
|
||||
|
||||
static var sampleDataNilOperator = UserOperatorServers(
|
||||
operator: nil,
|
||||
smpServers: [UserServer.sampleData.preset],
|
||||
xftpServers: [UserServer.sampleData.xftpPreset]
|
||||
xftpServers: [UserServer.sampleData.xftpPreset],
|
||||
chatRelays: []
|
||||
)
|
||||
}
|
||||
|
||||
public enum UserServersWarning: Decodable {
|
||||
case noChatRelays(user: UserRef?)
|
||||
}
|
||||
|
||||
enum UserServersError: Decodable {
|
||||
case noServers(protocol: ServerProtocol, user: UserRef?)
|
||||
case storageMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case proxyMissing(protocol: ServerProtocol, user: UserRef?)
|
||||
case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String)
|
||||
case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String)
|
||||
|
||||
var globalError: String? {
|
||||
switch self {
|
||||
@@ -1907,6 +1960,11 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
struct RelayConnectionResult: Decodable {
|
||||
var relayMember: GroupMember
|
||||
var relayError: ChatError?
|
||||
}
|
||||
|
||||
enum ProtocolTestStep: String, Decodable, Equatable {
|
||||
case connect
|
||||
case disconnect
|
||||
@@ -1958,6 +2016,41 @@ struct ProtocolTestFailure: Decodable, Error, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum RelayTestStep: String, Decodable {
|
||||
case getLink
|
||||
case decodeLink
|
||||
case connect
|
||||
case waitResponse
|
||||
case verify
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .getLink: return NSLocalizedString("Get link", comment: "relay test step")
|
||||
case .decodeLink: return NSLocalizedString("Decode link", comment: "relay test step")
|
||||
case .connect: return NSLocalizedString("Connect", comment: "relay test step")
|
||||
case .waitResponse: return NSLocalizedString("Wait response", comment: "relay test step")
|
||||
case .verify: return NSLocalizedString("Verify", comment: "relay test step")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct RelayTestFailure: Decodable, Error {
|
||||
public var rtfStep: RelayTestStep
|
||||
public var rtfError: ChatError
|
||||
|
||||
var localizedDescription: String {
|
||||
let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "relay test failure"), rtfStep.text)
|
||||
switch rtfError {
|
||||
case .errorAgent(agentError: .SMP(_, .AUTH)):
|
||||
return err + " " + NSLocalizedString("Server requires authorization to connect to relay, check password.", comment: "relay test error")
|
||||
case .errorAgent(agentError: .BROKER(_, .NETWORK(.unknownCAError))):
|
||||
return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "relay test error")
|
||||
default:
|
||||
return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "relay test error"), String(describing: rtfError))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MigrationFileLinkData: Codable {
|
||||
let networkConfig: NetworkConfig?
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
@@ -25,6 +26,7 @@ private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
|
||||
|
||||
private let maxTimerCount = 9
|
||||
|
||||
// Spec: spec/services/notifications.md#BGManager
|
||||
class BGManager {
|
||||
static let shared = BGManager()
|
||||
var chatReceiver: ChatReceiver?
|
||||
@@ -32,6 +34,7 @@ class BGManager {
|
||||
var completed = true
|
||||
var timerCount = 0
|
||||
|
||||
// Spec: spec/services/notifications.md#register
|
||||
func register() {
|
||||
logger.debug("BGManager.register")
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in
|
||||
@@ -39,6 +42,7 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#schedule
|
||||
func schedule(interval: TimeInterval? = nil) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
@@ -66,6 +70,7 @@ class BGManager {
|
||||
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#handleRefresh
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
@@ -103,6 +108,7 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#receiveMessages-BG
|
||||
func receiveMessages(_ completeReceiving: @escaping (String) -> Void) {
|
||||
if (!self.completed) {
|
||||
logger.debug("BGManager.receiveMessages: in progress, exiting")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 22/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/state.md
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
@@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
}
|
||||
|
||||
// analogue for SecondaryContextFilter in Kotlin
|
||||
// Spec: spec/state.md#SecondaryItemsModelFilter
|
||||
enum SecondaryItemsModelFilter {
|
||||
case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo)
|
||||
case msgContentTagContext(contentTag: MsgContentTag)
|
||||
@@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter {
|
||||
}
|
||||
|
||||
// analogue for ChatsContext in Kotlin
|
||||
// Spec: spec/state.md#ItemsModel
|
||||
class ItemsModel: ObservableObject {
|
||||
static let shared = ItemsModel(secondaryIMFilter: nil)
|
||||
public var secondaryIMFilter: SecondaryItemsModelFilter?
|
||||
@@ -103,12 +106,14 @@ class ItemsModel: ObservableObject {
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadSecondaryChat
|
||||
static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) {
|
||||
let im = ItemsModel(secondaryIMFilter: chatFilter)
|
||||
ChatModel.shared.secondaryIM = im
|
||||
im.loadOpenChat(chatId, willNavigate: willNavigate)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadOpenChat
|
||||
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
@@ -134,6 +139,7 @@ class ItemsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadOpenChatNoWait
|
||||
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
@@ -179,6 +185,7 @@ class PreloadState {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatTagsModel
|
||||
class ChatTagsModel: ObservableObject {
|
||||
static let shared = ChatTagsModel()
|
||||
|
||||
@@ -326,6 +333,30 @@ class ConnectProgressManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
class ChannelRelaysModel: ObservableObject {
|
||||
static let shared = ChannelRelaysModel()
|
||||
@Published var groupId: Int64? = nil
|
||||
@Published var groupRelays: [GroupRelay] = []
|
||||
|
||||
func set(groupId: Int64, groupRelays: [GroupRelay]) {
|
||||
self.groupId = groupId
|
||||
self.groupRelays = groupRelays
|
||||
}
|
||||
|
||||
func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) {
|
||||
if groupId == groupInfo.groupId,
|
||||
let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
|
||||
groupRelays[i] = relay
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
groupId = nil
|
||||
groupRelays = []
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatModel
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@@ -355,9 +386,13 @@ final class ChatModel: ObservableObject {
|
||||
@Published var chatSubStatus: SubscriptionStatus?
|
||||
@Published var openAroundItemId: ChatItem.ID? = nil
|
||||
@Published var chatToTop: String?
|
||||
@Published var creatingChannelId: String?
|
||||
@Published var groupMembers: [GMember] = []
|
||||
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
|
||||
@Published var membersLoaded = false
|
||||
// Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart.
|
||||
// APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join.
|
||||
@Published var channelRelayHostnames: [Int64: [String]] = [:]
|
||||
// items in the terminal view
|
||||
@Published var showingTerminal = false
|
||||
@Published var terminalItems: [TerminalItem] = []
|
||||
@@ -383,6 +418,7 @@ final class ChatModel: ObservableObject {
|
||||
@Published var showCallView = false
|
||||
@Published var activeCallViewIsCollapsed = false
|
||||
// remote desktop
|
||||
// Spec: spec/architecture.md#remoteCtrlSession
|
||||
@Published var remoteCtrlSession: RemoteCtrlSession?
|
||||
// currently showing invitation
|
||||
@Published var showingInvitation: ShowingInvitation?
|
||||
@@ -423,6 +459,7 @@ final class ChatModel: ObservableObject {
|
||||
userAddress?.shortLinkDataSet ?? true
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#getUser
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@@ -433,6 +470,7 @@ final class ChatModel: ObservableObject {
|
||||
users.firstIndex { $0.user.userId == user.userId }
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#updateUser
|
||||
func updateUser(_ user: User) {
|
||||
if let i = getUserIndex(user) {
|
||||
users[i].user = user
|
||||
@@ -442,6 +480,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#removeUser
|
||||
func removeUser(_ user: User) {
|
||||
if let i = getUserIndex(user) {
|
||||
users.remove(at: i)
|
||||
@@ -452,6 +491,7 @@ final class ChatModel: ObservableObject {
|
||||
chats.first(where: { $0.id == id }) != nil
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#getChat
|
||||
func getChat(_ id: String) -> Chat? {
|
||||
chats.first(where: { $0.id == id })
|
||||
}
|
||||
@@ -506,6 +546,7 @@ final class ChatModel: ObservableObject {
|
||||
chats.firstIndex(where: { $0.id == id })
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#addChat
|
||||
func addChat(_ chat: Chat) {
|
||||
if chatId == nil {
|
||||
withAnimation { addChat_(chat, at: 0) }
|
||||
@@ -519,6 +560,7 @@ final class ChatModel: ObservableObject {
|
||||
chats.insert(chat, at: position)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#updateChatInfo
|
||||
func updateChatInfo(_ cInfo: ChatInfo) {
|
||||
if let i = getChatIndex(cInfo.id) {
|
||||
if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil {
|
||||
@@ -570,6 +612,7 @@ final class ChatModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#replaceChat
|
||||
func replaceChat(_ id: String, _ chat: Chat) {
|
||||
if let i = getChatIndex(id) {
|
||||
chats[i] = chat
|
||||
@@ -1054,6 +1097,7 @@ final class ChatModel: ObservableObject {
|
||||
NtfManager.shared.changeNtfBadgeCount(by: by)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#totalUnreadCountForAllUsers
|
||||
func totalUnreadCountForAllUsers() -> Int {
|
||||
var unread: Int = 0
|
||||
for chat in chats {
|
||||
@@ -1153,6 +1197,7 @@ final class ChatModel: ObservableObject {
|
||||
return (prevMember, memberIds.count)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#popChat
|
||||
func popChat(_ id: String) {
|
||||
if let i = getChatIndex(id) {
|
||||
// no animation here, for it not to look like it just moved when leaving the chat
|
||||
@@ -1176,14 +1221,26 @@ final class ChatModel: ObservableObject {
|
||||
showingInvitation?.connChatUsed = true
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#removeChat
|
||||
func removeChat(_ id: String) {
|
||||
var groupId: Int64?
|
||||
withAnimation {
|
||||
if let i = getChatIndex(id) {
|
||||
let removed = chats.remove(at: i)
|
||||
groupId = removed.chatInfo.groupInfo?.groupId
|
||||
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
|
||||
removeWallpaperFilesFromChat(removed)
|
||||
}
|
||||
}
|
||||
if chatId == id {
|
||||
groupMembers = []
|
||||
groupMembersIndexes.removeAll()
|
||||
// Remove channelRelayHostnames for this channel only, preserving other prepared channels
|
||||
if let gId = groupId {
|
||||
channelRelayHostnames.removeValue(forKey: gId)
|
||||
}
|
||||
membersLoaded = false
|
||||
}
|
||||
}
|
||||
|
||||
func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool {
|
||||
@@ -1192,13 +1249,19 @@ final class ChatModel: ObservableObject {
|
||||
updateGroup(groupInfo)
|
||||
return false
|
||||
}
|
||||
// update current chat
|
||||
if chatId == groupInfo.id {
|
||||
// update current chat or channel being created
|
||||
if chatId == groupInfo.id || creatingChannelId == groupInfo.id {
|
||||
if let i = groupMembersIndexes[member.groupMemberId] {
|
||||
let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus
|
||||
withAnimation(.default) {
|
||||
self.groupMembers[i].wrapped = member
|
||||
self.groupMembers[i].created = Date.now
|
||||
}
|
||||
// Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array,
|
||||
// so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly.
|
||||
if connStatusChanged {
|
||||
objectWillChange.send()
|
||||
}
|
||||
return false
|
||||
} else {
|
||||
withAnimation {
|
||||
@@ -1248,6 +1311,7 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#Chat
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
@@ -22,6 +23,7 @@ enum NtfCallAction {
|
||||
case reject
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#NtfManager
|
||||
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NtfManager()
|
||||
|
||||
@@ -48,6 +50,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
handler()
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#processNotificationResponse
|
||||
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
|
||||
let chatModel = ChatModel.shared
|
||||
let content = ntfResponse.notification.request.content
|
||||
@@ -149,6 +152,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#registerCategories
|
||||
func registerCategories() {
|
||||
logger.debug("NtfManager.registerCategories")
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
@@ -207,6 +211,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
])
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#requestAuthorization
|
||||
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
|
||||
logger.debug("NtfManager.requestAuthorization")
|
||||
let center = UNUserNotificationCenter.current()
|
||||
@@ -230,6 +235,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyContactRequest
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest, 0))
|
||||
@@ -240,6 +246,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
addNotification(createContactConnectedNtf(user, contact, 0))
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyMessageReceived
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled(chatItem: cItem) {
|
||||
@@ -247,16 +254,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyCallInvitation
|
||||
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||
logger.debug("NtfManager.notifyCallInvitation")
|
||||
addNotification(createCallInvitationNtf(invitation, 0))
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#setNtfBadgeCount
|
||||
func setNtfBadgeCount(_ count: Int) {
|
||||
UIApplication.shared.applicationIconBadgeNumber = count
|
||||
ntfBadgeCountGroupDefault.set(count)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#changeNtfBadgeCount
|
||||
func changeNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 27/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/api.md | spec/architecture.md
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
@@ -49,6 +50,7 @@ enum TerminalItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#beginBGTask
|
||||
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
|
||||
var id: UIBackgroundTaskIdentifier!
|
||||
var running = true
|
||||
@@ -86,12 +88,14 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
return r
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatSendCmdSync
|
||||
@inline(__always)
|
||||
func chatSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R {
|
||||
let res: APIResult<R> = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
|
||||
return try apiResult(res)
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmdSync
|
||||
func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult<R> {
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
@@ -112,12 +116,14 @@ func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = tru
|
||||
return resp
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatSendCmd
|
||||
@inline(__always)
|
||||
func chatSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R {
|
||||
let res: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
|
||||
return try apiResult(res)
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmdWithRetry
|
||||
func chatApiSendCmdWithRetry<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue<Bool>? = nil, retryNum: Int32 = 0) async -> APIResult<R>? {
|
||||
let r: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum)
|
||||
if inProgress == nil || inProgress?.boxedValue == true,
|
||||
@@ -210,6 +216,7 @@ func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String)
|
||||
String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer))
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmd
|
||||
@inline(__always)
|
||||
func chatApiSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult<R> {
|
||||
await withCheckedContinuation { cont in
|
||||
@@ -226,6 +233,7 @@ func apiResult<R: ChatAPIResult>(_ res: APIResult<R>) throws -> R {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatRecvMsg
|
||||
func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult<ChatEvent>? {
|
||||
await withCheckedContinuation { cont in
|
||||
_ = withBGTask(bgDelay: msgDelay) { () -> APIResult<ChatEvent>? in
|
||||
@@ -346,6 +354,7 @@ func apiStopChat() async throws {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#apiActivateChat
|
||||
func apiActivateChat() {
|
||||
chatReopenStore()
|
||||
do {
|
||||
@@ -355,6 +364,7 @@ func apiActivateChat() {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#apiSuspendChat
|
||||
func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
do {
|
||||
try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
|
||||
@@ -363,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#apiSetAppFilePaths
|
||||
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#apiSetEncryptLocalFiles
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable))
|
||||
}
|
||||
@@ -491,8 +503,8 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?,
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
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)
|
||||
func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl)
|
||||
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
|
||||
}
|
||||
|
||||
@@ -524,8 +536,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws {
|
||||
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
|
||||
}
|
||||
|
||||
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)
|
||||
func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
|
||||
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages)
|
||||
return await processSendMessageCmd(toChatType: type, cmd: cmd)
|
||||
}
|
||||
|
||||
@@ -746,6 +758,15 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) {
|
||||
let userId = try currentUserId("testChatRelay")
|
||||
let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address))
|
||||
if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r {
|
||||
return (relayProfile, relayTestFailure)
|
||||
}
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func getServerOperators() async throws -> ServerOperatorConditions {
|
||||
let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators)
|
||||
if case let .serverOperatorConditions(conditions) = r { return conditions }
|
||||
@@ -783,10 +804,10 @@ func setUserServers(userServers: [UserOperatorServers]) async throws {
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] {
|
||||
func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) {
|
||||
let userId = try currentUserId("validateServers")
|
||||
let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers))
|
||||
if case let .userServersValidation(_, serverErrors) = r { return serverErrors }
|
||||
if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) }
|
||||
logger.error("validateServers error: \(String(describing: r))")
|
||||
throw r.unexpected
|
||||
}
|
||||
@@ -878,6 +899,12 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett
|
||||
try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings))
|
||||
}
|
||||
|
||||
func apiGetUpdatedGroupLinkData(_ groupId: Int64) async -> GroupInfo? {
|
||||
let r: APIResult<ChatResponse0> = await chatApiSendCmd(.apiGetUpdatedGroupLinkData(groupId: groupId))
|
||||
if case let .result(.groupInfo(_, groupInfo)) = r { return groupInfo }
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
|
||||
let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId))
|
||||
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
|
||||
@@ -1109,9 +1136,9 @@ func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactS
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
|
||||
func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData {
|
||||
let userId = try currentUserId("apiPrepareGroup")
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData))
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData))
|
||||
if case let .newPreparedChat(_, chat) = r { return chat }
|
||||
throw r.unexpected
|
||||
}
|
||||
@@ -1135,9 +1162,9 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? {
|
||||
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
|
||||
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
|
||||
if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo }
|
||||
if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) }
|
||||
if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) }
|
||||
return nil
|
||||
}
|
||||
@@ -1455,6 +1482,7 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#receiveFile
|
||||
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async {
|
||||
await receiveFiles(
|
||||
user: user,
|
||||
@@ -1573,6 +1601,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#cancelFile
|
||||
func cancelFile(user: User, fileId: Int64) async {
|
||||
if let chatItem = await apiCancelFile(fileId: fileId) {
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
@@ -1595,12 +1624,14 @@ func setLocalDeviceName(_ displayName: String) throws {
|
||||
try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName))
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#connectRemoteCtrl
|
||||
func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress))
|
||||
if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) }
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#findKnownRemoteCtrl
|
||||
func findKnownRemoteCtrl() async throws {
|
||||
try await sendCommandOkResp(.findKnownRemoteCtrl)
|
||||
}
|
||||
@@ -1810,6 +1841,22 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? {
|
||||
let userId = try currentUserId("apiNewPublicGroup")
|
||||
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile))
|
||||
switch r {
|
||||
case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)):
|
||||
return (groupInfo, groupLink, groupRelays)
|
||||
default: if let r { throw r.unexpected } else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
|
||||
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId))
|
||||
if case let .result(.groupRelays(_, _, relays)) = r { return relays }
|
||||
return []
|
||||
}
|
||||
|
||||
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
|
||||
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
|
||||
@@ -2078,6 +2125,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#startChat
|
||||
func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws {
|
||||
logger.debug("startChat")
|
||||
let m = ChatModel.shared
|
||||
@@ -2199,6 +2247,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#ChatReceiver
|
||||
class ChatReceiver {
|
||||
private var receiveLoop: Task<Void, Never>?
|
||||
private var receiveMessages = true
|
||||
@@ -2244,6 +2293,7 @@ class ChatReceiver {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#processReceivedMsg
|
||||
func processReceivedMsg(_ res: ChatEvent) async {
|
||||
let m = ChatModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
@@ -2442,9 +2492,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
}
|
||||
case let .groupLinkConnecting(user, groupInfo, hostMember):
|
||||
if !active(user) { return }
|
||||
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
_ = m.upsertGroupMember(groupInfo, hostMember)
|
||||
if let hostConn = hostMember.activeConn {
|
||||
m.dismissConnReqView(hostConn.id)
|
||||
m.removeChat(hostConn.id)
|
||||
@@ -2507,10 +2557,11 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
m.updateGroup(groupInfo)
|
||||
}
|
||||
}
|
||||
case let .userJoinedGroup(user, groupInfo):
|
||||
case let .userJoinedGroup(user, groupInfo, hostMember):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
_ = m.upsertGroupMember(groupInfo, hostMember)
|
||||
}
|
||||
if m.chatId == groupInfo.id {
|
||||
if groupInfo.membership.memberPending {
|
||||
@@ -2542,6 +2593,23 @@ func processReceivedMsg(_ res: ChatEvent) async {
|
||||
m.updateGroup(toGroup)
|
||||
}
|
||||
}
|
||||
case let .groupLinkDataUpdated(user, groupInfo, _, groupRelays, _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
m.updateGroup(groupInfo)
|
||||
let relaysModel = ChannelRelaysModel.shared
|
||||
if relaysModel.groupId == groupInfo.groupId {
|
||||
relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays)
|
||||
}
|
||||
}
|
||||
}
|
||||
case let .groupRelayUpdated(user, groupInfo, member, groupRelay):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
_ = m.upsertGroupMember(groupInfo, member)
|
||||
ChannelRelaysModel.shared.updateRelay(groupInfo, groupRelay)
|
||||
}
|
||||
}
|
||||
case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
|
||||
if active(user) {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 17/01/2022.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
@@ -12,6 +13,7 @@ import SimpleXChat
|
||||
let logger = Logger()
|
||||
|
||||
@main
|
||||
// Spec: spec/architecture.md#SimpleXApp
|
||||
struct SimpleXApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@@ -60,6 +62,7 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spec: spec/architecture.md#scenePhaseHandling
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/theme.md#CurrentColors
|
||||
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
|
||||
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
|
||||
@@ -17,6 +18,7 @@ var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
|
||||
|
||||
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
|
||||
|
||||
// Spec: spec/services/theme.md#AppTheme
|
||||
class AppTheme: ObservableObject, Equatable {
|
||||
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
|
||||
|
||||
@@ -89,6 +91,7 @@ struct ThemedBackground: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#systemInDarkThemeCurrently
|
||||
var systemInDarkThemeCurrently: Bool {
|
||||
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
// Created by Avently on 03.06.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/theme.md
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeManager
|
||||
class ThemeManager {
|
||||
// Spec: spec/services/theme.md#ActiveTheme
|
||||
struct ActiveTheme: Equatable {
|
||||
let name: String
|
||||
let base: DefaultTheme
|
||||
@@ -41,6 +44,7 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#defaultActiveTheme
|
||||
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||
@@ -56,6 +60,7 @@ class ThemeManager {
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#currentColors
|
||||
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||
let themeName = currentThemeDefault.get()
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
@@ -96,6 +101,7 @@ class ThemeManager {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#currentThemeOverridesForExport
|
||||
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
|
||||
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
|
||||
let wType = current.wallpaper.type
|
||||
@@ -114,6 +120,7 @@ class ThemeManager {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#applyTheme
|
||||
static func applyTheme(_ theme: String) {
|
||||
currentThemeDefault.set(theme)
|
||||
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
@@ -125,6 +132,7 @@ class ThemeManager {
|
||||
// applyNavigationBarColors(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#adjustWindowStyle
|
||||
static func adjustWindowStyle() {
|
||||
let style = switch currentThemeDefault.get() {
|
||||
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
|
||||
@@ -161,6 +169,7 @@ class ThemeManager {
|
||||
AppTheme.shared.updateFromCurrentColors()
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyThemeColor
|
||||
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let nonSystemThemeName = baseTheme.themeName
|
||||
let pref = pref ?? themeOverridesDefault
|
||||
@@ -178,6 +187,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyWallpaper
|
||||
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
|
||||
let nonSystemThemeName = baseTheme.themeName
|
||||
let pref = pref ?? themeOverridesDefault
|
||||
@@ -253,6 +263,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = prevValue
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyThemeOverrides
|
||||
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let wallpaper = theme.wallpaper?.importFromString()
|
||||
let nonSystemThemeName = theme.base.themeName
|
||||
@@ -273,6 +284,7 @@ class ThemeManager {
|
||||
applyTheme(nonSystemThemeName)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#resetAllThemeColors
|
||||
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||
@@ -295,6 +307,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = prevValue
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#removeTheme
|
||||
static func removeTheme(_ themeId: String?) {
|
||||
var themes = themeOverridesDefault.get().map { $0 }
|
||||
themes.removeAll(where: { $0.themeId == themeId })
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by Evgeny on 05/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
import SimpleXChat
|
||||
import AVFoundation
|
||||
|
||||
// Spec: spec/services/calls.md#ActiveCallView
|
||||
struct ActiveCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -282,6 +284,7 @@ struct ActiveCallView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#ActiveCallOverlay
|
||||
struct ActiveCallOverlay: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var call: Call
|
||||
@@ -350,6 +353,7 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#audioCallInfoView
|
||||
private func audioCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Text(call.contact.chatViewName)
|
||||
@@ -399,6 +403,7 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#endCallButton
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", .red, padding: 10) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 21/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
@@ -14,6 +15,7 @@ import AVFoundation
|
||||
import SimpleXChat
|
||||
import WebRTC
|
||||
|
||||
// Spec: spec/services/calls.md#CallController
|
||||
class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject {
|
||||
static let shared = CallController()
|
||||
static let isInChina = SKStorefront().countryCode == "CHN"
|
||||
@@ -49,6 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
logger.debug("CallController.providerDidReset")
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXStartCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
@@ -59,6 +62,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXAnswerCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
Task {
|
||||
@@ -88,6 +92,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXEndCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
logger.debug("CallController.provider CXEndCallAction")
|
||||
// Should be nil here if connection was in connected state
|
||||
@@ -103,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXSetMutedCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
action.fulfill()
|
||||
@@ -192,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#pushRegistryDidReceive
|
||||
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||
logger.debug("CallController: did receive push with type \(type.rawValue)")
|
||||
if type != .voIP {
|
||||
@@ -276,6 +283,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
reportExpiredCall(update: update, completion)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#reportNewIncomingCall
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
@@ -316,6 +324,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#reportOutgoingCall
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting outgoing call connected")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
@@ -422,6 +431,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
provider.configuration = conf
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#hasActiveCalls
|
||||
func hasActiveCalls() -> Bool {
|
||||
controller.callObserver.calls.count > 0
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// Created by Avently on 09.02.2023.
|
||||
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import WebRTC
|
||||
import LZString
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCClient
|
||||
final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDelegate, RTCFrameDecryptorDelegate {
|
||||
private static let factory: RTCPeerConnectionFactory = {
|
||||
RTCInitializeSSL()
|
||||
@@ -87,6 +89,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
|
||||
]
|
||||
|
||||
// Spec: spec/services/calls.md#initializeCall
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||
connection.delegate = self
|
||||
@@ -132,6 +135,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#createPeerConnection
|
||||
func createPeerConnection(_ iceServers: [WebRTC.RTCIceServer], _ relay: Bool?) -> RTCPeerConnection {
|
||||
let constraints = RTCMediaConstraints(mandatoryConstraints: nil,
|
||||
optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue])
|
||||
@@ -157,6 +161,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
return config
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#addIceCandidates
|
||||
func addIceCandidates(_ connection: RTCPeerConnection, _ remoteIceCandidates: [RTCIceCandidate]) {
|
||||
remoteIceCandidates.forEach { candidate in
|
||||
connection.add(candidate.toWebRTCCandidate()) { error in
|
||||
@@ -167,6 +172,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#sendCallCommand
|
||||
func sendCallCommand(command: WCallCommand) async {
|
||||
var resp: WCallResponse? = nil
|
||||
let pc = activeCall?.connection
|
||||
@@ -295,6 +301,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#sendIceCandidates
|
||||
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
@@ -353,6 +360,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#enableMedia
|
||||
@MainActor
|
||||
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
|
||||
@@ -411,6 +419,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
localRendererAspectRatio.wrappedValue = size.width / size.height
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#setupLocalTracks
|
||||
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
|
||||
let pc = call.connection
|
||||
let transceivers = call.connection.transceivers
|
||||
@@ -490,6 +499,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
|
||||
// Should be called after local description set
|
||||
// Spec: spec/services/calls.md#setupEncryptionForLocalTracks
|
||||
func setupEncryptionForLocalTracks(_ call: Call) {
|
||||
if let encryptor = call.frameEncryptor {
|
||||
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
||||
@@ -567,6 +577,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#startCaptureLocalVideo
|
||||
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
@@ -630,6 +641,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
return (localCamera, localVideoTrack)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#endCall
|
||||
func endCall() {
|
||||
if #available(iOS 16.0, *) {
|
||||
_endCall()
|
||||
|
||||
@@ -57,6 +57,18 @@ struct ChatInfoToolbar: View {
|
||||
.padding(.top, -2)
|
||||
}
|
||||
}
|
||||
.if (channelSubscriberCount != nil) { v in
|
||||
VStack(spacing: 0) {
|
||||
v
|
||||
if let count = channelSubscriberCount {
|
||||
Text(subscriberCountStr(count))
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(1)
|
||||
.padding(.top, -2)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let contact = chat.chatInfo.contact,
|
||||
contact.ready && contact.active,
|
||||
let chatSubStatus = m.chatSubStatus,
|
||||
@@ -69,6 +81,17 @@ struct ChatInfoToolbar: View {
|
||||
.frame(width: 220)
|
||||
}
|
||||
|
||||
private var channelSubscriberCount: Int64? {
|
||||
if case let .group(groupInfo, _) = chat.chatInfo,
|
||||
groupInfo.useRelays,
|
||||
let count = groupInfo.groupSummary.publicMemberCount,
|
||||
count > 0 {
|
||||
count
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private var contactVerifiedShield: Text {
|
||||
(Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.font(.caption)
|
||||
@@ -102,6 +125,12 @@ struct ChatInfoToolbar: View {
|
||||
}
|
||||
}
|
||||
|
||||
public func subscriberCountStr(_ count: Int64) -> String {
|
||||
count == 1
|
||||
? String.localizedStringWithFormat(NSLocalizedString("%d subscriber", comment: "channel subscriber count"), count)
|
||||
: String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count)
|
||||
}
|
||||
|
||||
struct ChatInfoToolbar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 05/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
@preconcurrency import SimpleXChat
|
||||
@@ -88,6 +89,7 @@ enum SendReceipts: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatInfoView
|
||||
struct ChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
// Created by Avently on 19.12.2022.
|
||||
// Copyright (c) 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
// Spec: spec/client/chat-view.md#AnimatedImageView
|
||||
class AnimatedImageView: UIView {
|
||||
var image: UIImage? = nil
|
||||
var imageView: UIImageView? = nil
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 20/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny on 21/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIChatFeatureView
|
||||
struct CIChatFeatureView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.revealed) var revealed: Bool
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 20.07.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIEventView
|
||||
struct CIEventView: View {
|
||||
var eventText: Text
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny on 21/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIFeaturePreferenceView
|
||||
struct CIFeaturePreferenceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 28/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIFileView
|
||||
struct CIFileView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 15.07.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIGroupInvitationView
|
||||
struct CIGroupInvitationView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 12/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIImageView
|
||||
struct CIImageView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let chatItem: ChatItem
|
||||
@@ -96,12 +98,13 @@ struct CIImageView: View {
|
||||
if img.imageData == nil {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w)
|
||||
.scaledToFill()
|
||||
.frame(width: w, height: w * heightRatio(img.size))
|
||||
.clipped()
|
||||
} else {
|
||||
SwiftyGif(image: img)
|
||||
.frame(width: w, height: w * img.size.height / img.size.width)
|
||||
.scaledToFit()
|
||||
SwiftyGif(image: img, contentMode: .scaleAspectFill)
|
||||
.frame(width: w, height: w * heightRatio(img.size))
|
||||
.clipped()
|
||||
}
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
loadingIndicator()
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 29.12.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIInvalidJSONView
|
||||
struct CIInvalidJSONView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var json: Data?
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Ian Davies on 07/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CILinkView
|
||||
struct CILinkView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
let linkPreview: LinkPreview
|
||||
@@ -19,7 +21,8 @@ struct CILinkView: View {
|
||||
if let uiImage = imageFromBase64(linkPreview.image) {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.aspectRatio(1 / heightRatio(uiImage.size), contentMode: .fill)
|
||||
.clipped()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
.if(!blurred) { v in
|
||||
v.simultaneousGesture(TapGesture().onEnded {
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by spaced4ndy on 19.09.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIMemberCreatedContactView
|
||||
struct CIMemberCreatedContactView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny Poberezkin on 11/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIMetaView
|
||||
struct CIMetaView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by Evgeny on 15/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIRcvDecryptionError
|
||||
struct CIRcvDecryptionError: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by Avently on 30/03/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SimpleXChat
|
||||
import Combine
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIVideoView
|
||||
struct CIVideoView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
private let chatItem: ChatItem
|
||||
@@ -185,7 +187,8 @@ struct CIVideoView: View {
|
||||
ZStack(alignment: .center) {
|
||||
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
|
||||
VideoPlayerView(player: player, url: url, showControls: false)
|
||||
.frame(width: w, height: w * preview.size.height / preview.size.width)
|
||||
.frame(width: w, height: w * heightRatio(preview.size))
|
||||
.clipped()
|
||||
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
|
||||
if playingUrl != url {
|
||||
player.pause()
|
||||
@@ -313,8 +316,9 @@ struct CIVideoView: View {
|
||||
return ZStack(alignment: .topTrailing) {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w)
|
||||
.scaledToFill()
|
||||
.frame(width: w, height: w * heightRatio(img.size))
|
||||
.clipped()
|
||||
.modifier(PrivacyBlur(blurred: $blurred))
|
||||
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
|
||||
fileStatusIcon()
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 22.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#CIVoiceView
|
||||
struct CIVoiceView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#DeletedItemView
|
||||
struct DeletedItemView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny Poberezkin on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#EmojiItemView
|
||||
struct EmojiItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by JRoberts on 22.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#FramedCIVoiceView
|
||||
struct FramedCIVoiceView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny Poberezkin on 04/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#FramedItemView
|
||||
struct FramedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by Evgeny on 08/10/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
import SwiftyGif
|
||||
import AVKit
|
||||
|
||||
// Spec: spec/client/chat-view.md#FullScreenMediaView
|
||||
struct FullScreenMediaView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@State var chatItem: ChatItem
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny on 28/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#IntegrityErrorItemView
|
||||
struct IntegrityErrorItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by JRoberts on 30.11.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#MarkedDeletedItemView
|
||||
struct MarkedDeletedItemView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 13/03/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -23,6 +24,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.
|
||||
return res
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#MsgContentView
|
||||
struct MsgContentView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Environment(\.showTimestamp) var showTimestamp: Bool
|
||||
@@ -320,6 +322,7 @@ func messageText(
|
||||
var bold: UIFont?
|
||||
var italic: UIFont?
|
||||
var snippet: UIFont?
|
||||
var small: UIFont?
|
||||
var mention: UIFont?
|
||||
var secretIdx: Int = 0
|
||||
for ft in fts {
|
||||
@@ -351,6 +354,10 @@ func messageText(
|
||||
attrs[.backgroundColor] = secretColor
|
||||
}
|
||||
hasSecrets = true
|
||||
case .small:
|
||||
small = small ?? UIFont.preferredFont(forTextStyle: .footnote)
|
||||
attrs[.font] = small
|
||||
attrs[.foregroundColor] = UIColor.secondaryLabel
|
||||
case let .colored(color):
|
||||
if let c = color.uiColor {
|
||||
attrs[.foregroundColor] = UIColor(c)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatItemInfoView
|
||||
struct ChatItemInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@@ -38,6 +38,7 @@ extension EnvironmentValues {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatItemView
|
||||
struct ChatItemView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@ObservedObject var im: ItemsModel
|
||||
@@ -194,7 +195,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
}
|
||||
|
||||
private func pendingReviewEventItemText() -> Text {
|
||||
Text(chatItem.content.text)
|
||||
Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel))
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.fontWeight(.bold)
|
||||
@@ -208,9 +209,9 @@ struct ChatItemContentView<Content: View>: View {
|
||||
.font(.caption)
|
||||
.foregroundColor(secondaryColor)
|
||||
.fontWeight(.light)
|
||||
+ chatEventText(chatItem, secondaryColor)
|
||||
+ chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
|
||||
} else {
|
||||
return chatEventText(chatItem, secondaryColor)
|
||||
return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +234,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
return if count <= 1 {
|
||||
nil
|
||||
} else if ns.count == 0 {
|
||||
Text("\(count) group events")
|
||||
chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events")
|
||||
} else if count > ns.count {
|
||||
Text(members) + textSpace + Text("and \(count - ns.count) other events")
|
||||
} else {
|
||||
@@ -274,8 +275,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor
|
||||
chatEventText(Text(eventText) + textSpace + ts, secondaryColor)
|
||||
}
|
||||
|
||||
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
|
||||
chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor)
|
||||
func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text {
|
||||
chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor)
|
||||
}
|
||||
|
||||
struct ChatItemView_Previews: PreviewProvider {
|
||||
|
||||
@@ -267,6 +267,7 @@ struct ListItem: Hashable {
|
||||
case .directRcv: 1
|
||||
case .groupSnd: 2
|
||||
case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash
|
||||
case .channelRcv: 3
|
||||
case .localSnd: 4
|
||||
case .localRcv: 5
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 27/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -13,6 +14,7 @@ import Combine
|
||||
|
||||
private let memberImageSize: CGFloat = 34
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatView
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@StateObject private var connectProgressManager = ConnectProgressManager.shared
|
||||
@@ -63,13 +65,13 @@ struct ChatView: View {
|
||||
@State private var showUserSupportChatSheet = false
|
||||
@State private var showCommandsMenu = false
|
||||
@State private var supportChatMemberInfoLinkActive = false
|
||||
|
||||
@State private var scrollView: EndlessScrollView<MergedItem> = EndlessScrollView(frame: .zero)
|
||||
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
|
||||
|
||||
// Spec: spec/client/chat-view.md#body
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
@@ -132,12 +134,6 @@ struct ChatView: View {
|
||||
.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,
|
||||
@@ -146,17 +142,8 @@ struct ChatView: View {
|
||||
keyboardVisible: $keyboardVisible,
|
||||
keyboardHiddenDate: $keyboardHiddenDate,
|
||||
selectedRange: $selectedRange,
|
||||
disabledText: reason?.composeLabel
|
||||
disabledText: chat.chatInfo.userCantSendReason?.composeLabel
|
||||
)
|
||||
.disabled(!composeEnabled)
|
||||
.if(!composeEnabled) { v in
|
||||
v.disabled(true).onTapGesture {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "You can't send messages!",
|
||||
message: reason?.alertMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SelectedItemsBottomToolbar(
|
||||
im: im,
|
||||
@@ -266,6 +253,18 @@ struct ChatView: View {
|
||||
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
|
||||
}
|
||||
}
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
if case let .group(groupInfo, _) = cInfo {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false,
|
||||
isChannel: groupInfo.useRelays
|
||||
)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: Binding(
|
||||
get: { !forwardedChatItems.isEmpty },
|
||||
set: { isPresented in
|
||||
@@ -348,6 +347,13 @@ struct ChatView: View {
|
||||
|
||||
if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] {
|
||||
scrollView.scrollToItem(index)
|
||||
} else if let viewedIdx = mergedItems.boxedValue.items.firstIndex(where: { !$0.hasUnread() }) {
|
||||
// scroll to first unread after last viewed item (items reversed: 0 = newest)
|
||||
if viewedIdx > 0 {
|
||||
scrollView.scrollToItem(viewedIdx - 1)
|
||||
} else {
|
||||
scrollView.scrollToBottom()
|
||||
}
|
||||
} else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) {
|
||||
scrollView.scrollToItem(unreadIndex)
|
||||
} else {
|
||||
@@ -402,6 +408,7 @@ struct ChatView: View {
|
||||
chatModel.groupMembers = []
|
||||
chatModel.groupMembersIndexes.removeAll()
|
||||
chatModel.membersLoaded = false
|
||||
ChannelRelaysModel.shared.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -530,32 +537,51 @@ struct ChatView: View {
|
||||
case let .direct(contact):
|
||||
HStack {
|
||||
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
|
||||
let canStartCall = callsPrefEnabled && contact.ready && contact.active && chatModel.activeCall == nil
|
||||
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
endCallButton(call)
|
||||
} else {
|
||||
contentFilterMenu(withLabel: false)
|
||||
}
|
||||
Menu {
|
||||
if callsPrefEnabled && chatModel.activeCall == nil {
|
||||
} else if canStartCall {
|
||||
// Call button always in toolbar; tap opens Audio/Video submenu
|
||||
Menu {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .audio)
|
||||
} label: {
|
||||
Label("Audio call", systemImage: "phone")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
Button {
|
||||
CallController.shared.startCall(contact, .video)
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
if let call = chatModel.activeCall, call.contact.id == cInfo.id {
|
||||
contentFilterMenu(withLabel: true)
|
||||
} label: {
|
||||
Image(systemName: "phone")
|
||||
}
|
||||
} else if chatModel.activeCall == nil {
|
||||
// Calls unavailable: show filter button in place of call button
|
||||
contentFilterMenu(withLabel: false)
|
||||
}
|
||||
Menu {
|
||||
searchButton()
|
||||
ToggleNtfsButton(chat: chat)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
// Filter options in menu when call button is shown (or during any active call)
|
||||
if !availableContent.isEmpty && (canStartCall || chatModel.activeCall != nil) {
|
||||
Divider()
|
||||
ForEach(availableContent, id: \.self) { type in
|
||||
Button {
|
||||
setContentFilter(type)
|
||||
} label: {
|
||||
Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon)
|
||||
}
|
||||
}
|
||||
if contentFilter != nil {
|
||||
Button {
|
||||
closeSearch()
|
||||
} label: {
|
||||
Label("All messages", systemImage: "bubble.left.and.text.bubble.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -565,17 +591,8 @@ struct ChatView: View {
|
||||
contentFilterMenu(withLabel: false)
|
||||
Menu {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
if chat.chatInfo.incognito || groupInfo.useRelays {
|
||||
groupLinkButton()
|
||||
.appSheet(isPresented: $showGroupLinkSheet) {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: true,
|
||||
creatingGroup: false
|
||||
)
|
||||
}
|
||||
} else {
|
||||
addMembersButton()
|
||||
}
|
||||
@@ -588,7 +605,26 @@ struct ChatView: View {
|
||||
}
|
||||
case .local:
|
||||
HStack {
|
||||
contentFilterMenu(withLabel: false)
|
||||
if !availableContent.isEmpty {
|
||||
Menu {
|
||||
ForEach(availableContent, id: \.self) { type in
|
||||
Button {
|
||||
setContentFilter(type)
|
||||
} label: {
|
||||
Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon)
|
||||
}
|
||||
}
|
||||
if contentFilter != nil {
|
||||
Button {
|
||||
closeSearch()
|
||||
} label: {
|
||||
Label("All messages", systemImage: "bubble.left.and.text.bubble.right")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
}
|
||||
searchButton()
|
||||
}
|
||||
default:
|
||||
@@ -668,6 +704,7 @@ struct ChatView: View {
|
||||
.frame(width: 220)
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#initChatView
|
||||
private func initChatView() {
|
||||
let cInfo = chat.chatInfo
|
||||
// This check prevents the call to apiContactInfo after the app is suspended, and the database is closed.
|
||||
@@ -697,6 +734,25 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays {
|
||||
Task { await chatModel.loadGroupMembers(groupInfo) }
|
||||
if groupInfo.membership.memberRole == .owner {
|
||||
Task {
|
||||
let relays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Task {
|
||||
if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) {
|
||||
await MainActor.run {
|
||||
chatModel.updateGroup(gInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateAvailableContent()
|
||||
}
|
||||
if chatModel.draftChatId == cInfo.id && !composeState.forwarding,
|
||||
@@ -727,6 +783,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#scrollToItem
|
||||
private func scrollToItem(_ itemId: ChatItem.ID) {
|
||||
Task {
|
||||
do {
|
||||
@@ -760,6 +817,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#searchToolbar
|
||||
private func searchToolbar() -> some View {
|
||||
let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search"
|
||||
return HStack(spacing: 12) {
|
||||
@@ -797,6 +855,7 @@ struct ChatView: View {
|
||||
ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#filtered
|
||||
private func filtered(_ reversedChatItems: Array<ChatItem>) -> Array<ChatItem> {
|
||||
reversedChatItems
|
||||
.enumerated()
|
||||
@@ -810,6 +869,7 @@ struct ChatView: View {
|
||||
.map { $0.element }
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#chatItemsList
|
||||
private func chatItemsList() -> some View {
|
||||
let cInfo = chat.chatInfo
|
||||
return GeometryReader { g in
|
||||
@@ -857,6 +917,7 @@ struct ChatView: View {
|
||||
selectedChatItems: $selectedChatItems,
|
||||
forwardedChatItems: $forwardedChatItems,
|
||||
searchText: $searchText,
|
||||
contentFilter: $contentFilter,
|
||||
closeKeyboardAndRun: closeKeyboardAndRun
|
||||
)
|
||||
}
|
||||
@@ -1021,12 +1082,12 @@ struct ChatView: View {
|
||||
switch groupInfo.businessChat?.chatType {
|
||||
case .none:
|
||||
if groupInfo.nextConnectPrepared {
|
||||
"Tap Join group"
|
||||
groupInfo.useRelays ? "Tap Join channel" : "Tap Join group"
|
||||
} else {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited: "Join group"
|
||||
case .memCreator: "Your group"
|
||||
default: "Group"
|
||||
case .memInvited: groupInfo.useRelays ? "Join channel" : "Join group"
|
||||
case .memCreator: groupInfo.useRelays ? "Your channel" : "Your group"
|
||||
default: groupInfo.useRelays ? "Channel" : "Group"
|
||||
}
|
||||
}
|
||||
case .business:
|
||||
@@ -1054,10 +1115,14 @@ struct ChatView: View {
|
||||
nil
|
||||
}
|
||||
case let .group(groupInfo, _):
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
|
||||
case .memAccepted: "connecting…"
|
||||
default: nil
|
||||
if groupInfo.useRelays {
|
||||
nil
|
||||
} else {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil
|
||||
case .memAccepted: "connecting…"
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
default: nil
|
||||
}
|
||||
@@ -1083,6 +1148,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#searchTextChanged
|
||||
private func searchTextChanged(_ s: String) {
|
||||
Task {
|
||||
await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s)
|
||||
@@ -1260,6 +1326,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#callButton
|
||||
private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View {
|
||||
Button {
|
||||
CallController.shared.startCall(contact, media)
|
||||
@@ -1374,7 +1441,11 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Group link", systemImage: "link.badge.plus")
|
||||
if case let .group(gInfo, _) = chat.chatInfo, gInfo.useRelays {
|
||||
Label("Channel link", systemImage: "link")
|
||||
} else {
|
||||
Label("Group link", systemImage: "link.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1397,6 +1468,7 @@ struct ChatView: View {
|
||||
))
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#deletedSelectedMessages
|
||||
private func deletedSelectedMessages() async {
|
||||
await MainActor.run {
|
||||
withAnimation {
|
||||
@@ -1405,6 +1477,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#forwardSelectedMessages
|
||||
private func forwardSelectedMessages() {
|
||||
Task {
|
||||
do {
|
||||
@@ -1515,6 +1588,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#loadChatItems
|
||||
private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool {
|
||||
if loadingMoreItems { return false }
|
||||
await MainActor.run {
|
||||
@@ -1555,6 +1629,7 @@ struct ChatView: View {
|
||||
VoiceItemState.chatView = [:]
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#onChatItemsUpdated
|
||||
func onChatItemsUpdated() {
|
||||
if !mergedItems.boxedValue.isActualState() {
|
||||
//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)")
|
||||
@@ -1582,6 +1657,7 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatItemWithMenu
|
||||
private struct ChatItemWithMenu: View {
|
||||
@ObservedObject var im: ItemsModel
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@@ -1616,12 +1692,14 @@ struct ChatView: View {
|
||||
@Binding var forwardedChatItems: [ChatItem]
|
||||
|
||||
@Binding var searchText: String
|
||||
@Binding var contentFilter: ContentFilter?
|
||||
var closeKeyboardAndRun: (@escaping () -> Void) -> Void
|
||||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
@State private var markReadTask: Task<Void, Never>? = nil
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
@State private var swipeOffset: CGFloat = 0
|
||||
|
||||
var revealed: Bool { revealedItems.contains(chatItem.id) }
|
||||
|
||||
@@ -1638,6 +1716,8 @@ struct ChatView: View {
|
||||
|
||||
let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir {
|
||||
groupMember.groupMemberId == prevGroupMember.groupMemberId
|
||||
} else if case .channelRcv = chatItem.chatDir, case .channelRcv = prevItem.chatDir {
|
||||
true
|
||||
} else {
|
||||
chatItem.chatDir.sent == prevItem.chatDir.sent
|
||||
}
|
||||
@@ -1653,16 +1733,21 @@ struct ChatView: View {
|
||||
func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool {
|
||||
let oldIsGroupRcv = switch older?.chatDir {
|
||||
case .groupRcv: true
|
||||
case .channelRcv: true
|
||||
default: false
|
||||
}
|
||||
let sameMember = switch (older?.chatDir, current.chatDir) {
|
||||
case (.groupRcv(let oldMember), .groupRcv(let member)):
|
||||
oldMember.memberId == member.memberId
|
||||
case (.channelRcv, .channelRcv):
|
||||
true
|
||||
default:
|
||||
false
|
||||
}
|
||||
if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
|
||||
return true
|
||||
} else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
@@ -1773,7 +1858,7 @@ struct ChatView: View {
|
||||
|
||||
private var searchIsNotBlank: Bool {
|
||||
get {
|
||||
searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
(searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) || contentFilter != nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1828,7 +1913,74 @@ struct ChatView: View {
|
||||
_ itemSeparation: ItemSeparation
|
||||
) -> some View {
|
||||
let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2
|
||||
if case let .groupRcv(member) = ci.chatDir,
|
||||
if case .channelRcv = ci.chatDir,
|
||||
case let .group(groupInfo, _) = chat.chatInfo {
|
||||
if showAvatar {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if ci.content.showMemberName {
|
||||
Group {
|
||||
Group {
|
||||
if #available(iOS 16.0, *) {
|
||||
MemberLayout(spacing: 16, msgWidth: msgWidth) {
|
||||
Text(groupInfo.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages"))
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 16) {
|
||||
Text(groupInfo.chatViewName)
|
||||
.lineLimit(1)
|
||||
Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages"))
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(1)
|
||||
.layoutPriority(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(
|
||||
maxWidth: maxWidth,
|
||||
alignment: chatItem.chatDir.sent ? .trailing : .leading
|
||||
)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
|
||||
.padding(.top, 3)
|
||||
}
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
if selectedChatItems != nil && ci.canBeDeletedForSelf {
|
||||
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
|
||||
.padding(.trailing, 12)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.simultaneousGesture(TapGesture().onEnded {
|
||||
showChatInfoSheet = true
|
||||
})
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, bottomPadding)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 12)
|
||||
} else {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
if selectedChatItems != nil && ci.canBeDeletedForSelf {
|
||||
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
|
||||
.padding(.leading, 12)
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
.padding(.trailing)
|
||||
.padding(.leading, 10 + memberImageSize + 12)
|
||||
}
|
||||
.padding(.bottom, bottomPadding)
|
||||
}
|
||||
} else if case let .groupRcv(member) = ci.chatDir,
|
||||
case let .group(groupInfo, _) = chat.chatInfo {
|
||||
if showAvatar {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@@ -1956,33 +2108,69 @@ struct ChatView: View {
|
||||
|
||||
func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
|
||||
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
|
||||
return VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
HStack {
|
||||
if ci.chatDir.sent {
|
||||
goToItemButton(true)
|
||||
let live = composeState.liveMessage != nil
|
||||
let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil
|
||||
return ZStack(alignment: .trailing) {
|
||||
Image(systemName: "arrowshape.turn.up.left")
|
||||
.font(.system(size: 18))
|
||||
.foregroundColor(.secondary)
|
||||
.opacity(min(1, -swipeOffset / 30))
|
||||
.offset(x: swipeOffset + 40)
|
||||
VStack(alignment: alignment.horizontal, spacing: 3) {
|
||||
HStack {
|
||||
if ci.chatDir.sent {
|
||||
goToItemButton(true)
|
||||
}
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
im: im,
|
||||
chatItem: ci,
|
||||
scrollToItem: scrollToItem,
|
||||
scrollToItemId: $scrollToItemId,
|
||||
maxWidth: maxWidth,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.environment(\.revealed, revealed)
|
||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||
.contextMenu { menu(ci, range, live: live) }
|
||||
.accessibilityLabel("")
|
||||
if !ci.chatDir.sent {
|
||||
goToItemButton(false)
|
||||
}
|
||||
}
|
||||
ChatItemView(
|
||||
chat: chat,
|
||||
im: im,
|
||||
chatItem: ci,
|
||||
scrollToItem: scrollToItem,
|
||||
scrollToItemId: $scrollToItemId,
|
||||
maxWidth: maxWidth,
|
||||
allowMenu: $allowMenu
|
||||
)
|
||||
.environment(\.revealed, revealed)
|
||||
.environment(\.showTimestamp, itemSeparation.timestamp)
|
||||
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
|
||||
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
|
||||
.accessibilityLabel("")
|
||||
if !ci.chatDir.sent {
|
||||
goToItemButton(false)
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions(ci)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
|
||||
chatItemReactions(ci)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.offset(x: swipeOffset)
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 10)
|
||||
.onChanged { value in
|
||||
guard canReply else { return }
|
||||
let x = value.translation.width
|
||||
if x < 0 {
|
||||
swipeOffset = max(x * 0.63, -56)
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
if swipeOffset < -42 {
|
||||
withAnimation {
|
||||
if composeState.editing {
|
||||
composeState = ComposeState(contextItem: .quotedItem(chatItem: ci))
|
||||
} else {
|
||||
composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci))
|
||||
}
|
||||
}
|
||||
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
||||
}
|
||||
withAnimation(.spring(response: 0.25)) {
|
||||
swipeOffset = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
|
||||
Button("Delete for me", role: .destructive) {
|
||||
@@ -2028,6 +2216,7 @@ struct ChatView: View {
|
||||
switch (prevItem?.chatDir) {
|
||||
case .groupSnd: return true
|
||||
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
|
||||
case .channelRcv: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
@@ -2693,6 +2882,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#FloatingButtonModel
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
@ObservedObject var im: ItemsModel
|
||||
|
||||
@@ -2775,6 +2965,7 @@ private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
|
||||
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#deleteMessages
|
||||
private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) {
|
||||
let itemIds = deletingItems
|
||||
if itemIds.count > 0 {
|
||||
@@ -2878,6 +3069,7 @@ private func buildTheme() -> AppTheme {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ReactionContextMenu
|
||||
struct ReactionContextMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let groupInfo: GroupInfo
|
||||
@@ -3027,6 +3219,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ContentFilter
|
||||
enum ContentFilter: CaseIterable {
|
||||
case images
|
||||
case videos
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Spec: spec/client/compose.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -6,6 +7,7 @@ import PhotosUI
|
||||
|
||||
let MAX_NUMBER_OF_MENTIONS = 3
|
||||
|
||||
// Spec: spec/client/compose.md#ComposePreview
|
||||
enum ComposePreview {
|
||||
case noPreview
|
||||
case linkPreview(linkPreview: LinkPreview?)
|
||||
@@ -14,6 +16,7 @@ enum ComposePreview {
|
||||
case filePreview(fileName: String, file: URL)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#ComposeContextItem
|
||||
enum ComposeContextItem: Equatable {
|
||||
case noContextItem
|
||||
case quotedItem(chatItem: ChatItem)
|
||||
@@ -22,12 +25,14 @@ enum ComposeContextItem: Equatable {
|
||||
case reportedItem(chatItem: ChatItem, reason: ReportReason)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#VoiceMessageRecordingState
|
||||
enum VoiceMessageRecordingState {
|
||||
case noRecording
|
||||
case recording
|
||||
case finished
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#LiveMessage
|
||||
struct LiveMessage {
|
||||
var chatItem: ChatItem
|
||||
var typedMsg: String
|
||||
@@ -36,6 +41,7 @@ struct LiveMessage {
|
||||
|
||||
typealias MentionedMembers = [String: CIMention]
|
||||
|
||||
// Spec: spec/client/compose.md#ComposeState
|
||||
struct ComposeState {
|
||||
var message: String
|
||||
var parsedMessage: [FormattedText]
|
||||
@@ -256,6 +262,7 @@ struct ComposeState {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#chatItemPreview
|
||||
func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
switch chatItem.content.msgContent {
|
||||
case .text:
|
||||
@@ -276,6 +283,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#UploadContent
|
||||
enum UploadContent: Equatable {
|
||||
case simpleImage(image: UIImage)
|
||||
case animatedImage(image: UIImage)
|
||||
@@ -317,6 +325,7 @@ enum UploadContent: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#ComposeView
|
||||
struct ComposeView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -355,12 +364,16 @@ struct ComposeView: View {
|
||||
@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
|
||||
@State private var relayListExpanded = false
|
||||
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
|
||||
|
||||
// Spec: spec/client/compose.md#body
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
|
||||
if chat.chatInfo.nextConnectPrepared,
|
||||
!composeState.inProgress,
|
||||
let user = chatModel.currentUser {
|
||||
ContextProfilePickerView(
|
||||
chat: chat,
|
||||
@@ -369,85 +382,152 @@ struct ComposeView: View {
|
||||
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 = 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()
|
||||
} else if fileProhibited {
|
||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||
Divider()
|
||||
} else if voiceProhibited {
|
||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||
Divider()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||
default: previewView()
|
||||
}
|
||||
|
||||
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)
|
||||
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
||||
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
|
||||
if gInfo.membership.memberRole == .owner {
|
||||
let relays = channelRelaysModel.groupId == gInfo.groupId
|
||||
? channelRelaysModel.groupRelays : []
|
||||
let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count
|
||||
let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
|
||||
if !relays.isEmpty && activeCount < relays.count {
|
||||
ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount)
|
||||
}
|
||||
} 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)
|
||||
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
|
||||
let relayMembers = chatModel.groupMembers
|
||||
.filter { $0.wrapped.memberRole == .relay }
|
||||
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
|
||||
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
|
||||
let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count
|
||||
let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count
|
||||
let failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count
|
||||
let errorCount = deletedCount + failedCount
|
||||
let resolvedCount = connectedCount + deletedCount
|
||||
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
||||
if total > 0, !showProgress || resolvedCount < total {
|
||||
subscriberChannelRelayBar(
|
||||
hostnames: hostnames,
|
||||
relayMembers: relayMembers,
|
||||
connectedCount: connectedCount,
|
||||
errorCount: errorCount,
|
||||
total: total,
|
||||
showProgress: showProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
}
|
||||
|
||||
let composeEnabled = (
|
||||
chat.chatInfo.sendMsgEnabled ||
|
||||
(chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) ||
|
||||
(chat.chatInfo.contact?.nextAcceptContactRequest ?? false)
|
||||
)
|
||||
Group {
|
||||
|
||||
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 = 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()
|
||||
} else if fileProhibited {
|
||||
msgNotAllowedView("Files and media not allowed", icon: "doc")
|
||||
Divider()
|
||||
} else if voiceProhibited {
|
||||
msgNotAllowedView("Voice messages not allowed", icon: "mic")
|
||||
Divider()
|
||||
}
|
||||
contextItemView()
|
||||
switch (composeState.editing, composeState.preview) {
|
||||
case (true, .filePreview): EmptyView()
|
||||
case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed
|
||||
default: previewView()
|
||||
}
|
||||
|
||||
let contact = chat.chatInfo.contact
|
||||
|
||||
if chat.chatInfo.groupInfo?.nextConnectPrepared == true {
|
||||
if chat.chatInfo.groupInfo?.businessChat == nil {
|
||||
let isChannel = chat.chatInfo.groupInfo?.useRelays == true
|
||||
connectButtonView(
|
||||
isChannel ? "Join channel" : "Join group",
|
||||
icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "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,
|
||||
placeholder: chat.chatInfo.groupInfo.map { gi in
|
||||
gi.useRelays && gi.membership.memberRole >= .owner
|
||||
? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner")
|
||||
: nil
|
||||
} ?? nil
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
} // Group
|
||||
.disabled(!composeEnabled)
|
||||
.if(!composeEnabled) { v in
|
||||
v.onTapGesture {
|
||||
if let reason = chat.chatInfo.userCantSendReason {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "You can't send messages!",
|
||||
message: reason.alertMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -643,18 +723,175 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View {
|
||||
let total = relays.count
|
||||
let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
||||
return VStack(spacing: 0) {
|
||||
relayBarHeader {
|
||||
if activeCount + failedCount < total {
|
||||
RelayProgressIndicator(active: activeCount, total: total)
|
||||
}
|
||||
if failedCount > 0 {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount))
|
||||
} else {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total))
|
||||
}
|
||||
}
|
||||
if relayListExpanded {
|
||||
ForEach(sorted) { relay in
|
||||
let failedErr = relayMemberConnFailed(relay)
|
||||
if let err = failedErr {
|
||||
Button {
|
||||
showAlert(
|
||||
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
||||
message: err
|
||||
)
|
||||
} label: {
|
||||
ownerRelayDetailRow(relay, connFailed: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
ownerRelayDetailRow(relay, connFailed: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, relayListExpanded ? 4 : 0)
|
||||
.animation(nil, value: relayListExpanded)
|
||||
}
|
||||
|
||||
private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View {
|
||||
relayBarDetailRow {
|
||||
Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary)
|
||||
Spacer()
|
||||
relayStatusIndicator(relay.relayStatus, connFailed: connFailed)
|
||||
}
|
||||
}
|
||||
|
||||
private func subscriberChannelRelayBar(
|
||||
hostnames: [String],
|
||||
relayMembers: [GMember],
|
||||
connectedCount: Int,
|
||||
errorCount: Int,
|
||||
total: Int,
|
||||
showProgress: Bool
|
||||
) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
relayBarHeader {
|
||||
if showProgress && connectedCount + errorCount < total {
|
||||
RelayProgressIndicator(active: connectedCount, total: total)
|
||||
}
|
||||
if showProgress {
|
||||
if errorCount > 0 {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount))
|
||||
} else {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total))
|
||||
}
|
||||
} else {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total))
|
||||
}
|
||||
}
|
||||
if relayListExpanded {
|
||||
if relayMembers.isEmpty {
|
||||
ForEach(hostnames, id: \.self) { relay in
|
||||
relayBarDetailRow {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay)))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ForEach(relayMembers) { member in
|
||||
let m = member.wrapped
|
||||
let host = m.relayLink.map { hostFromRelayLink($0) }
|
||||
let failedErr = m.activeConn?.connFailedErr
|
||||
if let err = failedErr {
|
||||
Button {
|
||||
showAlert(
|
||||
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
||||
message: err
|
||||
)
|
||||
} label: {
|
||||
subscriberRelayDetailRow(m, host: host, connFailed: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
subscriberRelayDetailRow(m, host: host, connFailed: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, relayListExpanded ? 4 : 0)
|
||||
.animation(nil, value: relayListExpanded)
|
||||
}
|
||||
|
||||
private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View {
|
||||
relayBarDetailRow {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Spacer()
|
||||
let status = relayConnStatus(m)
|
||||
Circle()
|
||||
.fill(status.color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(status.text)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if connFailed {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relayBarHeader<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
Button {
|
||||
withAnimation(nil) { relayListExpanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
content()
|
||||
Spacer()
|
||||
Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.opacity(0.7)
|
||||
}
|
||||
.font(.callout)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, relayListExpanded ? 4 : 8)
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
private func relayBarDetailRow<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
||||
HStack {
|
||||
content()
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func relayMemberConnFailed(_ relay: GroupRelay) -> String? {
|
||||
chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?
|
||||
.wrapped.activeConn?.connFailedErr
|
||||
}
|
||||
|
||||
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 {
|
||||
if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true {
|
||||
ProgressView()
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 60)
|
||||
.frame(height: 57)
|
||||
.disabled(composeState.inProgress)
|
||||
}
|
||||
|
||||
@@ -679,6 +916,7 @@ struct ComposeView: View {
|
||||
.padding(.horizontal, 12)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#sendMessageView
|
||||
private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
SendMessageView(
|
||||
@@ -840,9 +1078,12 @@ struct ComposeView: View {
|
||||
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) {
|
||||
if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
|
||||
await MainActor.run {
|
||||
self.chatModel.updateGroup(groupInfo)
|
||||
self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId)
|
||||
self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) }
|
||||
self.chatModel.populateGroupMembersIndexes()
|
||||
clearState()
|
||||
}
|
||||
} else {
|
||||
@@ -878,6 +1119,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#addMediaContent
|
||||
private func addMediaContent(_ content: UploadContent) async {
|
||||
if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) {
|
||||
var newMedia: [(String, UploadContent?)] = []
|
||||
@@ -906,6 +1148,7 @@ struct ComposeView: View {
|
||||
getMaxFileSize(.xftp)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#sendLiveMessage
|
||||
private func sendLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
let lm = composeState.liveMessage
|
||||
@@ -923,6 +1166,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#updateLiveMessage
|
||||
private func updateLiveMessage() async {
|
||||
let typedMsg = composeState.message
|
||||
if let liveMessage = composeState.liveMessage {
|
||||
@@ -941,6 +1185,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#liveMessageToSend
|
||||
private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? {
|
||||
let s = t != lm.typedMsg ? truncateToWords(t) : t
|
||||
return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil
|
||||
@@ -1087,6 +1332,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#sendMessage
|
||||
private func sendMessage(ttl: Int?) {
|
||||
logger.debug("ChatView sendMessage")
|
||||
Task {
|
||||
@@ -1095,6 +1341,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#sendMessageAsync
|
||||
private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? {
|
||||
var sent: ChatItem?
|
||||
let msgText = text ?? composeState.message
|
||||
@@ -1305,6 +1552,7 @@ struct ComposeView: View {
|
||||
type: chat.chatInfo.chatType,
|
||||
id: chat.chatInfo.apiId,
|
||||
scope: chat.chatInfo.groupChatScope(),
|
||||
sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
|
||||
live: live,
|
||||
ttl: ttl,
|
||||
composedMessages: msgs
|
||||
@@ -1330,6 +1578,7 @@ struct ComposeView: View {
|
||||
toChatType: chat.chatInfo.chatType,
|
||||
toChatId: chat.chatInfo.apiId,
|
||||
toScope: chat.chatInfo.groupChatScope(),
|
||||
sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false,
|
||||
fromChatType: fromChatInfo.chatType,
|
||||
fromChatId: fromChatInfo.apiId,
|
||||
fromScope: fromChatInfo.groupChatScope(),
|
||||
@@ -1361,6 +1610,7 @@ struct ComposeView: View {
|
||||
await MainActor.run { composeState.inProgress = true }
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#startVoiceMessageRecording
|
||||
private func startVoiceMessageRecording() async {
|
||||
startingRecording = true
|
||||
let fileName = generateNewFileName("voice", "m4a")
|
||||
@@ -1401,6 +1651,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#finishVoiceMessageRecording
|
||||
private func finishVoiceMessageRecording() {
|
||||
audioRecorder?.stop()
|
||||
audioRecorder = nil
|
||||
@@ -1411,6 +1662,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#allowVoiceMessagesToContact
|
||||
private func allowVoiceMessagesToContact() {
|
||||
if case let .direct(contact) = chat.chatInfo {
|
||||
allowFeatureToContact(contact, .voice)
|
||||
@@ -1436,12 +1688,14 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#cancelVoiceMessageRecording
|
||||
private func cancelVoiceMessageRecording(_ fileName: String) {
|
||||
stopPlayback.toggle()
|
||||
audioRecorder?.stop()
|
||||
removeFile(fileName)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#clearState
|
||||
private func clearState(live: Bool = false) {
|
||||
if live {
|
||||
composeState.inProgress = false
|
||||
@@ -1455,11 +1709,13 @@ struct ComposeView: View {
|
||||
startingRecording = false
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#saveCurrentDraft
|
||||
private func saveCurrentDraft() {
|
||||
chatModel.draft = composeState
|
||||
chatModel.draftChatId = chat.id
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#clearCurrentDraft
|
||||
private func clearCurrentDraft() {
|
||||
if chatModel.draftChatId == chat.id {
|
||||
chatModel.draft = nil
|
||||
@@ -1467,6 +1723,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#showLinkPreview
|
||||
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
|
||||
prevLinkUrl = linkUrl
|
||||
(linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
|
||||
@@ -1486,6 +1743,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#getMessageLinks
|
||||
private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) {
|
||||
guard let parsedMsg else { return (nil, false) }
|
||||
let simplexLink = parsedMsgHasSimplexLink(parsedMsg)
|
||||
@@ -1512,6 +1770,7 @@ struct ComposeView: View {
|
||||
composeState = composeState.copy(preview: .noPreview)
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#loadLinkPreview
|
||||
private func loadLinkPreview(_ urlStr: String) {
|
||||
if pendingLinkUrl == urlStr, let url = URL(string: urlStr) {
|
||||
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
|
||||
|
||||
@@ -11,6 +11,7 @@ import SimpleXChat
|
||||
|
||||
private let liveMsgInterval: UInt64 = 3000_000000
|
||||
|
||||
// Spec: spec/client/compose.md#SendMessageView
|
||||
struct SendMessageView: View {
|
||||
var placeholder: String?
|
||||
@Binding var composeState: ComposeState
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by JRoberts on 22.07.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//
|
||||
// ChannelMembersView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 20.02.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChannelMembersView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
var body: some View {
|
||||
let members = chatModel.groupMembers
|
||||
.filter { m in
|
||||
let s = m.wrapped.memberStatus
|
||||
return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay
|
||||
}
|
||||
if groupInfo.isOwner {
|
||||
let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1)
|
||||
List {
|
||||
Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) {
|
||||
memberRow(GMember(groupInfo.membership), user: true, showRole: true)
|
||||
ForEach(members) { member in
|
||||
memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let owners = members.filter { $0.wrapped.memberRole >= .owner }
|
||||
List {
|
||||
Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) {
|
||||
ForEach(owners) { member in
|
||||
memberRow(member, user: false, showRole: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func memberRow(_ gMember: GMember, user: Bool, showRole: Bool) -> some View {
|
||||
let member = gMember.wrapped
|
||||
let nameText = Text(member.chatViewName)
|
||||
.foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
|
||||
let displayName = member.verified
|
||||
? (Text(Image(systemName: "checkmark.shield")) + textSpace)
|
||||
.font(.caption).baselineOffset(2).kerning(-2)
|
||||
.foregroundColor(theme.colors.secondary) + nameText
|
||||
: nameText
|
||||
let row = HStack {
|
||||
MemberProfileImage(member, size: 38)
|
||||
.padding(.trailing, 2)
|
||||
VStack(alignment: .leading) {
|
||||
displayName
|
||||
.lineLimit(1)
|
||||
if user {
|
||||
Text("you")
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
if showRole {
|
||||
Text(member.memberRole.text)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
if user {
|
||||
row
|
||||
} else {
|
||||
NavigationLink {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
groupMember: gMember,
|
||||
scrollToItemId: Binding.constant(nil)
|
||||
)
|
||||
.navigationBarHidden(false)
|
||||
} label: {
|
||||
row
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChannelMembersView(
|
||||
chat: Chat.sampleData,
|
||||
groupInfo: GroupInfo.sampleData
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
//
|
||||
// ChannelRelaysView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 20.02.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct ChannelRelaysView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
var groupInfo: GroupInfo
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var groupRelays: [GroupRelay] = []
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
relaysList()
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await chatModel.loadGroupMembers(groupInfo)
|
||||
if groupInfo.isOwner {
|
||||
groupRelays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func relaysList() -> some View {
|
||||
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
|
||||
if relayMembers.isEmpty {
|
||||
Section {
|
||||
Text("No chat relays")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(relayMembers) { member in
|
||||
NavigationLink {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
groupMember: member,
|
||||
scrollToItemId: Binding.constant(nil),
|
||||
groupRelay: groupRelays.first(where: { $0.groupMemberId == member.wrapped.groupMemberId })
|
||||
)
|
||||
.navigationBarHidden(false)
|
||||
} label: {
|
||||
let statusText = groupInfo.isOwner
|
||||
? ownerRelayStatusText(member.wrapped)
|
||||
: subscriberRelayStatusText(member.wrapped)
|
||||
relayMemberRow(member.wrapped, statusText: statusText)
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Chat relays forward messages to channel subscribers.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
"disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
"inactive"
|
||||
} else {
|
||||
relayConnStatus(member).text
|
||||
}
|
||||
}
|
||||
|
||||
private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if case .failed = member.activeConn?.connStatus {
|
||||
"failed"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
"disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
"inactive"
|
||||
} else {
|
||||
groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text
|
||||
?? relayConnStatus(member).text
|
||||
}
|
||||
}
|
||||
|
||||
private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View {
|
||||
HStack {
|
||||
MemberProfileImage(member, size: 38)
|
||||
.padding(.trailing, 2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(member.chatViewName)
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(1)
|
||||
Text(statusText)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) {
|
||||
switch member.activeConn?.connStatus {
|
||||
case .ready: ("connected", .green)
|
||||
case .deleted: ("deleted", .red)
|
||||
case .failed: ("failed", .red)
|
||||
default: ("connecting", .yellow)
|
||||
}
|
||||
}
|
||||
|
||||
func hostFromRelayLink(_ link: String) -> String {
|
||||
if let ft = parseSimpleXMarkdown(link) {
|
||||
for f in ft {
|
||||
if case let .simplexLink(_, _, _, smpHosts) = f.format,
|
||||
let host = smpHosts.first {
|
||||
return host
|
||||
}
|
||||
}
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData)
|
||||
}
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by JRoberts on 14.07.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
|
||||
|
||||
// Spec: spec/client/chat-view.md#GroupChatInfoView
|
||||
struct GroupChatInfoView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -88,22 +90,57 @@ struct GroupChatInfoView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.canAddMembers && groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
if groupInfo.useRelays && groupInfo.membership.memberIncognito {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(groupInfo.membership.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator {
|
||||
memberSupportButton()
|
||||
}
|
||||
|
||||
if groupInfo.useRelays {
|
||||
Section {
|
||||
// TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership)
|
||||
if groupInfo.isOwner && groupLink != nil {
|
||||
channelLinkButton()
|
||||
} else if let link = groupInfo.groupProfile.publicGroup?.groupLink {
|
||||
SimpleXLinkQRCode(uri: link)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(link)])
|
||||
} label: {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) {
|
||||
channelMembersButton()
|
||||
}
|
||||
} footer: {
|
||||
if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil {
|
||||
Text("You can share a link or a QR code - anybody will be able to join the channel.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
if groupInfo.canModerate {
|
||||
GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
|
||||
} else {
|
||||
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("")
|
||||
}
|
||||
if groupInfo.membership.memberActive
|
||||
&& (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) {
|
||||
UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId)
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -113,22 +150,28 @@ struct GroupChatInfoView: View {
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if !groupInfo.useRelays {
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
}
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if !groupInfo.useRelays {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
if !groupInfo.useRelays {
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
}
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
@@ -140,7 +183,7 @@ struct GroupChatInfoView: View {
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
|
||||
if !groupInfo.nextConnectPrepared {
|
||||
if !groupInfo.nextConnectPrepared && !groupInfo.useRelays {
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if (chat.chatInfo.incognito) {
|
||||
@@ -172,12 +215,18 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) {
|
||||
channelRelaysButton()
|
||||
}
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrentOrPending {
|
||||
leaveGroupButton()
|
||||
if !groupInfo.useRelays || !groupInfo.isOwner
|
||||
|| members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,13 +267,15 @@ struct GroupChatInfoView: View {
|
||||
sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
do {
|
||||
if let gLink = try apiGetGroupLink(groupInfo.groupId) {
|
||||
groupLink = gLink
|
||||
groupLinkMemberRole = gLink.acceptMemberRole
|
||||
if !groupInfo.useRelays || groupInfo.isOwner {
|
||||
do {
|
||||
if let gLink = try apiGetGroupLink(groupInfo.groupId) {
|
||||
groupLink = gLink
|
||||
groupLinkMemberRole = gLink.acceptMemberRole
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,6 +308,14 @@ struct GroupChatInfoView: View {
|
||||
.lineLimit(4)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
if groupInfo.useRelays,
|
||||
let count = groupInfo.groupSummary.publicMemberCount,
|
||||
count > 0 {
|
||||
Text(subscriberCountStr(count))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
@@ -297,7 +356,9 @@ struct GroupChatInfoView: View {
|
||||
let buttonWidth = g.size.width / 4
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
searchButton(width: buttonWidth)
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.useRelays && groupInfo.isOwner {
|
||||
channelLinkActionButton(width: buttonWidth)
|
||||
} else if !groupInfo.useRelays && groupInfo.canAddMembers {
|
||||
addMembersActionButton(width: buttonWidth)
|
||||
}
|
||||
if let nextNtfMode = chat.chatInfo.nextNtfMode {
|
||||
@@ -358,6 +419,23 @@ struct GroupChatInfoView: View {
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
|
||||
private func channelLinkActionButton(width: CGFloat) -> some View {
|
||||
ZStack {
|
||||
InfoViewButton(image: "link", title: "link", width: width) {
|
||||
groupLinkNavLinkActive = true
|
||||
}
|
||||
|
||||
NavigationLink(isActive: $groupLinkNavLinkActive) {
|
||||
groupLinkDestinationView()
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
.disabled(!groupInfo.ready)
|
||||
}
|
||||
|
||||
private func addMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType {
|
||||
case .customer: "Add team members"
|
||||
@@ -453,7 +531,9 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
if case .failed = member.activeConn?.connStatus {
|
||||
return "failed"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
@@ -543,19 +623,51 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func channelLinkButton() -> some View {
|
||||
NavigationLink {
|
||||
groupLinkDestinationView()
|
||||
} label: {
|
||||
Label("Channel link", systemImage: "link")
|
||||
}
|
||||
}
|
||||
|
||||
private func groupLinkDestinationView() -> some View {
|
||||
GroupLinkView(
|
||||
groupId: groupInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: $groupLinkMemberRole,
|
||||
showTitle: false,
|
||||
creatingGroup: false
|
||||
creatingGroup: false,
|
||||
isChannel: groupInfo.useRelays
|
||||
)
|
||||
.navigationBarTitle("Group link")
|
||||
.navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
|
||||
private func channelMembersButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners"
|
||||
return NavigationLink {
|
||||
ChannelMembersView(chat: chat, groupInfo: groupInfo)
|
||||
.navigationTitle(label)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label(label, systemImage: "person.2")
|
||||
}
|
||||
}
|
||||
|
||||
private func channelRelaysButton() -> some View {
|
||||
NavigationLink {
|
||||
ChannelRelaysView(chat: chat, groupInfo: groupInfo)
|
||||
.navigationTitle("Chat relays")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Chat relays", systemImage: "externaldrive.connected.to.line.below")
|
||||
}
|
||||
}
|
||||
|
||||
struct UserSupportChatNavLink: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -650,7 +762,7 @@ struct GroupChatInfoView: View {
|
||||
groupProfile: groupInfo.groupProfile
|
||||
)
|
||||
} label: {
|
||||
Label("Edit group profile", systemImage: "pencil")
|
||||
Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -672,7 +784,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
@ViewBuilder private func deleteGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteGroupAlert
|
||||
} label: {
|
||||
@@ -691,7 +803,7 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func leaveGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
|
||||
return Button(role: .destructive) {
|
||||
alert = .leaveGroupAlert
|
||||
} label: {
|
||||
@@ -702,7 +814,7 @@ struct GroupChatInfoView: View {
|
||||
|
||||
// TODO reuse this and clearChatAlert with ChatInfoView
|
||||
private func deleteGroupAlert() -> Alert {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
return Alert(
|
||||
title: Text(label),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
@@ -739,9 +851,11 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert() -> Alert {
|
||||
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
groupInfo.useRelays
|
||||
? "You will stop receiving messages from this channel. Chat history will be preserved."
|
||||
: groupInfo.businessChat == nil
|
||||
? "You will stop receiving messages from this group. Chat history will be preserved."
|
||||
: "You will stop receiving messages from this chat. Chat history will be preserved."
|
||||
)
|
||||
@@ -792,9 +906,13 @@ struct GroupChatInfoView: View {
|
||||
|
||||
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Remove subscriber?", comment: "alert title")
|
||||
: NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message:
|
||||
groupInfo.businessChat == nil
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
|
||||
: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
@@ -836,10 +954,18 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool
|
||||
}
|
||||
|
||||
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!")
|
||||
groupInfo.useRelays ? (
|
||||
groupInfo.membership.memberCurrent
|
||||
? Text("Channel will be deleted for all subscribers - this cannot be undone!")
|
||||
: Text("Channel will be deleted for you - this cannot be undone!")
|
||||
) : 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!")
|
||||
) : (
|
||||
groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!")
|
||||
groupInfo.membership.memberCurrent
|
||||
? Text("Chat will be deleted for all members - this cannot be undone!")
|
||||
: Text("Chat will be deleted for you - this cannot be undone!")
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by JRoberts on 15.10.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -16,6 +17,7 @@ struct GroupLinkView: View {
|
||||
@Binding var groupLinkMemberRole: GroupMemberRole
|
||||
var showTitle: Bool = false
|
||||
var creatingGroup: Bool = false
|
||||
var isChannel: Bool = false
|
||||
var linkCreatedCb: (() -> Void)? = nil
|
||||
@State private var showShortLink = true
|
||||
@State private var creatingLink = false
|
||||
@@ -59,12 +61,16 @@ struct GroupLinkView: View {
|
||||
List {
|
||||
Group {
|
||||
if showTitle {
|
||||
Text("Group link")
|
||||
Text(isChannel ? "Channel link" : "Group link")
|
||||
.font(.largeTitle)
|
||||
.bold()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
if isChannel {
|
||||
Text("You can share a link or a QR code - anybody will be able to join the channel.")
|
||||
} else {
|
||||
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
@@ -72,15 +78,17 @@ struct GroupLinkView: View {
|
||||
|
||||
Section {
|
||||
if let groupLink = groupLink {
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
|
||||
Text(role.text)
|
||||
if !isChannel {
|
||||
Picker("Initial role", selection: $groupLinkMemberRole) {
|
||||
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
}
|
||||
.frame(height: 36)
|
||||
SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink)
|
||||
.id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))")
|
||||
if groupLink.shouldBeUpgraded {
|
||||
if !isChannel && groupLink.shouldBeUpgraded {
|
||||
Button {
|
||||
upgradeAndShareLinkAlert()
|
||||
} label: {
|
||||
@@ -88,7 +96,7 @@ struct GroupLinkView: View {
|
||||
}
|
||||
}
|
||||
Button {
|
||||
if groupLink.shouldBeUpgraded {
|
||||
if !isChannel && groupLink.shouldBeUpgraded {
|
||||
upgradeAndShareLinkAlert(groupLink: groupLink)
|
||||
} else {
|
||||
groupLink.shareAddress(short: showShortLink)
|
||||
@@ -97,7 +105,7 @@ struct GroupLinkView: View {
|
||||
Label("Share link", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
if !creatingGroup {
|
||||
if !creatingGroup && !isChannel {
|
||||
Button(role: .destructive) { alert = .deleteLink } label: {
|
||||
Label("Delete link", systemImage: "trash")
|
||||
}
|
||||
@@ -109,7 +117,7 @@ struct GroupLinkView: View {
|
||||
.disabled(creatingLink)
|
||||
}
|
||||
} header: {
|
||||
if let groupLink, groupLink.connLinkContact.connShortLink != nil {
|
||||
if !isChannel, let groupLink, groupLink.connLinkContact.connShortLink != nil {
|
||||
ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by JRoberts on 25.07.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-view.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -19,6 +20,7 @@ struct GroupMemberInfoView: View {
|
||||
@Binding var scrollToItemId: ChatItem.ID?
|
||||
var navigation: Bool = false
|
||||
var openedFromSupportChat: Bool = false
|
||||
var groupRelay: GroupRelay? = nil
|
||||
@State private var connectionStats: ConnectionStats? = nil
|
||||
@State private var connectionCode: String? = nil
|
||||
@State private var connectionLoaded: Bool = false
|
||||
@@ -31,6 +33,26 @@ struct GroupMemberInfoView: View {
|
||||
@State private var justOpened = true
|
||||
@State private var progressIndicator = false
|
||||
|
||||
private var channelMemberSectionHeader: LocalizedStringKey {
|
||||
if groupInfo.useRelays {
|
||||
switch groupMember.wrapped.memberRole {
|
||||
case .relay: "Relay"
|
||||
case .owner: "Owner"
|
||||
default: "Subscriber"
|
||||
}
|
||||
} else {
|
||||
"Member"
|
||||
}
|
||||
}
|
||||
|
||||
private var relaySectionFooter: LocalizedStringKey {
|
||||
if groupInfo.isOwner {
|
||||
"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel."
|
||||
} else {
|
||||
"You connected to the channel via this relay link."
|
||||
}
|
||||
}
|
||||
|
||||
enum GroupMemberInfoViewAlert: Identifiable {
|
||||
case blockMemberAlert(mem: GroupMember)
|
||||
case unblockMemberAlert(mem: GroupMember)
|
||||
@@ -88,13 +110,15 @@ struct GroupMemberInfoView: View {
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons(member)
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
if !groupInfo.useRelays {
|
||||
infoActionButtons(member)
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
}
|
||||
|
||||
if connectionLoaded {
|
||||
|
||||
@@ -102,10 +126,14 @@ struct GroupMemberInfoView: View {
|
||||
Section {
|
||||
if !openedFromSupportChat
|
||||
&& groupInfo.membership.memberRole >= .moderator
|
||||
&& member.memberRole != .relay
|
||||
&& (member.memberRole < .moderator || member.supportChat != nil) {
|
||||
MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId)
|
||||
}
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
if let code = connectionCode,
|
||||
!(groupInfo.useRelays && member.memberRole == .relay) {
|
||||
verifyCodeButton(code)
|
||||
}
|
||||
if let connStats = connectionStats,
|
||||
connStats.ratchetSyncAllowed {
|
||||
synchronizeConnectionButton()
|
||||
@@ -140,11 +168,11 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Member").foregroundColor(theme.colors.secondary)) {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat"
|
||||
Section {
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat"
|
||||
infoRow(label, groupInfo.displayName)
|
||||
|
||||
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
|
||||
Picker("Change role", selection: $newRole) {
|
||||
ForEach(roles) { role in
|
||||
Text(role.text)
|
||||
@@ -154,6 +182,23 @@ struct GroupMemberInfoView: View {
|
||||
} else {
|
||||
infoRow("Role", member.memberRole.text)
|
||||
}
|
||||
if let link = member.relayLink {
|
||||
infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link)))
|
||||
}
|
||||
if let address = groupRelay?.userChatRelay.address {
|
||||
infoRow("Relay address", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(address)))
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(address)])
|
||||
} label: {
|
||||
Label("Share relay address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
if groupInfo.useRelays && member.memberRole == .relay {
|
||||
Text(relaySectionFooter).foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if let connStats = connectionStats {
|
||||
@@ -188,9 +233,22 @@ struct GroupMemberInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if let connFailedErr = member.activeConn?.connFailedErr {
|
||||
Section {
|
||||
Text(connFailedErr)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} header: {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.red)
|
||||
Text("Connection failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if groupInfo.membership.memberRole >= .moderator {
|
||||
adminDestructiveSection(member)
|
||||
} else {
|
||||
} else if !groupInfo.useRelays {
|
||||
nonAdminBlockSection(member)
|
||||
}
|
||||
|
||||
@@ -202,16 +260,18 @@ struct GroupMemberInfoView: View {
|
||||
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
|
||||
infoRow("Connection", connLevelDesc)
|
||||
}
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) {
|
||||
await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) }
|
||||
if !groupInfo.useRelays || member.memberRole == .relay {
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
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")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -575,7 +635,9 @@ struct GroupMemberInfoView: View {
|
||||
blockForAllButton(mem)
|
||||
}
|
||||
}
|
||||
if canRemove {
|
||||
// TODO [relays] removing relay should also remove its link from group link data;
|
||||
// TODO - removing last relay should be prohibited or show warning
|
||||
if canRemove && mem.memberRole != .relay {
|
||||
if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
|
||||
deleteMemberMessagesButton(mem)
|
||||
} else {
|
||||
@@ -637,7 +699,7 @@ struct GroupMemberInfoView: View {
|
||||
Button(role: .destructive) {
|
||||
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
|
||||
} label: {
|
||||
Label("Remove member", systemImage: "trash")
|
||||
Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
@@ -817,7 +879,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
|
||||
|
||||
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Block member for all?"),
|
||||
title: Text(gInfo.useRelays ? "Block subscriber for all?" : "Block member for all?"),
|
||||
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
|
||||
primaryButton: .destructive(Text("Block for all")) {
|
||||
blockMemberForAll(gInfo, mem, true)
|
||||
@@ -828,7 +890,7 @@ func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
|
||||
func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
|
||||
Alert(
|
||||
title: Text("Unblock member for all?"),
|
||||
title: Text(gInfo.useRelays ? "Unblock subscriber for all?" : "Unblock member for all?"),
|
||||
message: Text("Messages from \(mem.chatViewName) will be shown!"),
|
||||
primaryButton: .default(Text("Unblock for all")) {
|
||||
blockMemberForAll(gInfo, mem, false)
|
||||
|
||||
@@ -42,7 +42,7 @@ struct GroupProfileView: View {
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
TextField("Group display name", text: $groupProfile.displayName)
|
||||
TextField(groupInfo.useRelays ? "Channel display name" : "Group display name", text: $groupProfile.displayName)
|
||||
.focused($focusDisplayName)
|
||||
if !validNewProfileName {
|
||||
Button {
|
||||
@@ -54,7 +54,7 @@ struct GroupProfileView: View {
|
||||
}
|
||||
let fullName = groupInfo.groupProfile.fullName
|
||||
if fullName != "" && fullName != groupProfile.displayName {
|
||||
TextField("Group full name (optional)", text: $groupProfile.fullName)
|
||||
TextField(groupInfo.useRelays ? "Channel full name (optional)" : "Group full name (optional)", text: $groupProfile.fullName)
|
||||
}
|
||||
HStack {
|
||||
TextField("Short description", text: $shortDescr)
|
||||
@@ -67,7 +67,7 @@ struct GroupProfileView: View {
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Group profile is stored on members' devices, not on the servers.")
|
||||
Text(groupInfo.useRelays ? "Channel profile is stored on subscribers' devices and on the chat relays." : "Group profile is stored on members' devices, not on the servers.")
|
||||
}
|
||||
|
||||
Section {
|
||||
@@ -80,11 +80,11 @@ struct GroupProfileView: View {
|
||||
currentProfileHash == groupProfile.hashValue &&
|
||||
(groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces)
|
||||
)
|
||||
Button("Save group profile", action: saveProfile)
|
||||
Button(groupInfo.useRelays ? "Save channel profile" : "Save group profile", action: saveProfile)
|
||||
.disabled(!canUpdateProfile)
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
.confirmationDialog(groupInfo.useRelays ? "Channel image" : "Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
Button("Take picture") {
|
||||
showTakePhoto = true
|
||||
}
|
||||
@@ -130,9 +130,15 @@ struct GroupProfileView: View {
|
||||
.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"),
|
||||
title: groupInfo.useRelays
|
||||
? NSLocalizedString("Save channel profile?", comment: "alert title")
|
||||
: NSLocalizedString("Save group profile?", comment: "alert title"),
|
||||
message: groupInfo.useRelays
|
||||
? NSLocalizedString("Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.", comment: "alert message")
|
||||
: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"),
|
||||
buttonTitle: groupInfo.useRelays
|
||||
? NSLocalizedString("Save (and notify subscribers)", comment: "alert button")
|
||||
: NSLocalizedString("Save (and notify members)", comment: "alert button"),
|
||||
buttonAction: saveProfile,
|
||||
cancelButton: true
|
||||
)
|
||||
@@ -142,14 +148,14 @@ struct GroupProfileView: View {
|
||||
switch a {
|
||||
case let .saveError(err):
|
||||
return Alert(
|
||||
title: Text("Error saving group profile"),
|
||||
title: Text(groupInfo.useRelays ? "Error saving channel profile" : "Error saving group profile"),
|
||||
message: Text(err)
|
||||
)
|
||||
case let .invalidName(name):
|
||||
return createInvalidNameAlert(name, $groupProfile.displayName)
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Group profile")
|
||||
.navigationBarTitle(groupInfo.useRelays ? "Channel profile" : "Group profile")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large)
|
||||
}
|
||||
|
||||
@@ -196,7 +196,9 @@ struct MemberSupportView: View {
|
||||
}
|
||||
|
||||
private func memberStatus(_ member: GroupMember) -> LocalizedStringKey {
|
||||
if member.activeConn?.connDisabled ?? false {
|
||||
if case .failed = member.activeConn?.connStatus {
|
||||
return "failed"
|
||||
} else if member.activeConn?.connDisabled ?? false {
|
||||
return "disabled"
|
||||
} else if member.activeConn?.connInactive ?? false {
|
||||
return "inactive"
|
||||
|
||||
@@ -40,6 +40,7 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
|
||||
dynamicSizes[font] ?? defaultDynamicSizes
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#ChatListNavLink
|
||||
struct ChatListNavLink: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -90,6 +91,7 @@ struct ChatListNavLink: View {
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#contactNavLink
|
||||
private func contactNavLink(_ contact: Contact) -> some View {
|
||||
Group {
|
||||
if contact.isContactCard {
|
||||
@@ -211,6 +213,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#groupNavLink
|
||||
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
case .memInvited:
|
||||
@@ -241,7 +244,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
.swipeActions(edge: .trailing) {
|
||||
tagChatButton(chat)
|
||||
if (groupInfo.membership.memberCurrentOrPending) {
|
||||
if groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) {
|
||||
leaveGroupChatButton(groupInfo)
|
||||
}
|
||||
if groupInfo.canDelete {
|
||||
@@ -266,7 +269,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.memberCurrentOrPending
|
||||
let showLeaveGroup = groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)
|
||||
let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
|
||||
|
||||
if showClearButton && totalNumberOfButtons <= 3 {
|
||||
@@ -295,6 +298,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#noteFolderNavLink
|
||||
private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
|
||||
NavLinkPlain(
|
||||
chatId: chat.chatInfo.id,
|
||||
@@ -325,6 +329,7 @@ struct ChatListNavLink: View {
|
||||
.tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary)
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#markReadButton
|
||||
@ViewBuilder private func markReadButton() -> some View {
|
||||
if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat {
|
||||
Button {
|
||||
@@ -344,6 +349,7 @@ struct ChatListNavLink: View {
|
||||
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#toggleFavoriteButton
|
||||
@ViewBuilder private func toggleFavoriteButton() -> some View {
|
||||
if chat.chatInfo.chatSettings?.favorite == true {
|
||||
Button {
|
||||
@@ -362,6 +368,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#toggleNtfsButton
|
||||
@ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View {
|
||||
if let nextMode = chat.chatInfo.nextNtfMode {
|
||||
Button {
|
||||
@@ -382,6 +389,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#clearChatButton
|
||||
private func clearChatButton() -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(clearChatAlert())
|
||||
@@ -483,6 +491,7 @@ struct ChatListNavLink: View {
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#contactRequestNavLink
|
||||
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
|
||||
ContactRequestView(contactRequest: contactRequest, chat: chat)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
@@ -517,6 +526,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#contactConnectionNavLink
|
||||
private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
|
||||
ContactConnectionView(chat: chat)
|
||||
.frameCompat(height: dynamicRowHeight)
|
||||
@@ -555,7 +565,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?"
|
||||
return Alert(
|
||||
title: Text(label),
|
||||
message: deleteGroupAlertMessage(groupInfo),
|
||||
@@ -610,9 +620,11 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
|
||||
private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert {
|
||||
let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?"
|
||||
let messageLabel: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
groupInfo.useRelays
|
||||
? "You will stop receiving messages from this channel. Chat history will be preserved."
|
||||
: groupInfo.businessChat == nil
|
||||
? "You will stop receiving messages from this group. Chat history will be preserved."
|
||||
: "You will stop receiving messages from this chat. Chat history will be preserved."
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 27/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/chat-list.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -31,6 +32,7 @@ enum UserPickerSheet: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#PresetTag
|
||||
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
|
||||
case groupReports = 0
|
||||
case favorites = 1
|
||||
@@ -46,6 +48,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#ActiveFilter
|
||||
enum ActiveFilter: Identifiable, Equatable {
|
||||
case presetTag(PresetTag)
|
||||
case userTag(ChatTag)
|
||||
@@ -61,13 +64,14 @@ enum ActiveFilter: Identifiable, Equatable {
|
||||
}
|
||||
|
||||
class SaveableSettings: ObservableObject {
|
||||
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
|
||||
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: [])
|
||||
}
|
||||
|
||||
struct ServerSettings {
|
||||
public var currUserServers: [UserOperatorServers]
|
||||
public var userServers: [UserOperatorServers]
|
||||
public var serverErrors: [UserServersError]
|
||||
public var serverWarnings: [UserServersWarning]
|
||||
}
|
||||
|
||||
struct UserPickerSheetView: View {
|
||||
@@ -135,6 +139,7 @@ struct UserPickerSheetView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#ChatListView
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@StateObject private var connectProgressManager = ConnectProgressManager.shared
|
||||
@@ -160,6 +165,7 @@ struct ChatListView: View {
|
||||
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
|
||||
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
|
||||
|
||||
// Spec: spec/client/chat-list.md#body
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody.scrollDismissesKeyboard(.immediately)
|
||||
@@ -445,6 +451,7 @@ struct ChatListView: View {
|
||||
}
|
||||
|
||||
|
||||
// Spec: spec/client/chat-list.md#unreadBadge
|
||||
private func unreadBadge(size: CGFloat = 18) -> some View {
|
||||
Circle()
|
||||
.frame(width: size, height: size)
|
||||
@@ -464,11 +471,13 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#stopAudioPlayer
|
||||
func stopAudioPlayer() {
|
||||
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
|
||||
VoiceItemState.smallView = [:]
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#filteredChats
|
||||
private func filteredChats() -> [Chat] {
|
||||
if let linkChatId = searchChatFilteredBySimplexLink {
|
||||
return chatModel.chats.filter { $0.id == linkChatId }
|
||||
@@ -511,6 +520,7 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#searchString
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
@@ -574,6 +584,7 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#ChatListSearchBar
|
||||
struct ChatListSearchBar: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -875,6 +886,7 @@ struct TagsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#setActiveFilter
|
||||
private func setActiveFilter(filter: ActiveFilter) {
|
||||
if filter != chatTagsModel.activeFilter {
|
||||
chatTagsModel.activeFilter = filter
|
||||
@@ -895,6 +907,7 @@ func chatStoppedIcon() -> some View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#presetTagMatchesChat
|
||||
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
|
||||
switch tag {
|
||||
case .groupReports:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-list.md#ChatPreviewView
|
||||
struct ChatPreviewView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -295,7 +296,7 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
|
||||
func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
|
||||
let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText()
|
||||
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
|
||||
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)
|
||||
|
||||
@@ -16,6 +16,7 @@ struct TagEditorNavParams {
|
||||
let tagId: Int64?
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#TagListView
|
||||
struct TagListView: View {
|
||||
var chat: Chat? = nil
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/client/chat-list.md#UserPicker
|
||||
struct UserPicker: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 04/09/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -33,6 +34,7 @@ enum DatabaseEncryptionAlert: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/database.md#DatabaseEncryptionView
|
||||
struct DatabaseEncryptionView: View {
|
||||
@EnvironmentObject private var m: ChatModel
|
||||
@EnvironmentObject private var theme: AppTheme
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 04/09/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -78,12 +79,25 @@ struct DatabaseErrorView: View {
|
||||
fileNameText(dbFile)
|
||||
}
|
||||
case let .downgrade(downMigrations):
|
||||
let warnings = downMigrationWarnings(downMigrations).reversed()
|
||||
titleText("Database downgrade")
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.resizable()
|
||||
.frame(width: 40, height: 36)
|
||||
.foregroundColor(.red)
|
||||
Text("Warning: you may lose some data!")
|
||||
.bold()
|
||||
.padding(.horizontal, 25)
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
if !warnings.isEmpty {
|
||||
ForEach(warnings, id: \.self) { warning in
|
||||
Text(warning)
|
||||
.bold()
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 25)
|
||||
}
|
||||
}
|
||||
migrationsText(downMigrations)
|
||||
Spacer()
|
||||
VStack(spacing: 10) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 19/06/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -41,6 +42,7 @@ enum DatabaseAlert: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/database.md#DatabaseView
|
||||
struct DatabaseView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 20/06/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -100,6 +100,7 @@ class OpenChatAlertViewController: UIViewController {
|
||||
private let profileName: String
|
||||
private let profileFullName: String
|
||||
private let profileImage: UIView
|
||||
private let subtitle: String?
|
||||
private let cancelTitle: String
|
||||
private let confirmTitle: String
|
||||
private let onCancel: () -> Void
|
||||
@@ -109,6 +110,7 @@ class OpenChatAlertViewController: UIViewController {
|
||||
profileName: String,
|
||||
profileFullName: String,
|
||||
profileImage: UIView,
|
||||
subtitle: String? = nil,
|
||||
cancelTitle: String = "Cancel",
|
||||
confirmTitle: String = "Open",
|
||||
onCancel: @escaping () -> Void,
|
||||
@@ -117,6 +119,7 @@ class OpenChatAlertViewController: UIViewController {
|
||||
self.profileName = profileName
|
||||
self.profileFullName = profileFullName
|
||||
self.profileImage = profileImage
|
||||
self.subtitle = subtitle
|
||||
self.cancelTitle = cancelTitle
|
||||
self.confirmTitle = confirmTitle
|
||||
self.onCancel = onCancel
|
||||
@@ -171,6 +174,18 @@ class OpenChatAlertViewController: UIViewController {
|
||||
profileViews.append(fullNameLabel)
|
||||
}
|
||||
|
||||
// Subtitle label (e.g. subscriber count)
|
||||
if let subtitle {
|
||||
let subtitleLabel = UILabel()
|
||||
subtitleLabel.text = subtitle
|
||||
subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote)
|
||||
subtitleLabel.textColor = .secondaryLabel
|
||||
subtitleLabel.numberOfLines = 1
|
||||
subtitleLabel.textAlignment = .center
|
||||
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
profileViews.append(subtitleLabel)
|
||||
}
|
||||
|
||||
// Horizontal stack for image + name
|
||||
let stack = UIStackView(arrangedSubviews: profileViews)
|
||||
stack.axis = .vertical
|
||||
@@ -291,6 +306,7 @@ func showOpenChatAlert<Content: View>(
|
||||
profileFullName: String,
|
||||
profileImage: Content,
|
||||
theme: AppTheme,
|
||||
subtitle: String? = nil,
|
||||
cancelTitle: String = "Cancel",
|
||||
confirmTitle: String = "Open",
|
||||
onCancel: @escaping () -> Void = {},
|
||||
@@ -306,6 +322,7 @@ func showOpenChatAlert<Content: View>(
|
||||
profileName: profileName,
|
||||
profileFullName: profileFullName,
|
||||
profileImage: hostedView,
|
||||
subtitle: subtitle,
|
||||
cancelTitle: cancelTitle,
|
||||
confirmTitle: confirmTitle,
|
||||
onCancel: onCancel,
|
||||
|
||||
@@ -29,6 +29,7 @@ struct VideoPlayerView: UIViewRepresentable {
|
||||
func makeUIView(context: UIViewRepresentableContext<VideoPlayerView>) -> UIView {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.showsPlaybackControls = showControls
|
||||
controller.videoGravity = .resizeAspectFill
|
||||
if #available(iOS 16.0, *) {
|
||||
controller.speeds = []
|
||||
}
|
||||
|
||||
@@ -17,6 +17,15 @@ extension View {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
@ViewBuilder func compactSectionSpacing() -> some View {
|
||||
if #available(iOS 17, *) {
|
||||
self.listSectionSpacing(.compact)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 11/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 10/04/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Avently on 14.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Avently on 23.02.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/database.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -373,10 +374,12 @@ struct MigrateToDevice: View {
|
||||
"Upgrade and open chat",
|
||||
"",
|
||||
.yesUp)
|
||||
case .downgrade:
|
||||
case let .downgrade(downMigrations):
|
||||
("Database downgrade",
|
||||
"Downgrade and open chat",
|
||||
NSLocalizedString("Warning: you may lose some data!", comment: ""),
|
||||
([NSLocalizedString("Warning: you may lose some data!", comment: "")]
|
||||
+ downMigrationWarnings(downMigrations).reversed())
|
||||
.joined(separator: "\n"),
|
||||
.yesUpDown)
|
||||
case let .migrationError(mtrError):
|
||||
("Incompatible database version",
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
//
|
||||
// AddChannelView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 23.02.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct AddChannelView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@StateObject private var channelRelaysModel = ChannelRelaysModel.shared
|
||||
@StateObject private var ss = SaveableSettings()
|
||||
@State private var profile = GroupProfile(displayName: "", fullName: "")
|
||||
@FocusState private var focusDisplayName: Bool
|
||||
@State private var showChooseSource = false
|
||||
@State private var showImagePicker = false
|
||||
@State private var showTakePhoto = false
|
||||
@State private var chosenImage: UIImage? = nil
|
||||
@State private var hasRelays = true
|
||||
@State private var groupInfo: GroupInfo? = nil
|
||||
@State private var groupLink: GroupLink? = nil
|
||||
@State private var groupRelays: [GroupRelay] = []
|
||||
@State private var creationInProgress = false
|
||||
@State private var showLinkStep = false
|
||||
@State private var relayListExpanded = false
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if showLinkStep, let gInfo = groupInfo {
|
||||
linkStepView(gInfo)
|
||||
} else if let gInfo = groupInfo {
|
||||
progressStepView(gInfo)
|
||||
} else {
|
||||
profileStepView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: Profile
|
||||
|
||||
private func profileStepView() -> some View {
|
||||
List {
|
||||
Group {
|
||||
ZStack(alignment: .center) {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: profile.image, size: 128)
|
||||
if profile.image != nil {
|
||||
Button {
|
||||
profile.image = nil
|
||||
} label: {
|
||||
Image(systemName: "multiply")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 12)
|
||||
}
|
||||
}
|
||||
}
|
||||
editImageButton { showChooseSource = true }
|
||||
.buttonStyle(BorderlessButtonStyle())
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
||||
|
||||
Section {
|
||||
channelNameTextField()
|
||||
NavigationLink {
|
||||
NetworkAndServers()
|
||||
.navigationTitle("Network & servers")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.environmentObject(ss)
|
||||
} label: {
|
||||
let color: Color = hasRelays ? .accentColor : .orange
|
||||
settingsRow("externaldrive.connected.to.line.below", color: color) {
|
||||
Text("Configure relays").foregroundColor(color)
|
||||
}
|
||||
}
|
||||
let canCreate = canCreateProfile() && hasRelays && !creationInProgress
|
||||
Button(action: createChannel) {
|
||||
settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create public channel") }
|
||||
}
|
||||
.disabled(!canCreate)
|
||||
} footer: {
|
||||
if !hasRelays {
|
||||
ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning"))
|
||||
} else {
|
||||
let name = ChatModel.shared.currentUser?.displayName ?? ""
|
||||
Text("Your profile **\(name)** will be shared with channel relays and subscribers.\nRelays can access channel messages.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.compactSectionSpacing()
|
||||
}
|
||||
.onAppear {
|
||||
Task { hasRelays = await checkHasRelays() }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
focusDisplayName = true
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) {
|
||||
Button("Take picture") { showTakePhoto = true }
|
||||
Button("Choose from library") { showImagePicker = true }
|
||||
}
|
||||
.fullScreenCover(isPresented: $showTakePhoto) {
|
||||
ZStack {
|
||||
Color.black.edgesIgnoringSafeArea(.all)
|
||||
CameraImagePicker(image: $chosenImage)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showImagePicker) {
|
||||
LibraryImagePicker(image: $chosenImage) { _ in
|
||||
await MainActor.run { showImagePicker = false }
|
||||
}
|
||||
}
|
||||
.onChange(of: chosenImage) { image in
|
||||
Task {
|
||||
let resized: String? = if let image {
|
||||
await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
await MainActor.run { profile.image = resized }
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
private func channelNameTextField() -> some View {
|
||||
ZStack(alignment: .leading) {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
if name != mkValidName(name) {
|
||||
Button {
|
||||
showInvalidChannelNameAlert()
|
||||
} label: {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
} else {
|
||||
Image(systemName: "pencil").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
TextField("Enter channel name…", text: $profile.displayName)
|
||||
.padding(.leading, 36)
|
||||
.focused($focusDisplayName)
|
||||
.submitLabel(.continue)
|
||||
.onSubmit {
|
||||
if canCreateProfile() && hasRelays { createChannel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func canCreateProfile() -> Bool {
|
||||
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
return name != "" && validDisplayName(name)
|
||||
}
|
||||
|
||||
private func createChannel() {
|
||||
focusDisplayName = false
|
||||
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
|
||||
profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on))
|
||||
creationInProgress = true
|
||||
Task {
|
||||
do {
|
||||
let enabledRelays = try await chooseRandomRelays()
|
||||
let relayIds = enabledRelays.compactMap { $0.chatRelayId }
|
||||
guard !relayIds.isEmpty else {
|
||||
await MainActor.run {
|
||||
creationInProgress = false
|
||||
hasRelays = false
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup(
|
||||
incognito: false, relayIds: relayIds, groupProfile: profile
|
||||
) else {
|
||||
await MainActor.run { creationInProgress = false }
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
m.updateGroup(gInfo)
|
||||
m.creatingChannelId = gInfo.id
|
||||
groupInfo = gInfo
|
||||
groupLink = gLink
|
||||
groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
||||
channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays)
|
||||
creationInProgress = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
creationInProgress = false
|
||||
showAlert(
|
||||
NSLocalizedString("Error creating channel", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let maxRelays = 3
|
||||
|
||||
private func chooseRandomRelays() async throws -> [UserChatRelay] {
|
||||
let servers = try await getUserServers()
|
||||
// Operator relays are grouped per operator; custom relays (nil operator)
|
||||
// are treated independently to maximize trust distribution.
|
||||
var operatorGroups: [[UserChatRelay]] = []
|
||||
var customRelays: [UserChatRelay] = []
|
||||
for op in servers {
|
||||
let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
|
||||
guard !relays.isEmpty else { continue }
|
||||
if op.operator != nil {
|
||||
operatorGroups.append(relays.shuffled())
|
||||
} else {
|
||||
customRelays = relays.shuffled()
|
||||
}
|
||||
}
|
||||
var selected: [UserChatRelay] = []
|
||||
// Prefer at least one custom relay when available -
|
||||
// user's own infrastructure for trust distribution.
|
||||
if let relay = customRelays.first {
|
||||
selected.append(relay)
|
||||
customRelays.removeFirst()
|
||||
if selected.count >= maxRelays { return selected }
|
||||
}
|
||||
// Round-robin across shuffled groups to distribute relays across operators.
|
||||
var groups = operatorGroups + customRelays.map { [$0] }
|
||||
groups.shuffle()
|
||||
let maxDepth = groups.map(\.count).max() ?? 0
|
||||
for depth in 0..<maxDepth {
|
||||
for group in groups {
|
||||
if depth < group.count {
|
||||
selected.append(group[depth])
|
||||
if selected.count >= maxRelays { return selected }
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
private func checkHasRelays() async -> Bool {
|
||||
guard let servers = try? await getUserServers() else { return false }
|
||||
return servers.contains { op in
|
||||
op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 2: Progress
|
||||
|
||||
private func progressStepView(_ gInfo: GroupInfo) -> some View {
|
||||
let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count
|
||||
let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count
|
||||
let total = groupRelays.count
|
||||
return List {
|
||||
Group {
|
||||
ProfileImage(imageStr: gInfo.groupProfile.image, size: 128)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
||||
Text(gInfo.groupProfile.displayName)
|
||||
.font(.headline)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
||||
|
||||
Section {
|
||||
Button {
|
||||
withAnimation { relayListExpanded.toggle() }
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
if activeCount + failedCount < total {
|
||||
RelayProgressIndicator(active: activeCount, total: total)
|
||||
}
|
||||
if failedCount > 0 {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount))
|
||||
} else {
|
||||
Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total))
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
|
||||
if relayListExpanded {
|
||||
ForEach(groupRelays) { relay in
|
||||
let failed = relayMemberConnFailed(relay)
|
||||
if let err = failed {
|
||||
Button {
|
||||
showAlert(
|
||||
NSLocalizedString("Relay connection failed", comment: "alert title"),
|
||||
message: err
|
||||
)
|
||||
} label: {
|
||||
relayRow(relay, connFailed: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
relayRow(relay, connFailed: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.compactSectionSpacing()
|
||||
|
||||
Section {
|
||||
Button("Channel link") {
|
||||
if activeCount >= total {
|
||||
showLinkStep = true
|
||||
} else if activeCount > 0 {
|
||||
let actions: [UIAlertAction] = if activeCount + failedCount < total {
|
||||
[
|
||||
UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
|
||||
UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in }
|
||||
]
|
||||
} else {
|
||||
[
|
||||
UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true },
|
||||
cancelAlertAction
|
||||
]
|
||||
}
|
||||
showAlert(
|
||||
NSLocalizedString("Not all relays connected", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total),
|
||||
actions: { actions }
|
||||
)
|
||||
}
|
||||
}
|
||||
.disabled(activeCount == 0)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Creating channel")
|
||||
.navigationBarBackButtonHidden(true)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("Cancel") { cancelChannelCreation(gInfo) }
|
||||
}
|
||||
}
|
||||
.onChange(of: channelRelaysModel.groupRelays) { relays in
|
||||
guard channelRelaysModel.groupId == gInfo.groupId else { return }
|
||||
groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) }
|
||||
if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) {
|
||||
showLinkStep = true
|
||||
channelRelaysModel.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relayMemberConnFailed(_ relay: GroupRelay) -> String? {
|
||||
m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?
|
||||
.wrapped.activeConn?.connFailedErr
|
||||
}
|
||||
|
||||
private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View {
|
||||
HStack {
|
||||
Text(relayDisplayName(relay))
|
||||
Spacer()
|
||||
relayStatusIndicator(relay.relayStatus, connFailed: connFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 3: Link
|
||||
|
||||
private func linkStepView(_ gInfo: GroupInfo) -> some View {
|
||||
GroupLinkView(
|
||||
groupId: gInfo.groupId,
|
||||
groupLink: $groupLink,
|
||||
groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays
|
||||
showTitle: false,
|
||||
creatingGroup: true,
|
||||
isChannel: true
|
||||
) {
|
||||
m.creatingChannelId = nil
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
dismissAllSheets(animated: true) {
|
||||
ItemsModel.shared.loadOpenChat(gInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationBarTitle("Channel link")
|
||||
}
|
||||
|
||||
private func cancelChannelCreation(_ gInfo: GroupInfo) {
|
||||
m.creatingChannelId = nil
|
||||
channelRelaysModel.reset()
|
||||
dismissAllSheets(animated: true)
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: .group, id: gInfo.apiId)
|
||||
await MainActor.run { m.removeChat(gInfo.id) }
|
||||
} catch {
|
||||
logger.error("cancelChannelCreation error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func showInvalidChannelNameAlert() {
|
||||
let validName = mkValidName(profile.displayName)
|
||||
if validName == "" {
|
||||
showAlert(NSLocalizedString("Invalid name!", comment: "alert title"))
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid name!", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in
|
||||
profile.displayName = validName
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func relayDisplayName(_ relay: GroupRelay) -> String {
|
||||
if !relay.userChatRelay.displayName.isEmpty { return relay.userChatRelay.displayName }
|
||||
if let domain = relay.userChatRelay.domains.first { return domain }
|
||||
if let link = relay.relayLink { return hostFromRelayLink(link) }
|
||||
return "relay \(relay.groupRelayId)"
|
||||
}
|
||||
|
||||
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View {
|
||||
let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow)
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : status.text
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
Text(text)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
if connFailed {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RelayProgressIndicator: View {
|
||||
var active: Int
|
||||
var total: Int
|
||||
|
||||
var body: some View {
|
||||
if active == 0 {
|
||||
ProgressView()
|
||||
.frame(width: 20, height: 20)
|
||||
} else {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5))
|
||||
Circle()
|
||||
.trim(from: 0, to: Double(active) / Double(max(total, 1)))
|
||||
.stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round))
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: 20, height: 20)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
AddChannelView()
|
||||
}
|
||||
@@ -88,7 +88,7 @@ struct AddGroupView: View {
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0))
|
||||
|
||||
Section {
|
||||
groupNameTextField()
|
||||
@@ -108,6 +108,7 @@ struct AddGroupView: View {
|
||||
focusDisplayName = false
|
||||
}
|
||||
}
|
||||
.compactSectionSpacing()
|
||||
}
|
||||
.onAppear() {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
|
||||
@@ -125,6 +125,14 @@ struct NewChatSheet: View {
|
||||
} label: {
|
||||
Label("Create group", systemImage: "person.2.circle.fill")
|
||||
}
|
||||
NavigationLink {
|
||||
AddChannelView()
|
||||
.navigationTitle("Create public channel")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
if (showArchive) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by spaced4ndy on 28.11.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -73,6 +74,7 @@ func showKeepInvitationAlert() {
|
||||
ChatModel.shared.showingInvitation = nil
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#NewChatView
|
||||
struct NewChatView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -988,42 +990,67 @@ private func showOwnGroupLinkConfirmConnectSheet(
|
||||
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)
|
||||
}
|
||||
if groupInfo.useRelays {
|
||||
showSheet(
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("This is your link for channel %@!", comment: "new chat action"),
|
||||
groupInfo.displayName
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Use current profile", comment: "new chat action"),
|
||||
style: .destructive,
|
||||
handler: { _ in
|
||||
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup)
|
||||
}
|
||||
actions: {[
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Open channel", comment: "new chat action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup)
|
||||
}
|
||||
),
|
||||
UIAlertAction(
|
||||
title: NSLocalizedString("Cancel", comment: "new chat action"),
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
cleanup?()
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
} else {
|
||||
showSheet(
|
||||
String.localizedStringWithFormat(
|
||||
NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"),
|
||||
groupInfo.displayName
|
||||
),
|
||||
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?()
|
||||
}
|
||||
)
|
||||
]}
|
||||
)
|
||||
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(
|
||||
@@ -1072,30 +1099,47 @@ private func showPrepareContactAlert(
|
||||
|
||||
private func showPrepareGroupAlert(
|
||||
connectionLink: CreatedConnLink,
|
||||
groupShortLinkInfo: GroupShortLinkInfo?,
|
||||
groupShortLinkData: GroupShortLinkData,
|
||||
theme: AppTheme,
|
||||
dismiss: Bool,
|
||||
cleanup: (() -> Void)?
|
||||
) {
|
||||
let isChannel = !(groupShortLinkInfo?.direct ?? true)
|
||||
let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" }
|
||||
showOpenChatAlert(
|
||||
profileName: groupShortLinkData.groupProfile.displayName,
|
||||
profileFullName: groupShortLinkData.groupProfile.fullName,
|
||||
profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize),
|
||||
profileImage:
|
||||
ProfileImage(
|
||||
imageStr: groupShortLinkData.groupProfile.image,
|
||||
iconName: isChannel
|
||||
? "antenna.radiowaves.left.and.right.circle.fill"
|
||||
: "person.2.circle.fill",
|
||||
size: alertProfileImageSize
|
||||
),
|
||||
theme: theme,
|
||||
subtitle: isChannel ? subscriberCount : nil,
|
||||
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
||||
confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"),
|
||||
confirmTitle: isChannel
|
||||
? NSLocalizedString("Open new channel", comment: "new chat action")
|
||||
: NSLocalizedString("Open new group", comment: "new chat action"),
|
||||
onCancel: { cleanup?() },
|
||||
onConfirm: {
|
||||
Task {
|
||||
do {
|
||||
let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData)
|
||||
let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData)
|
||||
await MainActor.run {
|
||||
if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty,
|
||||
case let .group(gInfo, _) = chat.chatInfo {
|
||||
ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays
|
||||
}
|
||||
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))
|
||||
showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error))
|
||||
await MainActor.run {
|
||||
cleanup?()
|
||||
}
|
||||
@@ -1136,6 +1180,7 @@ private func showOpenKnownGroupAlert(
|
||||
theme: AppTheme,
|
||||
dismiss: Bool
|
||||
) {
|
||||
let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" }
|
||||
showOpenChatAlert(
|
||||
profileName: groupInfo.groupProfile.displayName,
|
||||
profileFullName: groupInfo.groupProfile.fullName,
|
||||
@@ -1146,9 +1191,15 @@ private func showOpenKnownGroupAlert(
|
||||
size: alertProfileImageSize
|
||||
),
|
||||
theme: theme,
|
||||
subtitle: groupInfo.useRelays ? subscriberCount : nil,
|
||||
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
|
||||
confirmTitle:
|
||||
groupInfo.businessChat == nil
|
||||
groupInfo.useRelays
|
||||
? ( groupInfo.nextConnectPrepared
|
||||
? NSLocalizedString("Open new channel", comment: "new chat action")
|
||||
: NSLocalizedString("Open channel", comment: "new chat action")
|
||||
)
|
||||
: groupInfo.businessChat == nil
|
||||
? ( groupInfo.nextConnectPrepared
|
||||
? NSLocalizedString("Open new group", comment: "new chat action")
|
||||
: NSLocalizedString("Open group", comment: "new chat action")
|
||||
@@ -1163,6 +1214,7 @@ private func showOpenKnownGroupAlert(
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#planAndConnect
|
||||
func planAndConnect(
|
||||
_ shortOrFullLink: String,
|
||||
theme: AppTheme,
|
||||
@@ -1171,6 +1223,14 @@ func planAndConnect(
|
||||
filterKnownContact: ((Contact) -> Void)? = nil,
|
||||
filterKnownGroup: ((GroupInfo) -> Void)? = nil
|
||||
) {
|
||||
if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format {
|
||||
showAlert(
|
||||
NSLocalizedString("Relay address", comment: "alert title"),
|
||||
message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message")
|
||||
)
|
||||
cleanup?()
|
||||
return
|
||||
}
|
||||
ConnectProgressManager.shared.cancelConnectProgress()
|
||||
let inProgress = BoxedValue(true)
|
||||
connectTask(inProgress)
|
||||
@@ -1329,12 +1389,13 @@ func planAndConnect(
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
case let .ok(groupSLinkData_):
|
||||
case let .ok(groupShortLinkInfo_, groupSLinkData_):
|
||||
if let groupSLinkData = groupSLinkData_ {
|
||||
logger.debug("planAndConnect, .groupLink, .ok, short link data present")
|
||||
await MainActor.run {
|
||||
showPrepareGroupAlert(
|
||||
connectionLink: connectionLink,
|
||||
groupShortLinkInfo: groupShortLinkInfo_,
|
||||
groupShortLinkData: groupSLinkData,
|
||||
theme: theme,
|
||||
dismiss: dismiss,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 30/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import CoreImage.CIFilterBuiltins
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Diogo Cunha on 13/11/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by spaced4ndy on 31.10.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 07/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by spaced4ndy on 28.04.2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import Contacts
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 08/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
// Created by Evgeny on 07/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// Spec: spec/client/navigation.md#OnboardingView
|
||||
struct OnboardingView: View {
|
||||
var onboarding: OnboardingStage
|
||||
|
||||
@@ -40,6 +42,7 @@ func onboardingButtonPlaceholder() -> some View {
|
||||
Spacer().frame(height: 40)
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#onboardingStage
|
||||
enum OnboardingStage: String, Identifiable {
|
||||
case step1_SimpleXInfo
|
||||
case step2_CreateProfile // deprecated
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 03/07/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 07/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 24/12/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 03/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/theme.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -21,6 +22,7 @@ let darkThemesWithoutBlackNames: [String] = [DefaultTheme.DARK.themeName, Defaul
|
||||
|
||||
let appSettingsURL = URL(string: UIApplication.openSettingsURLString)!
|
||||
|
||||
// Spec: spec/services/theme.md#AppearanceSettings
|
||||
struct AppearanceSettings: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -313,6 +315,7 @@ struct AppearanceSettings: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ToolbarMaterial
|
||||
enum ToolbarMaterial: String, CaseIterable {
|
||||
case bar
|
||||
case ultraThin
|
||||
@@ -596,6 +599,7 @@ struct CustomizeThemeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ImportExportThemeSection
|
||||
struct ImportExportThemeSection: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showFileImporter: Bool
|
||||
@@ -632,6 +636,7 @@ struct ImportExportThemeSection: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeImporter
|
||||
struct ThemeImporter: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
var save: (ThemeOverrides) -> Void
|
||||
@@ -1141,6 +1146,7 @@ private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64
|
||||
wallpaperFilesToDelete.forEach(removeWallpaperFile)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#decodeYAML
|
||||
private func decodeYAML<T: Decodable>(_ string: String) -> T? {
|
||||
do {
|
||||
return try YAMLDecoder().decode(T.self, from: string)
|
||||
@@ -1150,6 +1156,7 @@ private func decodeYAML<T: Decodable>(_ string: String) -> T? {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#encodeThemeOverrides
|
||||
private func encodeThemeOverrides(_ value: ThemeOverrides) throws -> String {
|
||||
let encoder = YAMLEncoder()
|
||||
encoder.options = YAMLEncoder.Options(sequenceStyle: .block, mappingStyle: .block, newLineScalarStyle: .doubleQuoted)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 02/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
//
|
||||
// ChatRelayView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by spaced4ndy on 23.02.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View {
|
||||
switch relay.tested {
|
||||
case .some(true): Image(systemName: "checkmark").foregroundColor(.green)
|
||||
case .some(false): Image(systemName: "multiply").foregroundColor(.red)
|
||||
case .none: Color.clear
|
||||
}
|
||||
}
|
||||
|
||||
func validRelayName(_ name: String) -> Bool {
|
||||
name != "" && validDisplayName(name)
|
||||
}
|
||||
|
||||
func showInvalidRelayNameAlert(_ name: Binding<String>) {
|
||||
let validName = mkValidName(name.wrappedValue)
|
||||
if validName == "" {
|
||||
showAlert(NSLocalizedString("Invalid name!", comment: "alert title"))
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid name!", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in
|
||||
name.wrappedValue = validName
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validRelayAddress(_ address: String) -> Bool {
|
||||
if let parsedMd = parseSimpleXMarkdown(address),
|
||||
parsedMd.count == 1,
|
||||
case .simplexLink(_, .relay, _, _) = parsedMd.first?.format {
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
func addChatRelay(
|
||||
_ relay: UserChatRelay,
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ serverErrors: Binding<[UserServersError]>,
|
||||
_ serverWarnings: Binding<[UserServersWarning]>? = nil,
|
||||
_ dismiss: DismissAction
|
||||
) {
|
||||
let nameEmpty = relay.displayName.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
if nameEmpty && addressEmpty {
|
||||
dismiss()
|
||||
} else if !validRelayName(relay.displayName) {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid relay name!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check relay name and try again.", comment: "alert message")
|
||||
)
|
||||
} else if !validRelayAddress(relay.address) {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid relay address!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check relay address and try again.", comment: "alert message")
|
||||
)
|
||||
} else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) {
|
||||
userServers[i].wrappedValue.chatRelays.append(relay)
|
||||
validateServers_(userServers, serverErrors, serverWarnings)
|
||||
dismiss()
|
||||
} else { // Shouldn't happen
|
||||
dismiss()
|
||||
showAlert(NSLocalizedString("Error adding relay", comment: "alert title"))
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatRelayView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
@Binding var relay: UserChatRelay
|
||||
@State var relayToEdit: UserChatRelay
|
||||
var backLabel: LocalizedStringKey
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: RelayTestFailure?
|
||||
|
||||
var body: some View {
|
||||
let validName = validRelayName(relayToEdit.displayName)
|
||||
let validAddress = validRelayAddress(relayToEdit.address)
|
||||
ZStack {
|
||||
if relay.preset {
|
||||
presetRelay()
|
||||
} else {
|
||||
customRelay(validName: validName, validAddress: validAddress)
|
||||
}
|
||||
if testing {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) {
|
||||
if validName && validAddress {
|
||||
relay = relayToEdit
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
dismiss()
|
||||
} else if !validName {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid relay name!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check relay name and try again.", comment: "alert message")
|
||||
)
|
||||
} else {
|
||||
dismiss()
|
||||
showAlert(
|
||||
NSLocalizedString("Invalid relay address!", comment: "alert title"),
|
||||
message: NSLocalizedString("Check relay address and try again.", comment: "alert message")
|
||||
)
|
||||
}
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
title: Text("Relay test failed!"),
|
||||
message: Text(testFailure?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.onChange(of: relayToEdit.address) { _ in
|
||||
if relayToEdit.address == relay.address {
|
||||
relayToEdit.tested = relay.tested
|
||||
relayToEdit.displayName = relay.displayName
|
||||
} else {
|
||||
relayToEdit.tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func relayNameHeader(validName: Bool) -> some View {
|
||||
HStack {
|
||||
Text("Your relay name").foregroundColor(theme.colors.secondary)
|
||||
if !validName {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
.onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presetRelay() -> some View {
|
||||
List {
|
||||
Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) {
|
||||
Text(relayToEdit.address)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) {
|
||||
Text(relayToEdit.displayName)
|
||||
}
|
||||
useRelaySection()
|
||||
}
|
||||
}
|
||||
|
||||
private func customRelay(validName: Bool, validAddress: Bool) -> some View {
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $relayToEdit.address)
|
||||
.multilineTextAlignment(.leading)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.allowsTightening(true)
|
||||
.lineLimit(10)
|
||||
.frame(height: 144)
|
||||
.padding(-6)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your relay address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if !validAddress {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
TextField("Enter relay name…", text: $relayToEdit.displayName)
|
||||
.autocorrectionDisabled(true)
|
||||
.disabled(relayToEdit.tested == true)
|
||||
} header: {
|
||||
relayNameHeader(validName: validName)
|
||||
} footer: {
|
||||
if relayToEdit.tested != true {
|
||||
Text("**Test relay** to retrieve its name.")
|
||||
}
|
||||
}
|
||||
useRelaySection(valid: validAddress)
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
relay.deleted = true
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
dismiss()
|
||||
} label: {
|
||||
Label("Delete relay", systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func useRelaySection(valid: Bool = true) -> some View {
|
||||
Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Button("Test relay") {
|
||||
testing = true
|
||||
relayToEdit.tested = nil
|
||||
Task {
|
||||
if let f = await testRelayConnection(relay: $relayToEdit) {
|
||||
showTestFailure = true
|
||||
testFailure = f
|
||||
}
|
||||
await MainActor.run { testing = false }
|
||||
}
|
||||
}
|
||||
.disabled(!valid || testing)
|
||||
Spacer()
|
||||
showRelayTestStatus(relay: relayToEdit)
|
||||
}
|
||||
Toggle("Use for new channels", isOn: $relayToEdit.enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatRelayViewLink: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
@Binding var relay: UserChatRelay
|
||||
var duplicateRelayAddresses: Set<String>
|
||||
var backLabel: LocalizedStringKey
|
||||
@Binding var selectedServer: String?
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(tag: relay.id, selection: $selectedServer) {
|
||||
ChatRelayView(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
relay: $relay,
|
||||
relayToEdit: relay,
|
||||
backLabel: backLabel
|
||||
)
|
||||
.navigationBarTitle("Chat relay")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
} label: {
|
||||
HStack {
|
||||
Group {
|
||||
if duplicateRelayAddresses.contains(relay.address) {
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
} else if !relay.enabled {
|
||||
Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary)
|
||||
} else {
|
||||
showRelayTestStatus(relay: relay)
|
||||
}
|
||||
}
|
||||
.frame(width: 16, alignment: .center)
|
||||
.padding(.trailing, 4)
|
||||
|
||||
let displayName = !relay.displayName.isEmpty ? relay.displayName : relay.domains.first ?? relay.address
|
||||
let v = Text(displayName).lineLimit(1)
|
||||
if relay.enabled {
|
||||
v
|
||||
} else {
|
||||
v.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NewChatRelayView: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
@State private var relayToEdit = UserChatRelay(
|
||||
chatRelayId: nil, address: "", name: "", domains: [],
|
||||
preset: false, tested: nil, enabled: true, deleted: false
|
||||
)
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@State private var testFailure: RelayTestFailure?
|
||||
|
||||
var body: some View {
|
||||
let validName = validRelayName(relayToEdit.displayName)
|
||||
let validAddress = validRelayAddress(relayToEdit.address)
|
||||
ZStack {
|
||||
List {
|
||||
Section {
|
||||
TextEditor(text: $relayToEdit.address)
|
||||
.multilineTextAlignment(.leading)
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.allowsTightening(true)
|
||||
.lineLimit(10)
|
||||
.frame(height: 144)
|
||||
.padding(-6)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your relay address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
if !validAddress {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
TextField("Enter relay name…", text: $relayToEdit.displayName)
|
||||
.autocorrectionDisabled(true)
|
||||
.disabled(relayToEdit.tested == true)
|
||||
} header: {
|
||||
HStack {
|
||||
Text("Your relay name").foregroundColor(theme.colors.secondary)
|
||||
if !validName {
|
||||
Spacer()
|
||||
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
|
||||
.onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) }
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
if relayToEdit.tested != true {
|
||||
Text("**Test relay** to retrieve its name.")
|
||||
}
|
||||
}
|
||||
Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Button("Test relay") {
|
||||
testing = true
|
||||
relayToEdit.tested = nil
|
||||
Task {
|
||||
if let f = await testRelayConnection(relay: $relayToEdit) {
|
||||
showTestFailure = true
|
||||
testFailure = f
|
||||
}
|
||||
await MainActor.run { testing = false }
|
||||
}
|
||||
}
|
||||
.disabled(!validAddress || testing)
|
||||
Spacer()
|
||||
showRelayTestStatus(relay: relayToEdit)
|
||||
}
|
||||
Toggle("Use for new channels", isOn: $relayToEdit.enabled)
|
||||
}
|
||||
}
|
||||
if testing {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss)
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
title: Text("Relay test failed!"),
|
||||
message: Text(testFailure?.localizedDescription ?? "")
|
||||
)
|
||||
}
|
||||
.onChange(of: relayToEdit.address) { _ in
|
||||
relayToEdit.tested = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testRelayConnection(relay: Binding<UserChatRelay>) async -> RelayTestFailure? {
|
||||
do {
|
||||
let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address)
|
||||
if let f = testFailure {
|
||||
await MainActor.run { relay.wrappedValue.tested = false }
|
||||
return f
|
||||
}
|
||||
await MainActor.run {
|
||||
relay.wrappedValue.tested = true
|
||||
if let relayProfile {
|
||||
relay.wrappedValue.displayName = relayProfile.displayName
|
||||
}
|
||||
}
|
||||
return nil
|
||||
} catch {
|
||||
logger.error("testRelayConnection \(responseError(error))")
|
||||
await MainActor.run { relay.wrappedValue.tested = false }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Stanislav Dmitrenko on 26.11.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 02/08/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -77,6 +78,7 @@ struct NetworkAndServers: View {
|
||||
YourServersView(
|
||||
userServers: $ss.servers.userServers,
|
||||
serverErrors: $ss.servers.serverErrors,
|
||||
serverWarnings: $ss.servers.serverWarnings,
|
||||
operatorIndex: idx
|
||||
)
|
||||
.navigationTitle("Your servers")
|
||||
@@ -114,6 +116,9 @@ struct NetworkAndServers: View {
|
||||
} else if !ss.servers.serverErrors.isEmpty {
|
||||
ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error"))
|
||||
}
|
||||
if let warnStr = globalServersWarning(ss.servers.serverWarnings) {
|
||||
ServersWarningView(warnStr: warnStr)
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) {
|
||||
@@ -142,6 +147,8 @@ struct NetworkAndServers: View {
|
||||
ss.servers.currUserServers = try await getUserServers()
|
||||
ss.servers.userServers = ss.servers.currUserServers
|
||||
ss.servers.serverErrors = []
|
||||
ss.servers.serverWarnings = []
|
||||
validateServers_($ss.servers.userServers, $ss.servers.serverErrors, $ss.servers.serverWarnings)
|
||||
} catch let error {
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
@@ -185,6 +192,7 @@ struct NetworkAndServers: View {
|
||||
currUserServers: $ss.servers.currUserServers,
|
||||
userServers: $ss.servers.userServers,
|
||||
serverErrors: $ss.servers.serverErrors,
|
||||
serverWarnings: $ss.servers.serverWarnings,
|
||||
operatorIndex: operatorIndex,
|
||||
useOperator: serverOperator.enabled
|
||||
)
|
||||
@@ -359,13 +367,18 @@ struct SimpleConditionsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
|
||||
func validateServers_(
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ serverErrors: Binding<[UserServersError]>,
|
||||
_ serverWarnings: Binding<[UserServersWarning]>? = nil
|
||||
) {
|
||||
let userServersToValidate = userServers.wrappedValue
|
||||
Task {
|
||||
do {
|
||||
let errs = try await validateServers(userServers: userServersToValidate)
|
||||
let (errs, warns) = try await validateServers(userServers: userServersToValidate)
|
||||
await MainActor.run {
|
||||
serverErrors.wrappedValue = errs
|
||||
serverWarnings?.wrappedValue = warns
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("validateServers error: \(responseError(error))")
|
||||
@@ -395,6 +408,20 @@ struct ServersErrorView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ServersWarningView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var warnStr: String
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
Text(warnStr)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func globalServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
for err in serverErrors {
|
||||
if let errStr = err.globalError {
|
||||
@@ -404,6 +431,29 @@ func globalServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? {
|
||||
for warn in serverWarnings {
|
||||
switch warn {
|
||||
case let .noChatRelays(user):
|
||||
let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning")
|
||||
if let user = user {
|
||||
return String.localizedStringWithFormat(
|
||||
NSLocalizedString("For chat profile %@:", comment: "servers warning"),
|
||||
user.localDisplayName
|
||||
) + " " + text
|
||||
} else { return text }
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> {
|
||||
Binding(
|
||||
get: { userServers[opIndex].wrappedValue.chatRelays },
|
||||
set: { userServers[opIndex].wrappedValue.chatRelays = $0 }
|
||||
)
|
||||
}
|
||||
|
||||
func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? {
|
||||
for err in serverErrors {
|
||||
if let errStr = err.globalSMPError {
|
||||
@@ -433,6 +483,14 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set<String> {
|
||||
return Set(duplicateHostsList)
|
||||
}
|
||||
|
||||
func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set<String> {
|
||||
Set(serverErrors.compactMap { err in
|
||||
if case let .duplicateChatRelayAddress(_, duplicateAddress) = err { return duplicateAddress }
|
||||
else { return nil }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) {
|
||||
let userServersToSave = userServers.wrappedValue
|
||||
Task {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by spaced4ndy on 13.11.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -14,6 +15,7 @@ struct NewServerView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
@State private var serverToEdit: UserServer = .empty
|
||||
@State private var showTestFailure = false
|
||||
@State private var testing = false
|
||||
@@ -27,7 +29,7 @@ struct NewServerView: View {
|
||||
}
|
||||
}
|
||||
.modifier(BackButton(disabled: Binding.constant(false)) {
|
||||
addServer(serverToEdit, $userServers, $serverErrors, dismiss)
|
||||
addServer(serverToEdit, $userServers, $serverErrors, $serverWarnings, dismiss)
|
||||
})
|
||||
.alert(isPresented: $showTestFailure) {
|
||||
Alert(
|
||||
@@ -117,6 +119,7 @@ func addServer(
|
||||
_ server: UserServer,
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ serverErrors: Binding<[UserServersError]>,
|
||||
_ serverWarnings: Binding<[UserServersWarning]>? = nil,
|
||||
_ dismiss: DismissAction
|
||||
) {
|
||||
if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) {
|
||||
@@ -125,7 +128,7 @@ func addServer(
|
||||
case .smp: userServers[i].wrappedValue.smpServers.append(server)
|
||||
case .xftp: userServers[i].wrappedValue.xftpServers.append(server)
|
||||
}
|
||||
validateServers_(userServers, serverErrors)
|
||||
validateServers_(userServers, serverErrors, serverWarnings)
|
||||
dismiss()
|
||||
if let op = matchingOperator {
|
||||
showAlert(
|
||||
@@ -151,6 +154,7 @@ func addServer(
|
||||
#Preview {
|
||||
NewServerView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
serverErrors: Binding.constant([]),
|
||||
serverWarnings: Binding.constant([])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by spaced4ndy on 28.10.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -18,6 +19,7 @@ struct OperatorView: View {
|
||||
@Binding var currUserServers: [UserOperatorServers]
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
var operatorIndex: Int
|
||||
@State var useOperator: Bool
|
||||
@State private var useOperatorToggleReset: Bool = false
|
||||
@@ -40,6 +42,7 @@ struct OperatorView: View {
|
||||
|
||||
private func operatorView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors)
|
||||
return VStack {
|
||||
List {
|
||||
Section {
|
||||
@@ -51,6 +54,8 @@ struct OperatorView: View {
|
||||
} footer: {
|
||||
if let errStr = globalServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else if let warnStr = globalServersWarning(serverWarnings) {
|
||||
ServersWarningView(warnStr: warnStr)
|
||||
} else {
|
||||
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
|
||||
case let .accepted(acceptedAt, _):
|
||||
@@ -68,15 +73,37 @@ struct OperatorView: View {
|
||||
}
|
||||
|
||||
if userServers[operatorIndex].operator_.enabled {
|
||||
if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in
|
||||
if !relay.wrappedValue.deleted {
|
||||
ChatRelayViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
relay: relay,
|
||||
duplicateRelayAddresses: duplicateRelayAddresses,
|
||||
backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else { EmptyView() }
|
||||
}
|
||||
} header: {
|
||||
Text("Chat relays").foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage)
|
||||
.onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy)
|
||||
.onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Use for messages")
|
||||
@@ -96,6 +123,7 @@ struct OperatorView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
@@ -127,6 +155,7 @@ struct OperatorView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
@@ -139,7 +168,7 @@ struct OperatorView: View {
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteSMPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Added message servers")
|
||||
@@ -151,7 +180,7 @@ struct OperatorView: View {
|
||||
Section {
|
||||
Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage)
|
||||
.onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Use for files")
|
||||
@@ -171,6 +200,7 @@ struct OperatorView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
@@ -202,6 +232,7 @@ struct OperatorView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
@@ -214,7 +245,7 @@ struct OperatorView: View {
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteXFTPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Added media & file servers")
|
||||
@@ -226,6 +257,7 @@ struct OperatorView: View {
|
||||
TestServersButton(
|
||||
smpServers: $userServers[operatorIndex].smpServers,
|
||||
xftpServers: $userServers[operatorIndex].xftpServers,
|
||||
chatRelays: $userServers[operatorIndex].chatRelays,
|
||||
testing: $testing
|
||||
)
|
||||
}
|
||||
@@ -245,6 +277,7 @@ struct OperatorView: View {
|
||||
currUserServers: $currUserServers,
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
operatorIndex: operatorIndex
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
@@ -275,18 +308,18 @@ struct OperatorView: View {
|
||||
switch userServers[operatorIndex].operator_.conditionsAcceptance {
|
||||
case .accepted:
|
||||
userServers[operatorIndex].operator_.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
case let .required(deadline):
|
||||
if deadline == nil {
|
||||
showConditionsSheet = true
|
||||
} else {
|
||||
userServers[operatorIndex].operator_.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
userServers[operatorIndex].operator_.enabled = false
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -423,6 +456,7 @@ struct SingleOperatorUsageConditionsView: View {
|
||||
@Binding var currUserServers: [UserOperatorServers]
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
var operatorIndex: Int
|
||||
|
||||
var body: some View {
|
||||
@@ -525,7 +559,7 @@ struct SingleOperatorUsageConditionsView: View {
|
||||
updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators)
|
||||
updateOperatorsConditionsAcceptance($userServers, r.serverOperators)
|
||||
userServers[operatorIndexToEnable].operator?.enabled = true
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
@@ -580,6 +614,7 @@ func conditionsLinkButton() -> some View {
|
||||
currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
|
||||
userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
serverWarnings: Binding.constant([]),
|
||||
operatorIndex: 1,
|
||||
useOperator: ServerOperator.sampleData1.enabled
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -14,6 +15,7 @@ struct ProtocolServerView: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
@Binding var server: UserServer
|
||||
@State var serverToEdit: UserServer
|
||||
var backLabel: LocalizedStringKey
|
||||
@@ -49,7 +51,7 @@ struct ProtocolServerView: View {
|
||||
)
|
||||
} else {
|
||||
server = serverToEdit
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
dismiss()
|
||||
}
|
||||
} else {
|
||||
@@ -201,6 +203,7 @@ struct ProtocolServerView_Previews: PreviewProvider {
|
||||
ProtocolServerView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
serverWarnings: Binding.constant([]),
|
||||
server: Binding.constant(UserServer.sampleData.custom),
|
||||
serverToEdit: UserServer.sampleData.custom,
|
||||
backLabel: "Your SMP servers"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 15/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -18,10 +19,12 @@ struct YourServersView: View {
|
||||
@Environment(\.editMode) private var editMode
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
var operatorIndex: Int
|
||||
@State private var selectedServer: String? = nil
|
||||
@State private var showAddServer = false
|
||||
@State private var newServerNavLinkActive = false
|
||||
@State private var newChatRelayNavLinkActive = false
|
||||
@State private var showScanProtoServer = false
|
||||
@State private var testing = false
|
||||
|
||||
@@ -40,7 +43,34 @@ struct YourServersView: View {
|
||||
|
||||
private func yourServersView() -> some View {
|
||||
let duplicateHosts = findDuplicateHosts(serverErrors)
|
||||
let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors)
|
||||
return List {
|
||||
if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in
|
||||
if !relay.wrappedValue.deleted {
|
||||
ChatRelayViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
relay: relay,
|
||||
duplicateRelayAddresses: duplicateRelayAddresses,
|
||||
backLabel: "Your servers",
|
||||
selectedServer: $selectedServer
|
||||
)
|
||||
} else { EmptyView() }
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteChatRelay($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Chat relays").foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
|
||||
Section {
|
||||
ForEach($userServers[operatorIndex].smpServers) { srv in
|
||||
@@ -48,6 +78,7 @@ struct YourServersView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .smp,
|
||||
@@ -60,7 +91,7 @@ struct YourServersView: View {
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteSMPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Message servers")
|
||||
@@ -83,6 +114,7 @@ struct YourServersView: View {
|
||||
ProtocolServerViewLink(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
duplicateHosts: duplicateHosts,
|
||||
server: srv,
|
||||
serverProtocol: .xftp,
|
||||
@@ -95,7 +127,7 @@ struct YourServersView: View {
|
||||
}
|
||||
.onDelete { indexSet in
|
||||
deleteXFTPServer($userServers, operatorIndex, indexSet)
|
||||
validateServers_($userServers, $serverErrors)
|
||||
validateServers_($userServers, $serverErrors, $serverWarnings)
|
||||
}
|
||||
} header: {
|
||||
Text("Media & file servers")
|
||||
@@ -124,10 +156,23 @@ struct YourServersView: View {
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
|
||||
NavigationLink(isActive: $newChatRelayNavLinkActive) {
|
||||
NewChatRelayView(userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings)
|
||||
.navigationTitle("New chat relay")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.frame(width: 1, height: 1)
|
||||
.hidden()
|
||||
}
|
||||
} footer: {
|
||||
if let errStr = globalServersError(serverErrors) {
|
||||
ServersErrorView(errStr: errStr)
|
||||
} else if let warnStr = globalServersWarning(serverWarnings) {
|
||||
ServersWarningView(warnStr: warnStr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +180,7 @@ struct YourServersView: View {
|
||||
TestServersButton(
|
||||
smpServers: $userServers[operatorIndex].smpServers,
|
||||
xftpServers: $userServers[operatorIndex].xftpServers,
|
||||
chatRelays: $userServers[operatorIndex].chatRelays,
|
||||
testing: $testing
|
||||
)
|
||||
howToButton()
|
||||
@@ -143,7 +189,8 @@ struct YourServersView: View {
|
||||
.toolbar {
|
||||
if (
|
||||
!userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty ||
|
||||
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty
|
||||
!userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty ||
|
||||
!userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty
|
||||
) {
|
||||
EditButton()
|
||||
}
|
||||
@@ -151,11 +198,13 @@ struct YourServersView: View {
|
||||
.confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) {
|
||||
Button("Enter server manually") { newServerNavLinkActive = true }
|
||||
Button("Scan server QR code") { showScanProtoServer = true }
|
||||
Button("Chat relay") { newChatRelayNavLinkActive = true }
|
||||
}
|
||||
.sheet(isPresented: $showScanProtoServer) {
|
||||
ScanProtocolServer(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings
|
||||
)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
@@ -164,7 +213,8 @@ struct YourServersView: View {
|
||||
private func newServerDestinationView() -> some View {
|
||||
NewServerView(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings
|
||||
)
|
||||
.navigationTitle("New server")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
@@ -189,6 +239,7 @@ struct ProtocolServerViewLink: View {
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
var duplicateHosts: Set<String>
|
||||
@Binding var server: UserServer
|
||||
var serverProtocol: ServerProtocol
|
||||
@@ -202,6 +253,7 @@ struct ProtocolServerViewLink: View {
|
||||
ProtocolServerView(
|
||||
userServers: $userServers,
|
||||
serverErrors: $serverErrors,
|
||||
serverWarnings: $serverWarnings,
|
||||
server: $server,
|
||||
serverToEdit: server,
|
||||
backLabel: backLabel
|
||||
@@ -279,9 +331,27 @@ func deleteXFTPServer(
|
||||
}
|
||||
}
|
||||
|
||||
func deleteChatRelay(
|
||||
_ userServers: Binding<[UserOperatorServers]>,
|
||||
_ operatorServersIndex: Int,
|
||||
_ serverIndexSet: IndexSet
|
||||
) {
|
||||
if let idx = serverIndexSet.first {
|
||||
let relay = userServers[operatorServersIndex].wrappedValue.chatRelays[idx]
|
||||
if relay.chatRelayId == nil {
|
||||
userServers[operatorServersIndex].wrappedValue.chatRelays.remove(at: idx)
|
||||
} else {
|
||||
var updatedRelay = relay
|
||||
updatedRelay.deleted = true
|
||||
userServers[operatorServersIndex].wrappedValue.chatRelays[idx] = updatedRelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TestServersButton: View {
|
||||
@Binding var smpServers: [UserServer]
|
||||
@Binding var xftpServers: [UserServer]
|
||||
@Binding var chatRelays: [UserChatRelay]
|
||||
@Binding var testing: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -290,20 +360,24 @@ struct TestServersButton: View {
|
||||
}
|
||||
|
||||
private var allServersDisabled: Bool {
|
||||
smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled }
|
||||
smpServers.allSatisfy { !$0.enabled } &&
|
||||
xftpServers.allSatisfy { !$0.enabled } &&
|
||||
chatRelays.filter({ !$0.deleted }).allSatisfy { !$0.enabled }
|
||||
}
|
||||
|
||||
private func testServers() {
|
||||
resetTestStatus()
|
||||
testing = true
|
||||
Task {
|
||||
let fs = await runServersTest()
|
||||
let rfs = await runRelaysTest()
|
||||
let sfs = await runServersTest()
|
||||
await MainActor.run {
|
||||
testing = false
|
||||
if !fs.isEmpty {
|
||||
let msg = fs.map { (srv, f) in
|
||||
"\(srv): \(f.localizedDescription)"
|
||||
}.joined(separator: "\n")
|
||||
var failures: [String] = []
|
||||
failures += rfs.map { (name, f) in "\(name): \(f.localizedDescription)" }
|
||||
failures += sfs.map { (srv, f) in "\(srv): \(f.localizedDescription)" }
|
||||
if !failures.isEmpty {
|
||||
let msg = failures.joined(separator: "\n")
|
||||
showAlert(
|
||||
NSLocalizedString("Tests failed!", comment: "alert title"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg)
|
||||
@@ -314,6 +388,12 @@ struct TestServersButton: View {
|
||||
}
|
||||
|
||||
private func resetTestStatus() {
|
||||
for i in 0..<chatRelays.count {
|
||||
if chatRelays[i].enabled && !chatRelays[i].deleted {
|
||||
chatRelays[i].tested = nil
|
||||
}
|
||||
}
|
||||
|
||||
for i in 0..<smpServers.count {
|
||||
if smpServers[i].enabled {
|
||||
smpServers[i].tested = nil
|
||||
@@ -346,6 +426,19 @@ struct TestServersButton: View {
|
||||
}
|
||||
return fs
|
||||
}
|
||||
|
||||
private func runRelaysTest() async -> [String: RelayTestFailure] {
|
||||
var fs: [String: RelayTestFailure] = [:]
|
||||
for i in 0..<chatRelays.count {
|
||||
if chatRelays[i].enabled && !chatRelays[i].deleted {
|
||||
if let f = await testRelayConnection(relay: $chatRelays[i]) {
|
||||
let name = !chatRelays[i].displayName.isEmpty ? chatRelays[i].displayName : chatRelays[i].domains.first ?? chatRelays[i].address
|
||||
fs[name] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
return fs
|
||||
}
|
||||
}
|
||||
|
||||
struct YourServersView_Previews: PreviewProvider {
|
||||
@@ -353,6 +446,7 @@ struct YourServersView_Previews: PreviewProvider {
|
||||
YourServersView(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([]),
|
||||
serverWarnings: Binding.constant([]),
|
||||
operatorIndex: 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 19/11/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@@ -14,6 +15,7 @@ struct ScanProtocolServer: View {
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@Binding var userServers: [UserOperatorServers]
|
||||
@Binding var serverErrors: [UserServersError]
|
||||
@Binding var serverWarnings: [UserServersWarning]
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading) {
|
||||
@@ -35,7 +37,7 @@ struct ScanProtocolServer: View {
|
||||
case let .success(r):
|
||||
var server: UserServer = .empty
|
||||
server.server = r.string
|
||||
addServer(server, $userServers, $serverErrors, dismiss)
|
||||
addServer(server, $userServers, $serverErrors, $serverWarnings, dismiss)
|
||||
case let .failure(e):
|
||||
logger.error("ScanProtocolServer.processQRCode QR code error: \(e.localizedDescription)")
|
||||
dismiss()
|
||||
@@ -47,7 +49,8 @@ struct ScanProtocolServer_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ScanProtocolServer(
|
||||
userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]),
|
||||
serverErrors: Binding.constant([])
|
||||
serverErrors: Binding.constant([]),
|
||||
serverWarnings: Binding.constant([])
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 31/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import StoreKit
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user