Merge branch 'master' into ep/privacy

This commit is contained in:
Evgeny @ SimpleX Chat
2026-04-11 07:32:06 +00:00
468 changed files with 52353 additions and 4057 deletions
+6
View File
@@ -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 }}
+1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) &nbsp;&nbsp;&nbsp; [<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).
+223
View File
@@ -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 46.
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 |
+1
View File
@@ -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
+16 -1
View File
@@ -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(
+110 -17
View File
@@ -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?
+6
View File
@@ -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")
+66 -2
View File
@@ -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]
+10
View File
@@ -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))
}
+80 -12
View File
@@ -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 {
+3
View File
@@ -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
+3
View File
@@ -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
}
+13
View File
@@ -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
}
+266 -73
View File
@@ -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) {
+101 -40
View File
@@ -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