mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 14:45:33 +00:00
Merge branch 'master' into chat-relays
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# Coding and building
|
||||
|
||||
You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context.
|
||||
|
||||
## Three-Layer Documentation Architecture
|
||||
|
||||
### Why this structure exists
|
||||
|
||||
LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results.
|
||||
|
||||
The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves.
|
||||
|
||||
### The layers
|
||||
|
||||
| Layer | Contains | Question it answers |
|
||||
|-------|----------|-------------------|
|
||||
| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? |
|
||||
| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? |
|
||||
| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? |
|
||||
| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? |
|
||||
|
||||
Each layer links to the next:
|
||||
- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point
|
||||
- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents
|
||||
- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec
|
||||
- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift.
|
||||
- Reverse direction: the Document Map (end of this file) maps source → spec → product
|
||||
|
||||
### Navigation workflow
|
||||
|
||||
When the user requests any change, you MUST follow these steps before writing any code:
|
||||
|
||||
1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas.
|
||||
|
||||
2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract.
|
||||
|
||||
3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees.
|
||||
|
||||
4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior.
|
||||
|
||||
5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information.
|
||||
|
||||
For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6.
|
||||
|
||||
6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below.
|
||||
|
||||
### Key navigation documents
|
||||
|
||||
| Document | Purpose | When to read |
|
||||
|----------|---------|-------------|
|
||||
| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change |
|
||||
| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior |
|
||||
| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms |
|
||||
| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature |
|
||||
| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change |
|
||||
| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation |
|
||||
|
||||
---
|
||||
|
||||
## Code Security
|
||||
|
||||
When designing code and planning implementations, you MUST:
|
||||
- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries.
|
||||
- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses.
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
**Follow existing code patterns — you MUST:**
|
||||
- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise.
|
||||
- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness.
|
||||
- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs.
|
||||
|
||||
**Comments policy — you MUST:**
|
||||
- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code.
|
||||
- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments.
|
||||
- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value.
|
||||
|
||||
**Diff and refactoring — you MUST:**
|
||||
- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff.
|
||||
- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit.
|
||||
- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert.
|
||||
|
||||
**Document and code structure — you MUST:**
|
||||
- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame.
|
||||
- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability.
|
||||
- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult.
|
||||
|
||||
**Code analysis and review — you MUST:**
|
||||
- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs.
|
||||
- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior.
|
||||
- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs.
|
||||
|
||||
---
|
||||
|
||||
## Plans
|
||||
|
||||
When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was.
|
||||
|
||||
### Plan requirements
|
||||
|
||||
1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions.
|
||||
|
||||
2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent.
|
||||
|
||||
3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth.
|
||||
|
||||
4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation.
|
||||
|
||||
---
|
||||
|
||||
## Change Protocol
|
||||
|
||||
### The rule
|
||||
|
||||
Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change.
|
||||
|
||||
### What to update
|
||||
|
||||
1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification.
|
||||
|
||||
2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion.
|
||||
|
||||
3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value.
|
||||
|
||||
4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work.
|
||||
|
||||
5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates.
|
||||
|
||||
6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context.
|
||||
|
||||
7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt.
|
||||
|
||||
8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later.
|
||||
|
||||
9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable.
|
||||
|
||||
### Adversarial self-review
|
||||
|
||||
After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers.
|
||||
|
||||
**Within-layer coherence — you MUST verify:**
|
||||
- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact
|
||||
- product/ is internally consistent — flows match views, rules match behavior descriptions
|
||||
|
||||
**Across-layer coherence — you MUST verify:**
|
||||
- Every new or changed function in source appears in the corresponding spec/ document
|
||||
- Every user-visible behavior change in source appears in the relevant product/ document
|
||||
- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines
|
||||
- All cross-references resolve — product → spec links, spec → source links
|
||||
- `spec/impact.md` covers all affected product concepts for the changed source files
|
||||
- `product/concepts.md` rows are current for any affected concepts
|
||||
|
||||
**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent.
|
||||
|
||||
---
|
||||
|
||||
## Document Map
|
||||
|
||||
### iOS Swift Sources
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md |
|
||||
| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md |
|
||||
| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md |
|
||||
| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md |
|
||||
| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md |
|
||||
| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md |
|
||||
| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md |
|
||||
| Shared/Views/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 |
|
||||
|
||||
### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`)
|
||||
|
||||
| Source Location | Spec Document | Product Document |
|
||||
|----------------|---------------|-----------------|
|
||||
| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md |
|
||||
| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md |
|
||||
| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md |
|
||||
| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md |
|
||||
| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md |
|
||||
| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md |
|
||||
| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md |
|
||||
| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md |
|
||||
| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md |
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 30/03/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 17/01/2022.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import Intents
|
||||
@@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#ContentView
|
||||
struct ContentView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var alertManager = AlertManager.shared
|
||||
@ObservedObject var callController = CallController.shared
|
||||
// Spec: spec/client/navigation.md#AppSheetState
|
||||
@ObservedObject var appSheetState = AppSheetState.shared
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
|
||||
// Spec: spec/client/navigation.md#contentAccessAuthenticationExtended
|
||||
var contentAccessAuthenticationExtended: Bool
|
||||
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@@ -161,6 +165,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#contentView
|
||||
@ViewBuilder private func contentView() -> some View {
|
||||
if let status = chatModel.chatDbStatus, status != .ok {
|
||||
DatabaseErrorView(status: status)
|
||||
@@ -176,6 +181,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#callView
|
||||
@ViewBuilder private func callView(_ call: Call) -> some View {
|
||||
if CallController.useCallKit() {
|
||||
ActiveCallView(call: call, canConnectCall: Binding.constant(true))
|
||||
@@ -193,6 +199,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#callBanner
|
||||
private func activeCallInteractiveArea(_ call: Call) -> some View {
|
||||
HStack {
|
||||
Text(call.contact.displayName).font(.body).foregroundColor(.white)
|
||||
@@ -227,6 +234,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#lockButton
|
||||
private func lockButton() -> some View {
|
||||
Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") }
|
||||
}
|
||||
@@ -339,6 +347,7 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#unlockedRecently
|
||||
private func unlockedRecently() -> Bool {
|
||||
if let lastSuccessfulUnlock = lastSuccessfulUnlock {
|
||||
return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2
|
||||
@@ -426,6 +435,7 @@ struct ContentView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#connectViaUrl
|
||||
func connectViaUrl() {
|
||||
let m = ChatModel.shared
|
||||
if let url = m.appOpenUrl {
|
||||
|
||||
@@ -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)
|
||||
@@ -643,6 +645,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])
|
||||
@@ -764,6 +767,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)
|
||||
@@ -903,6 +907,7 @@ enum ChatResponse1: Decodable, ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatResponse2
|
||||
enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
// group responses
|
||||
case groupCreated(user: UserRef, groupInfo: GroupInfo)
|
||||
@@ -1046,6 +1051,7 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatEvent
|
||||
enum ChatEvent: Decodable, ChatAPIResult {
|
||||
case chatSuspended
|
||||
case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
@@ -25,6 +26,7 @@ private let maxBgRefreshInterval: TimeInterval = 2400 // 40 minutes
|
||||
|
||||
private let maxTimerCount = 9
|
||||
|
||||
// Spec: spec/services/notifications.md#BGManager
|
||||
class BGManager {
|
||||
static let shared = BGManager()
|
||||
var chatReceiver: ChatReceiver?
|
||||
@@ -32,6 +34,7 @@ class BGManager {
|
||||
var completed = true
|
||||
var timerCount = 0
|
||||
|
||||
// Spec: spec/services/notifications.md#register
|
||||
func register() {
|
||||
logger.debug("BGManager.register")
|
||||
BGTaskScheduler.shared.register(forTaskWithIdentifier: receiveTaskId, using: nil) { task in
|
||||
@@ -39,6 +42,7 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#schedule
|
||||
func schedule(interval: TimeInterval? = nil) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.schedule: disabled")
|
||||
@@ -66,6 +70,7 @@ class BGManager {
|
||||
Date.now.timeIntervalSince(chatLastBackgroundRunGroupDefault.get()) > runInterval
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#handleRefresh
|
||||
private func handleRefresh(_ task: BGAppRefreshTask) {
|
||||
if !ChatModel.shared.ntfEnableLocal {
|
||||
logger.debug("BGManager.handleRefresh: disabled")
|
||||
@@ -103,6 +108,7 @@ class BGManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#receiveMessages-BG
|
||||
func receiveMessages(_ completeReceiving: @escaping (String) -> Void) {
|
||||
if (!self.completed) {
|
||||
logger.debug("BGManager.receiveMessages: in progress, exiting")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 22/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/state.md
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
@@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
|
||||
}
|
||||
|
||||
// analogue for SecondaryContextFilter in Kotlin
|
||||
// Spec: spec/state.md#SecondaryItemsModelFilter
|
||||
enum SecondaryItemsModelFilter {
|
||||
case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo)
|
||||
case msgContentTagContext(contentTag: MsgContentTag)
|
||||
@@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter {
|
||||
}
|
||||
|
||||
// analogue for ChatsContext in Kotlin
|
||||
// Spec: spec/state.md#ItemsModel
|
||||
class ItemsModel: ObservableObject {
|
||||
static let shared = ItemsModel(secondaryIMFilter: nil)
|
||||
public var secondaryIMFilter: SecondaryItemsModelFilter?
|
||||
@@ -103,12 +106,14 @@ class ItemsModel: ObservableObject {
|
||||
.store(in: &bag)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadSecondaryChat
|
||||
static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) {
|
||||
let im = ItemsModel(secondaryIMFilter: chatFilter)
|
||||
ChatModel.shared.secondaryIM = im
|
||||
im.loadOpenChat(chatId, willNavigate: willNavigate)
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadOpenChat
|
||||
func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
@@ -134,6 +139,7 @@ class ItemsModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#loadOpenChatNoWait
|
||||
func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) {
|
||||
navigationTimeoutTask?.cancel()
|
||||
loadChatTask?.cancel()
|
||||
@@ -179,6 +185,7 @@ class PreloadState {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatTagsModel
|
||||
class ChatTagsModel: ObservableObject {
|
||||
static let shared = ChatTagsModel()
|
||||
|
||||
@@ -326,6 +333,7 @@ class ConnectProgressManager: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatModel
|
||||
final class ChatModel: ObservableObject {
|
||||
@Published var onboardingStage: OnboardingStage?
|
||||
@Published var setDeliveryReceipts = false
|
||||
@@ -383,6 +391,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 +432,7 @@ final class ChatModel: ObservableObject {
|
||||
userAddress?.shortLinkDataSet ?? true
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#getUser
|
||||
func getUser(_ userId: Int64) -> User? {
|
||||
currentUser?.userId == userId
|
||||
? currentUser
|
||||
@@ -433,6 +443,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 +453,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 +464,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 +519,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 +533,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 +585,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 +1070,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 +1170,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,6 +1194,7 @@ final class ChatModel: ObservableObject {
|
||||
showingInvitation?.connChatUsed = true
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#removeChat
|
||||
func removeChat(_ id: String) {
|
||||
withAnimation {
|
||||
if let i = getChatIndex(id) {
|
||||
@@ -1248,6 +1267,7 @@ struct NTFContactRequest {
|
||||
var chatId: String
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#Chat
|
||||
final class Chat: ObservableObject, Identifiable, ChatLike {
|
||||
@Published var chatInfo: ChatInfo
|
||||
@Published var chatItems: [ChatItem]
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 08/02/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
@@ -22,6 +23,7 @@ enum NtfCallAction {
|
||||
case reject
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#NtfManager
|
||||
class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
static let shared = NtfManager()
|
||||
|
||||
@@ -48,6 +50,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
handler()
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#processNotificationResponse
|
||||
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
|
||||
let chatModel = ChatModel.shared
|
||||
let content = ntfResponse.notification.request.content
|
||||
@@ -149,6 +152,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
return false
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#registerCategories
|
||||
func registerCategories() {
|
||||
logger.debug("NtfManager.registerCategories")
|
||||
UNUserNotificationCenter.current().setNotificationCategories([
|
||||
@@ -207,6 +211,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
])
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#requestAuthorization
|
||||
func requestAuthorization(onDeny denied: (()-> Void)? = nil, onAuthorized authorized: (()-> Void)? = nil) {
|
||||
logger.debug("NtfManager.requestAuthorization")
|
||||
let center = UNUserNotificationCenter.current()
|
||||
@@ -230,6 +235,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyContactRequest
|
||||
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
|
||||
logger.debug("NtfManager.notifyContactRequest")
|
||||
addNotification(createContactRequestNtf(user, contactRequest, 0))
|
||||
@@ -240,6 +246,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
addNotification(createContactConnectedNtf(user, contact, 0))
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyMessageReceived
|
||||
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
|
||||
logger.debug("NtfManager.notifyMessageReceived")
|
||||
if cInfo.ntfsEnabled(chatItem: cItem) {
|
||||
@@ -247,16 +254,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#notifyCallInvitation
|
||||
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
|
||||
logger.debug("NtfManager.notifyCallInvitation")
|
||||
addNotification(createCallInvitationNtf(invitation, 0))
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#setNtfBadgeCount
|
||||
func setNtfBadgeCount(_ count: Int) {
|
||||
UIApplication.shared.applicationIconBadgeNumber = count
|
||||
ntfBadgeCountGroupDefault.set(count)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#changeNtfBadgeCount
|
||||
func changeNtfBadgeCount(by count: Int = 1) {
|
||||
setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber + count))
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny Poberezkin on 27/01/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/api.md | spec/architecture.md
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
@@ -49,6 +50,7 @@ enum TerminalItem: Identifiable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#beginBGTask
|
||||
func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
|
||||
var id: UIBackgroundTaskIdentifier!
|
||||
var running = true
|
||||
@@ -86,12 +88,14 @@ private func withBGTask<T>(bgDelay: Double? = nil, f: @escaping () -> T) -> T {
|
||||
return r
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatSendCmdSync
|
||||
@inline(__always)
|
||||
func chatSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R {
|
||||
let res: APIResult<R> = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
|
||||
return try apiResult(res)
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmdSync
|
||||
func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult<R> {
|
||||
if log {
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
@@ -112,12 +116,14 @@ func chatApiSendCmdSync<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = tru
|
||||
return resp
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatSendCmd
|
||||
@inline(__always)
|
||||
func chatSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R {
|
||||
let res: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log)
|
||||
return try apiResult(res)
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmdWithRetry
|
||||
func chatApiSendCmdWithRetry<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue<Bool>? = nil, retryNum: Int32 = 0) async -> APIResult<R>? {
|
||||
let r: APIResult<R> = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum)
|
||||
if inProgress == nil || inProgress?.boxedValue == true,
|
||||
@@ -210,6 +216,7 @@ func proxyDestinationErrorAlertMessage(proxyServer: String, destServer: String)
|
||||
String.localizedStringWithFormat(NSLocalizedString("Forwarding server %@ failed to connect to destination server %@. Please try later.", comment: "alert message"), serverHostname(proxyServer), serverHostname(destServer))
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatApiSendCmd
|
||||
@inline(__always)
|
||||
func chatApiSendCmd<R: ChatAPIResult>(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult<R> {
|
||||
await withCheckedContinuation { cont in
|
||||
@@ -226,6 +233,7 @@ func apiResult<R: ChatAPIResult>(_ res: APIResult<R>) throws -> R {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#chatRecvMsg
|
||||
func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult<ChatEvent>? {
|
||||
await withCheckedContinuation { cont in
|
||||
_ = withBGTask(bgDelay: msgDelay) { () -> APIResult<ChatEvent>? in
|
||||
@@ -346,6 +354,7 @@ func apiStopChat() async throws {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#apiActivateChat
|
||||
func apiActivateChat() {
|
||||
chatReopenStore()
|
||||
do {
|
||||
@@ -355,6 +364,7 @@ func apiActivateChat() {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/architecture.md#apiSuspendChat
|
||||
func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
do {
|
||||
try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
|
||||
@@ -363,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#apiSetAppFilePaths
|
||||
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws {
|
||||
let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl)
|
||||
if case .cmdOk = r { return }
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#apiSetEncryptLocalFiles
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable))
|
||||
}
|
||||
@@ -1455,6 +1467,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 +1586,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 +1609,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)
|
||||
}
|
||||
@@ -2078,6 +2094,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 +2216,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 +2262,7 @@ class ChatReceiver {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#processReceivedMsg
|
||||
func processReceivedMsg(_ res: ChatEvent) async {
|
||||
let m = ChatModel.shared
|
||||
logger.debug("processReceivedMsg: \(res.responseType)")
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Evgeny Poberezkin on 17/01/2022.
|
||||
//
|
||||
// Spec: spec/architecture.md
|
||||
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
@@ -12,6 +13,7 @@ import SimpleXChat
|
||||
let logger = Logger()
|
||||
|
||||
@main
|
||||
// Spec: spec/architecture.md#SimpleXApp
|
||||
struct SimpleXApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var chatModel = ChatModel.shared
|
||||
@@ -60,6 +62,7 @@ struct SimpleXApp: App {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spec: spec/architecture.md#scenePhaseHandling
|
||||
.onChange(of: scenePhase) { phase in
|
||||
logger.debug("scenePhase was \(String(describing: scenePhase)), now \(String(describing: phase))")
|
||||
AppSheetState.shared.scenePhaseActive = phase == .active
|
||||
|
||||
@@ -10,6 +10,7 @@ import Foundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/theme.md#CurrentColors
|
||||
var CurrentColors: ThemeManager.ActiveTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
|
||||
var MenuTextColor: Color { if isInDarkTheme() { AppTheme.shared.colors.onBackground.opacity(0.8) } else { Color.black } }
|
||||
@@ -17,6 +18,7 @@ var NoteFolderIconColor: Color { AppTheme.shared.appColors.primaryVariant2 }
|
||||
|
||||
func isInDarkTheme() -> Bool { !CurrentColors.colors.isLight }
|
||||
|
||||
// Spec: spec/services/theme.md#AppTheme
|
||||
class AppTheme: ObservableObject, Equatable {
|
||||
static let shared = AppTheme(name: CurrentColors.name, base: CurrentColors.base, colors: CurrentColors.colors, appColors: CurrentColors.appColors, wallpaper: CurrentColors.wallpaper)
|
||||
|
||||
@@ -89,6 +91,7 @@ struct ThemedBackground: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#systemInDarkThemeCurrently
|
||||
var systemInDarkThemeCurrently: Bool {
|
||||
return UITraitCollection.current.userInterfaceStyle == .dark
|
||||
}
|
||||
|
||||
@@ -5,12 +5,15 @@
|
||||
// Created by Avently on 03.06.2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/theme.md
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeManager
|
||||
class ThemeManager {
|
||||
// Spec: spec/services/theme.md#ActiveTheme
|
||||
struct ActiveTheme: Equatable {
|
||||
let name: String
|
||||
let base: DefaultTheme
|
||||
@@ -41,6 +44,7 @@ class ThemeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#defaultActiveTheme
|
||||
static func defaultActiveTheme(_ appSettingsTheme: [ThemeOverrides]) -> ThemeOverrides? {
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
let defaultThemeId = currentThemeIdsDefault.get()[nonSystemThemeName]
|
||||
@@ -56,6 +60,7 @@ class ThemeManager {
|
||||
return ThemeModeOverride(mode: CurrentColors.base.mode, colors: defaultTheme?.colors ?? ThemeColors(), wallpaper: defaultTheme?.wallpaper ?? ThemeWallpaper.from(PresetWallpaper.school.toType(CurrentColors.base), nil, nil))
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#currentColors
|
||||
static func currentColors(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?, _ appSettingsTheme: [ThemeOverrides]) -> ActiveTheme {
|
||||
let themeName = currentThemeDefault.get()
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
@@ -96,6 +101,7 @@ class ThemeManager {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#currentThemeOverridesForExport
|
||||
static func currentThemeOverridesForExport(_ themeOverridesForType: WallpaperType?, _ perChatTheme: ThemeModeOverride?, _ perUserTheme: ThemeModeOverrides?) -> ThemeOverrides {
|
||||
let current = currentColors(themeOverridesForType, perChatTheme, perUserTheme, themeOverridesDefault.get())
|
||||
let wType = current.wallpaper.type
|
||||
@@ -114,6 +120,7 @@ class ThemeManager {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#applyTheme
|
||||
static func applyTheme(_ theme: String) {
|
||||
currentThemeDefault.set(theme)
|
||||
CurrentColors = currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
|
||||
@@ -125,6 +132,7 @@ class ThemeManager {
|
||||
// applyNavigationBarColors(CurrentColors.toAppTheme())
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#adjustWindowStyle
|
||||
static func adjustWindowStyle() {
|
||||
let style = switch currentThemeDefault.get() {
|
||||
case DefaultTheme.LIGHT.themeName: UIUserInterfaceStyle.light
|
||||
@@ -161,6 +169,7 @@ class ThemeManager {
|
||||
AppTheme.shared.updateFromCurrentColors()
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyThemeColor
|
||||
static func saveAndApplyThemeColor(_ baseTheme: DefaultTheme, _ name: ThemeColor, _ color: Color? = nil, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let nonSystemThemeName = baseTheme.themeName
|
||||
let pref = pref ?? themeOverridesDefault
|
||||
@@ -178,6 +187,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = pref.wrappedValue.withUpdatedColor(name, color?.toReadableHex())
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyWallpaper
|
||||
static func saveAndApplyWallpaper(_ baseTheme: DefaultTheme, _ type: WallpaperType?, _ pref: CodableDefault<[ThemeOverrides]>?) {
|
||||
let nonSystemThemeName = baseTheme.themeName
|
||||
let pref = pref ?? themeOverridesDefault
|
||||
@@ -253,6 +263,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = prevValue
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#saveAndApplyThemeOverrides
|
||||
static func saveAndApplyThemeOverrides(_ theme: ThemeOverrides, _ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let wallpaper = theme.wallpaper?.importFromString()
|
||||
let nonSystemThemeName = theme.base.themeName
|
||||
@@ -273,6 +284,7 @@ class ThemeManager {
|
||||
applyTheme(nonSystemThemeName)
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#resetAllThemeColors
|
||||
static func resetAllThemeColors(_ pref: CodableDefault<[ThemeOverrides]>? = nil) {
|
||||
let nonSystemThemeName = nonSystemThemeName()
|
||||
let pref: CodableDefault<[ThemeOverrides]> = pref ?? themeOverridesDefault
|
||||
@@ -295,6 +307,7 @@ class ThemeManager {
|
||||
pref.wrappedValue = prevValue
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#removeTheme
|
||||
static func removeTheme(_ themeId: String?) {
|
||||
var themes = themeOverridesDefault.get().map { $0 }
|
||||
themes.removeAll(where: { $0.themeId == themeId })
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
// Created by Evgeny on 05/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
import SimpleXChat
|
||||
import AVFoundation
|
||||
|
||||
// Spec: spec/services/calls.md#ActiveCallView
|
||||
struct ActiveCallView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@@ -282,6 +284,7 @@ struct ActiveCallView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#ActiveCallOverlay
|
||||
struct ActiveCallOverlay: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@ObservedObject var call: Call
|
||||
@@ -350,6 +353,7 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#audioCallInfoView
|
||||
private func audioCallInfoView(_ call: Call) -> some View {
|
||||
VStack {
|
||||
Text(call.contact.chatViewName)
|
||||
@@ -399,6 +403,7 @@ struct ActiveCallOverlay: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#endCallButton
|
||||
private func endCallButton() -> some View {
|
||||
let cc = CallController.shared
|
||||
return callButton("phone.down.fill", .red, padding: 10) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 21/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import Foundation
|
||||
import CallKit
|
||||
@@ -14,6 +15,7 @@ import AVFoundation
|
||||
import SimpleXChat
|
||||
import WebRTC
|
||||
|
||||
// Spec: spec/services/calls.md#CallController
|
||||
class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject {
|
||||
static let shared = CallController()
|
||||
static let isInChina = SKStorefront().countryCode == "CHN"
|
||||
@@ -49,6 +51,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
logger.debug("CallController.providerDidReset")
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXStartCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
|
||||
logger.debug("CallController.provider CXStartCallAction")
|
||||
if callManager.startOutgoingCall(callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
@@ -59,6 +62,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXAnswerCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
|
||||
logger.debug("CallController.provider CXAnswerCallAction")
|
||||
Task {
|
||||
@@ -88,6 +92,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXEndCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
|
||||
logger.debug("CallController.provider CXEndCallAction")
|
||||
// Should be nil here if connection was in connected state
|
||||
@@ -103,6 +108,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CXSetMutedCallAction
|
||||
func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
|
||||
if callManager.enableMedia(source: .mic, enable: !action.isMuted, callUUID: action.callUUID.uuidString.lowercased()) {
|
||||
action.fulfill()
|
||||
@@ -192,6 +198,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
logger.debug("CallController: didUpdate push credentials for type \(type.rawValue)")
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#pushRegistryDidReceive
|
||||
func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType, completion: @escaping () -> Void) {
|
||||
logger.debug("CallController: did receive push with type \(type.rawValue)")
|
||||
if type != .voIP {
|
||||
@@ -276,6 +283,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
reportExpiredCall(update: update, completion)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#reportNewIncomingCall
|
||||
func reportNewIncomingCall(invitation: RcvCallInvitation, completion: @escaping (Error?) -> Void) {
|
||||
logger.debug("CallController.reportNewIncomingCall, UUID=\(String(describing: invitation.callUUID))")
|
||||
if CallController.useCallKit(), let callUUID = invitation.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
@@ -316,6 +324,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#reportOutgoingCall
|
||||
func reportOutgoingCall(call: Call, connectedAt dateConnected: Date?) {
|
||||
logger.debug("CallController: reporting outgoing call connected")
|
||||
if CallController.useCallKit(), let callUUID = call.callUUID, let uuid = UUID(uuidString: callUUID) {
|
||||
@@ -422,6 +431,7 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
|
||||
provider.configuration = conf
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#hasActiveCalls
|
||||
func hasActiveCalls() -> Bool {
|
||||
controller.callObserver.calls.count > 0
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
// Created by Avently on 09.02.2023.
|
||||
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import WebRTC
|
||||
import LZString
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCClient
|
||||
final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDelegate, RTCFrameDecryptorDelegate {
|
||||
private static let factory: RTCPeerConnectionFactory = {
|
||||
RTCInitializeSSL()
|
||||
@@ -87,6 +89,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"),
|
||||
]
|
||||
|
||||
// Spec: spec/services/calls.md#initializeCall
|
||||
func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call {
|
||||
let connection = createPeerConnection(iceServers ?? getWebRTCIceServers() ?? defaultIceServers, relay)
|
||||
connection.delegate = self
|
||||
@@ -132,6 +135,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#createPeerConnection
|
||||
func createPeerConnection(_ iceServers: [WebRTC.RTCIceServer], _ relay: Bool?) -> RTCPeerConnection {
|
||||
let constraints = RTCMediaConstraints(mandatoryConstraints: nil,
|
||||
optionalConstraints: ["DtlsSrtpKeyAgreement": kRTCMediaConstraintsValueTrue])
|
||||
@@ -157,6 +161,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
return config
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#addIceCandidates
|
||||
func addIceCandidates(_ connection: RTCPeerConnection, _ remoteIceCandidates: [RTCIceCandidate]) {
|
||||
remoteIceCandidates.forEach { candidate in
|
||||
connection.add(candidate.toWebRTCCandidate()) { error in
|
||||
@@ -167,6 +172,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#sendCallCommand
|
||||
func sendCallCommand(command: WCallCommand) async {
|
||||
var resp: WCallResponse? = nil
|
||||
let pc = activeCall?.connection
|
||||
@@ -295,6 +301,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#sendIceCandidates
|
||||
func sendIceCandidates(_ candidates: [RTCIceCandidate]) async {
|
||||
await self.sendCallResponse(.init(
|
||||
corrId: nil,
|
||||
@@ -353,6 +360,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#enableMedia
|
||||
@MainActor
|
||||
func enableMedia(_ source: CallMediaSource, _ enable: Bool) {
|
||||
logger.debug("WebRTCClient: enabling media \(source.rawValue) \(enable)")
|
||||
@@ -411,6 +419,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
localRendererAspectRatio.wrappedValue = size.width / size.height
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#setupLocalTracks
|
||||
func setupLocalTracks(_ incomingCall: Bool, _ call: Call) {
|
||||
let pc = call.connection
|
||||
let transceivers = call.connection.transceivers
|
||||
@@ -490,6 +499,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
|
||||
// Should be called after local description set
|
||||
// Spec: spec/services/calls.md#setupEncryptionForLocalTracks
|
||||
func setupEncryptionForLocalTracks(_ call: Call) {
|
||||
if let encryptor = call.frameEncryptor {
|
||||
call.connection.senders.forEach { $0.setRtcFrameEncryptor(encryptor) }
|
||||
@@ -567,6 +577,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#startCaptureLocalVideo
|
||||
func startCaptureLocalVideo(_ device: AVCaptureDevice.Position?, _ capturer: RTCVideoCapturer?) {
|
||||
#if targetEnvironment(simulator)
|
||||
guard
|
||||
@@ -630,6 +641,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg
|
||||
return (localCamera, localVideoTrack)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#endCall
|
||||
func endCall() {
|
||||
if #available(iOS 16.0, *) {
|
||||
_endCall()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -70,6 +72,7 @@ struct ChatView: View {
|
||||
|
||||
let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil)
|
||||
|
||||
// Spec: spec/client/chat-view.md#body
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
viewBody
|
||||
@@ -668,6 +671,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.
|
||||
@@ -727,6 +731,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#scrollToItem
|
||||
private func scrollToItem(_ itemId: ChatItem.ID) {
|
||||
Task {
|
||||
do {
|
||||
@@ -760,6 +765,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 +803,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 +817,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
|
||||
@@ -1083,6 +1091,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 +1269,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)
|
||||
@@ -1397,6 +1407,7 @@ struct ChatView: View {
|
||||
))
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#deletedSelectedMessages
|
||||
private func deletedSelectedMessages() async {
|
||||
await MainActor.run {
|
||||
withAnimation {
|
||||
@@ -1405,6 +1416,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#forwardSelectedMessages
|
||||
private func forwardSelectedMessages() {
|
||||
Task {
|
||||
do {
|
||||
@@ -1515,6 +1527,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 +1568,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 +1596,7 @@ struct ChatView: View {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ChatItemWithMenu
|
||||
private struct ChatItemWithMenu: View {
|
||||
@ObservedObject var im: ItemsModel
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@@ -2693,6 +2708,7 @@ struct ChatView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#FloatingButtonModel
|
||||
class FloatingButtonModel: ObservableObject {
|
||||
@ObservedObject var im: ItemsModel
|
||||
|
||||
@@ -2775,6 +2791,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 +2895,7 @@ private func buildTheme() -> AppTheme {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-view.md#ReactionContextMenu
|
||||
struct ReactionContextMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let groupInfo: GroupInfo
|
||||
@@ -3027,6 +3045,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
|
||||
@@ -356,6 +365,7 @@ struct ComposeView: View {
|
||||
@AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false
|
||||
@State private var updatingCompose = false
|
||||
|
||||
// Spec: spec/client/compose.md#body
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
@@ -679,6 +689,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(
|
||||
@@ -878,6 +889,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 +918,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 +936,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 +955,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 +1102,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#sendMessage
|
||||
private func sendMessage(ttl: Int?) {
|
||||
logger.debug("ChatView sendMessage")
|
||||
Task {
|
||||
@@ -1095,6 +1111,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
|
||||
@@ -1361,6 +1378,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 +1419,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#finishVoiceMessageRecording
|
||||
private func finishVoiceMessageRecording() {
|
||||
audioRecorder?.stop()
|
||||
audioRecorder = nil
|
||||
@@ -1411,6 +1430,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 +1456,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 +1477,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 +1491,7 @@ struct ComposeView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/compose.md#showLinkPreview
|
||||
private func showLinkPreview(_ parsedMsg: [FormattedText]?) {
|
||||
prevLinkUrl = linkUrl
|
||||
(linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg)
|
||||
@@ -1486,6 +1511,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 +1538,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -135,6 +138,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 +164,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 +450,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 +470,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 +519,7 @@ struct ChatListView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#searchString
|
||||
func searchString() -> String {
|
||||
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
}
|
||||
@@ -574,6 +583,7 @@ struct SubsStatusIndicator: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#ChatListSearchBar
|
||||
struct ChatListSearchBar: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@@ -875,6 +885,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 +906,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1163,6 +1165,7 @@ private func showOpenKnownGroupAlert(
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/client/navigation.md#planAndConnect
|
||||
func planAndConnect(
|
||||
_ shortOrFullLink: String,
|
||||
theme: AppTheme,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Created by Avently on 17.01.2023.
|
||||
// Copyright (c) 2023 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/client/navigation.md
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 26/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import UserNotifications
|
||||
import OSLog
|
||||
@@ -22,6 +23,7 @@ let nseSuspendSchedule: SuspendSchedule = (2, 4)
|
||||
|
||||
let fastNSESuspendSchedule: SuspendSchedule = (1, 1)
|
||||
|
||||
// Spec: spec/services/notifications.md#NSENotificationData
|
||||
public enum NSENotificationData {
|
||||
case connectionEvent(_ user: User, _ connEntity: ConnectionEntity)
|
||||
case contactConnected(_ user: any UserLike, _ contact: Contact)
|
||||
@@ -76,6 +78,7 @@ public enum NSENotificationData {
|
||||
// Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid
|
||||
// background crashes and contention for database with the application (both UI and background fetch triggered either on schedule
|
||||
// or when background notification is received.
|
||||
// Spec: spec/services/notifications.md#NSEThreads
|
||||
class NSEThreads {
|
||||
static let shared = NSEThreads()
|
||||
private let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
|
||||
@@ -238,6 +241,7 @@ class NSEThreads {
|
||||
// NotificationEntities for the same connection across multiple NSE instances (NSEThreads) are processed sequentially, so that the earliest NSE instance receives the earliest messages.
|
||||
// The reason for this complexity is to process all required messages within allotted 30 seconds,
|
||||
// accounting for the possibility that multiple notifications may be delivered concurrently.
|
||||
// Spec: spec/services/notifications.md#NotificationEntity
|
||||
struct NotificationEntity {
|
||||
var ntfConn: NtfConn
|
||||
var entityId: ChatId
|
||||
@@ -279,6 +283,7 @@ struct NotificationEntity {
|
||||
// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never
|
||||
// more than one process of notification service extension exists at a time.
|
||||
// Soon after notification service delivers the last notification it is either suspended or terminated.
|
||||
// Spec: spec/services/notifications.md#NotificationService
|
||||
class NotificationService: UNNotificationServiceExtension {
|
||||
var contentHandler: ((UNNotificationContent) -> Void)?
|
||||
// served as notification if no message attempts (msgBestAttemptNtf) could be produced
|
||||
@@ -291,6 +296,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
var appSubscriber: AppSubscriber?
|
||||
var returnedSuspension = false
|
||||
|
||||
// Spec: spec/services/notifications.md#didReceive
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
logger.debug("DEBUGGING: NotificationService.didReceive")
|
||||
let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
|
||||
@@ -594,6 +600,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
serviceBestAttemptNtf = ntf
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#deliverBestAttemptNtf
|
||||
private func deliverBestAttemptNtf(urgent: Bool = false) {
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)")
|
||||
if let handler = contentHandler, urgent || !expectingMoreMessages {
|
||||
@@ -770,6 +777,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
// nseStateGroupDefault must not be used in NSE directly, only via this singleton
|
||||
// Spec: spec/services/notifications.md#NSEChatState
|
||||
class NSEChatState {
|
||||
static let shared = NSEChatState()
|
||||
private var value_ = NSEState.created
|
||||
@@ -824,6 +832,7 @@ var networkConfig: NetCfg = getNetCfg()
|
||||
|
||||
// startChat uses semaphore startLock to ensure that only one didReceive thread can start chat controller
|
||||
// Subsequent calls to didReceive will be waiting on semaphore and won't start chat again, as it will be .active
|
||||
// Spec: spec/services/notifications.md#startChat-NSE
|
||||
func startChat() -> DBMigrationResult? {
|
||||
logger.debug("NotificationService: startChat")
|
||||
// only skip creating if there is chat controller
|
||||
@@ -848,6 +857,7 @@ func startChat() -> DBMigrationResult? {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#doStartChat
|
||||
func doStartChat() -> DBMigrationResult? {
|
||||
logger.debug("NotificationService: doStartChat")
|
||||
haskell_init_nse()
|
||||
@@ -940,6 +950,7 @@ func chatSuspended() {
|
||||
|
||||
// A single loop is used per Notification service extension process to receive and process all messages depending on the NSE state
|
||||
// If the extension is not active yet, or suspended/suspending, or the app is running, the notifications will not be received.
|
||||
// Spec: spec/services/notifications.md#receiveMessages
|
||||
func receiveMessages() async {
|
||||
logger.debug("NotificationService receiveMessages")
|
||||
while true {
|
||||
@@ -988,6 +999,7 @@ private let isInChina = SKStorefront().countryCode == "CHN"
|
||||
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
|
||||
|
||||
@inline(__always)
|
||||
// Spec: spec/services/notifications.md#receivedMsgNtf
|
||||
func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? {
|
||||
logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
|
||||
switch res {
|
||||
|
||||
@@ -110,6 +110,7 @@ public func resetChatCtrl() {
|
||||
migrationResult = nil
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#sendSimpleXCmd
|
||||
@inline(__always)
|
||||
public func sendSimpleXCmd<R: ChatAPIResult>(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult<R> {
|
||||
if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 26/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/api.md
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -22,6 +23,7 @@ public func onOff(_ b: Bool) -> String {
|
||||
b ? "on" : "off"
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#APIResult
|
||||
public enum APIResult<R>: Decodable where R: Decodable, R: ChatAPIResult {
|
||||
case result(R)
|
||||
case error(ChatError)
|
||||
@@ -59,6 +61,7 @@ public enum APIResult<R>: Decodable where R: Decodable, R: ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatAPIResult
|
||||
public protocol ChatAPIResult: Decodable {
|
||||
var responseType: String { get }
|
||||
var details: String { get }
|
||||
@@ -79,6 +82,7 @@ extension ChatAPIResult {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#decodeAPIResult
|
||||
public func decodeAPIResult<R: ChatAPIResult>(_ d: Data) -> APIResult<R> {
|
||||
// print("decodeAPIResult \(String(describing: R.self))")
|
||||
do {
|
||||
@@ -691,6 +695,7 @@ private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
|
||||
encodeJSON(value).cString(using: .utf8)!
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatError
|
||||
public enum ChatError: Decodable, Hashable, Error {
|
||||
case error(errorType: ChatErrorType)
|
||||
case errorAgent(agentError: AgentErrorType)
|
||||
@@ -713,6 +718,7 @@ public enum ChatError: Decodable, Hashable, Error {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/api.md#ChatErrorType
|
||||
public enum ChatErrorType: Decodable, Hashable {
|
||||
case noActiveUser
|
||||
case noConnectionUser(agentConnId: String)
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
// Created by Evgeny on 05/05/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/calls.md
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCCallOffer
|
||||
public struct WebRTCCallOffer: Encodable {
|
||||
public init(callType: CallType, rtcSession: WebRTCSession) {
|
||||
self.callType = callType
|
||||
@@ -19,6 +21,7 @@ public struct WebRTCCallOffer: Encodable {
|
||||
public var rtcSession: WebRTCSession
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCSession
|
||||
public struct WebRTCSession: Codable {
|
||||
public init(rtcSession: String, rtcIceCandidates: String) {
|
||||
self.rtcSession = rtcSession
|
||||
@@ -29,6 +32,7 @@ public struct WebRTCSession: Codable {
|
||||
public var rtcIceCandidates: String
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#WebRTCExtraInfo
|
||||
public struct WebRTCExtraInfo: Codable {
|
||||
public init(rtcIceCandidates: String) {
|
||||
self.rtcIceCandidates = rtcIceCandidates
|
||||
@@ -37,6 +41,7 @@ public struct WebRTCExtraInfo: Codable {
|
||||
public var rtcIceCandidates: String
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#RcvCallInvitation
|
||||
public struct RcvCallInvitation: Decodable {
|
||||
public var user: User
|
||||
public var contact: Contact
|
||||
@@ -65,6 +70,7 @@ public struct RcvCallInvitation: Decodable {
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/calls.md#CallType
|
||||
public struct CallType: Codable {
|
||||
public init(media: CallMediaType, capabilities: CallCapabilities) {
|
||||
self.media = media
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 26/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/state.md | spec/api.md
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@@ -1367,6 +1368,7 @@ public enum GroupFeatureEnabled: String, Codable, Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatInfo
|
||||
public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
case direct(contact: Contact)
|
||||
case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?)
|
||||
@@ -1871,6 +1873,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/state.md#ChatStats
|
||||
public struct ChatStats: Decodable, Hashable {
|
||||
public init(
|
||||
unreadCount: Int = 0,
|
||||
@@ -4234,6 +4237,7 @@ public struct CIFile: Decodable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#CryptoFile
|
||||
public struct CryptoFile: Codable, Hashable {
|
||||
public var filePath: String // the name of the file, not a full path
|
||||
public var cryptoArgs: CryptoFileArgs?
|
||||
@@ -4281,6 +4285,7 @@ public struct CryptoFile: Codable, Hashable {
|
||||
static var decryptedUrls = Dictionary<String, URL>()
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#CryptoFileArgs
|
||||
public struct CryptoFileArgs: Codable, Hashable {
|
||||
public var fileKey: String
|
||||
public var fileNonce: String
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//
|
||||
// Created by Evgeny on 05/09/2023.
|
||||
// Copyright © 2023 SimpleX Chat. All rights reserved.
|
||||
// Spec: spec/services/files.md
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@@ -13,6 +14,7 @@ enum WriteFileResult: Decodable {
|
||||
case error(writeError: String)
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#writeCryptoFile
|
||||
public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs {
|
||||
let ptr: UnsafeMutableRawPointer = malloc(data.count)
|
||||
memcpy(ptr, (data as NSData).bytes, data.count)
|
||||
@@ -25,6 +27,7 @@ public func writeCryptoFile(path: String, data: Data) throws -> CryptoFileArgs {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#readCryptoFile
|
||||
public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> Data {
|
||||
var cPath = path.cString(using: .utf8)!
|
||||
var cKey = cryptoArgs.fileKey.cString(using: .utf8)!
|
||||
@@ -47,6 +50,7 @@ public func readCryptoFile(path: String, cryptoArgs: CryptoFileArgs) throws -> D
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#encryptCryptoFile
|
||||
public func encryptCryptoFile(fromPath: String, toPath: String) throws -> CryptoFileArgs {
|
||||
var cFromPath = fromPath.cString(using: .utf8)!
|
||||
var cToPath = toPath.cString(using: .utf8)!
|
||||
@@ -58,6 +62,7 @@ public func encryptCryptoFile(fromPath: String, toPath: String) throws -> Crypto
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#decryptCryptoFile
|
||||
public func decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) throws {
|
||||
var cFromPath = fromPath.cString(using: .utf8)!
|
||||
var cKey = cryptoArgs.fileKey.cString(using: .utf8)!
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by JRoberts on 15.04.2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/files.md
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
@@ -13,14 +14,19 @@ import UIKit
|
||||
let logger = Logger()
|
||||
|
||||
// image file size for complession
|
||||
// Spec: spec/services/files.md#MAX_IMAGE_SIZE
|
||||
public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB
|
||||
|
||||
// Spec: spec/services/files.md#MAX_IMAGE_SIZE_AUTO_RCV
|
||||
public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
|
||||
|
||||
// Spec: spec/services/files.md#MAX_VOICE_SIZE_AUTO_RCV
|
||||
public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2
|
||||
|
||||
// Spec: spec/services/files.md#MAX_VIDEO_SIZE_AUTO_RCV
|
||||
public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB
|
||||
|
||||
// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP
|
||||
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB
|
||||
|
||||
public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max
|
||||
@@ -37,10 +43,12 @@ private let CHAT_DB_BAK: String = "_chat.db.bak"
|
||||
|
||||
private let AGENT_DB_BAK: String = "_agent.db.bak"
|
||||
|
||||
// Spec: spec/database.md#getDocumentsDirectory
|
||||
public func getDocumentsDirectory() -> URL {
|
||||
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
|
||||
}
|
||||
|
||||
// Spec: spec/database.md#getGroupContainerDirectory
|
||||
public func getGroupContainerDirectory() -> URL {
|
||||
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)!
|
||||
}
|
||||
@@ -51,12 +59,14 @@ func getAppDirectory() -> URL {
|
||||
: getDocumentsDirectory()
|
||||
}
|
||||
|
||||
// Spec: spec/database.md#DB_FILE_PREFIX
|
||||
let DB_FILE_PREFIX = "simplex_v1"
|
||||
|
||||
func getLegacyDatabasePath() -> URL {
|
||||
getDocumentsDirectory().appendingPathComponent("mobile_v1", isDirectory: false)
|
||||
}
|
||||
|
||||
// Spec: spec/database.md#getAppDatabasePath
|
||||
public func getAppDatabasePath() -> URL {
|
||||
dbContainerGroupDefault.get() == .group
|
||||
? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX, isDirectory: false)
|
||||
@@ -72,6 +82,7 @@ func fileModificationDate(_ path: String) -> Date? {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#deleteAppDatabaseAndFiles
|
||||
public func deleteAppDatabaseAndFiles() {
|
||||
let fm = FileManager.default
|
||||
let dbPath = getAppDatabasePath().path
|
||||
@@ -93,6 +104,7 @@ public func deleteAppDatabaseAndFiles() {
|
||||
storeDBPassphraseGroupDefault.set(true)
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#deleteAppFiles
|
||||
public func deleteAppFiles() {
|
||||
let fm = FileManager.default
|
||||
do {
|
||||
@@ -183,6 +195,7 @@ public func removeLegacyDatabaseAndFiles() -> Bool {
|
||||
return r1 && r2
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#getTempFilesDirectory
|
||||
public func getTempFilesDirectory() -> URL {
|
||||
getAppDirectory().appendingPathComponent("temp_files", isDirectory: true)
|
||||
}
|
||||
@@ -191,6 +204,7 @@ public func getMigrationTempFilesDirectory() -> URL {
|
||||
getDocumentsDirectory().appendingPathComponent("migration_temp_files", isDirectory: true)
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#getAppFilesDirectory
|
||||
public func getAppFilesDirectory() -> URL {
|
||||
getAppDirectory().appendingPathComponent("app_files", isDirectory: true)
|
||||
}
|
||||
@@ -199,6 +213,7 @@ public func getAppFilePath(_ fileName: String) -> URL {
|
||||
getAppFilesDirectory().appendingPathComponent(fileName)
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#getWallpaperDirectory
|
||||
public func getWallpaperDirectory() -> URL {
|
||||
getAppDirectory().appendingPathComponent("assets", isDirectory: true).appendingPathComponent("wallpapers", isDirectory: true)
|
||||
}
|
||||
@@ -207,6 +222,7 @@ public func getWallpaperFilePath(_ filename: String) -> URL {
|
||||
getWallpaperDirectory().appendingPathComponent(filename)
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#saveFile
|
||||
public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> CryptoFile? {
|
||||
let filePath = getAppFilePath(fileName)
|
||||
do {
|
||||
@@ -223,6 +239,7 @@ public func saveFile(_ data: Data, _ fileName: String, encrypted: Bool) -> Crypt
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#removeFile
|
||||
public func removeFile(_ url: URL) {
|
||||
do {
|
||||
try FileManager.default.removeItem(atPath: url.path)
|
||||
@@ -239,12 +256,14 @@ public func removeFile(_ fileName: String) {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#cleanupDirectFile
|
||||
public func cleanupDirectFile(_ aChatItem: AChatItem) {
|
||||
if aChatItem.chatInfo.chatType == .direct {
|
||||
cleanupFile(aChatItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/files.md#cleanupFile
|
||||
public func cleanupFile(_ aChatItem: AChatItem) {
|
||||
let cItem = aChatItem.chatItem
|
||||
let mc = cItem.content.msgContent
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// Created by Evgeny on 28/04/2022.
|
||||
// Copyright © 2022 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
// Spec: spec/services/notifications.md
|
||||
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
@@ -22,6 +23,7 @@ public let appNotificationId = "chat.simplex.app.notification"
|
||||
|
||||
let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification")
|
||||
|
||||
// Spec: spec/services/notifications.md#createContactRequestNtf
|
||||
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
return createNotification(
|
||||
@@ -40,6 +42,7 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createContactConnectedNtf
|
||||
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
return createNotification(
|
||||
@@ -59,6 +62,7 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact,
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createMessageReceivedNtf
|
||||
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
let previewMode = ntfPreviewModeGroupDefault.get()
|
||||
var title: String
|
||||
@@ -78,6 +82,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createCallInvitationNtf
|
||||
public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
let text = invitation.callType.media == .video
|
||||
? NSLocalizedString("Incoming video call", comment: "notification")
|
||||
@@ -93,6 +98,7 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCoun
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createConnectionEventNtf
|
||||
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
|
||||
var title: String
|
||||
@@ -124,6 +130,7 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createErrorNtf
|
||||
public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
var title: String
|
||||
switch dbStatus {
|
||||
@@ -149,6 +156,7 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) ->
|
||||
)
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createAppStoppedNtf
|
||||
public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent {
|
||||
return createNotification(
|
||||
categoryIdentifier: ntfCategoryConnectionEvent,
|
||||
@@ -163,6 +171,7 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember
|
||||
: "#\(groupInfo.displayName) \(groupMember.chatViewName):"
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#createNotification
|
||||
public func createNotification(
|
||||
categoryIdentifier: String,
|
||||
title: String,
|
||||
@@ -187,6 +196,7 @@ public func createNotification(
|
||||
return content
|
||||
}
|
||||
|
||||
// Spec: spec/services/notifications.md#hideSecrets
|
||||
func hideSecrets(_ cItem: ChatItem) -> String {
|
||||
if let md = cItem.formattedText {
|
||||
var res = ""
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// Spec: spec/services/theme.md#PresetWallpaper
|
||||
public enum PresetWallpaper: CaseIterable {
|
||||
case cats
|
||||
case flowers
|
||||
@@ -306,6 +307,7 @@ public enum WallpaperScaleType: String, Codable, CaseIterable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#WallpaperType
|
||||
public enum WallpaperType: Equatable {
|
||||
public var image: SwiftUI.Image? {
|
||||
if let uiImage {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// Spec: spec/services/theme.md#DefaultTheme
|
||||
public enum DefaultTheme: String, Codable, Equatable {
|
||||
case LIGHT
|
||||
case DARK
|
||||
@@ -39,6 +40,7 @@ public enum DefaultThemeMode: String, Codable {
|
||||
case dark
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#Colors
|
||||
public class Colors: ObservableObject, NSCopying, Equatable {
|
||||
@Published public var primary: Color
|
||||
@Published public var primaryVariant: Color
|
||||
@@ -84,6 +86,7 @@ public class Colors: ObservableObject, NSCopying, Equatable {
|
||||
public func clone() -> Colors { copy() as! Colors }
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#AppColors
|
||||
public class AppColors: ObservableObject, NSCopying, Equatable {
|
||||
@Published public var title: Color
|
||||
@Published public var primaryVariant2: Color
|
||||
@@ -135,6 +138,7 @@ public class AppColors: ObservableObject, NSCopying, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#AppWallpaper
|
||||
public class AppWallpaper: ObservableObject, NSCopying, Equatable {
|
||||
public static func == (lhs: AppWallpaper, rhs: AppWallpaper) -> Bool {
|
||||
lhs.background == rhs.background &&
|
||||
@@ -222,6 +226,7 @@ public enum ThemeColor {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeColors
|
||||
public struct ThemeColors: Codable, Equatable, Hashable {
|
||||
public var primary: String? = nil
|
||||
public var primaryVariant: String? = nil
|
||||
@@ -293,6 +298,7 @@ public struct ThemeColors: Codable, Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeWallpaper
|
||||
public struct ThemeWallpaper: Codable, Equatable, Hashable {
|
||||
public var preset: String?
|
||||
public var scale: Float?
|
||||
@@ -375,6 +381,7 @@ public struct ThemeWallpaper: Codable, Equatable, Hashable {
|
||||
|
||||
/// If you add new properties, make sure they serialized to YAML correctly, see:
|
||||
/// encodeThemeOverrides()
|
||||
// Spec: spec/services/theme.md#ThemeOverrides
|
||||
public struct ThemeOverrides: Codable, Equatable, Hashable {
|
||||
public var themeId: String = UUID().uuidString
|
||||
public var base: DefaultTheme
|
||||
@@ -559,6 +566,7 @@ extension [ThemeOverrides] {
|
||||
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeModeOverrides
|
||||
public struct ThemeModeOverrides: Codable, Hashable {
|
||||
public var light: ThemeModeOverride? = nil
|
||||
public var dark: ThemeModeOverride? = nil
|
||||
@@ -573,6 +581,7 @@ public struct ThemeModeOverrides: Codable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/services/theme.md#ThemeModeOverride
|
||||
public struct ThemeModeOverride: Codable, Equatable, Hashable {
|
||||
public var mode: DefaultThemeMode// = CurrentColors.base.mode
|
||||
public var colors: ThemeColors = ThemeColors()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# SimpleX Chat iOS -- Product Overview
|
||||
|
||||
> SimpleX Chat iOS product specification. Bidirectional code links: product docs reference source files, source files reference product docs.
|
||||
>
|
||||
> **Related spec:** [spec/README.md](../spec/README.md) | [spec/architecture.md](../spec/architecture.md)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Vision](#vision)
|
||||
2. [Target Users](#target-users)
|
||||
3. [Capability Map](#capability-map)
|
||||
4. [Navigation Map](#navigation-map)
|
||||
5. [Related Specifications](#related-specifications)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
SimpleX Chat is the first messaging platform with no user identifiers of any kind -- not even random numbers. It provides end-to-end encrypted messaging (with optional post-quantum cryptography), audio/video calls, file sharing, and group communication through a fully decentralized architecture where users control their own SMP relay servers. The iOS app is a native SwiftUI application backed by a Haskell core library.
|
||||
|
||||
---
|
||||
|
||||
## Vision
|
||||
|
||||
SimpleX Chat is the first messaging platform that has no user identifiers -- not even random numbers. It uses double-ratchet end-to-end encryption with optional post-quantum cryptography. The system is fully decentralized with user-controlled SMP relay servers.
|
||||
|
||||
The protocol design ensures that no server or network observer can determine who communicates with whom. Each conversation uses separate unidirectional messaging queues on potentially different servers, and there is no shared identifier between the sender and receiver queues.
|
||||
|
||||
---
|
||||
|
||||
## Target Users
|
||||
|
||||
- **Privacy-conscious individuals** wanting secure messaging without phone-number or email-based identity
|
||||
- **Groups and communities** needing encrypted group communication with role-based access control
|
||||
- **Users avoiding identity linkage** who want to communicate without any persistent user identifier
|
||||
- **Organizations** needing self-hosted messaging infrastructure with full control over relay servers
|
||||
|
||||
---
|
||||
|
||||
## Capability Map
|
||||
|
||||
### 1. Messaging
|
||||
|
||||
Core message composition, delivery, and interaction features.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Text with markdown | Rich text formatting with SimpleX markdown syntax | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` |
|
||||
| Images | Compressed inline images (up to 255KB) | `Shared/Views/Chat/ChatItem/CIImageView.swift` |
|
||||
| Video | Video message recording and playback | `Shared/Views/Chat/ChatItem/CIVideoView.swift` |
|
||||
| Voice messages | Audio recording and playback (5min / 510KB limit) | `Shared/Views/Chat/ChatItem/CIVoiceView.swift` |
|
||||
| File sharing | Files up to 1GB via XFTP protocol | `Shared/Views/Chat/ChatItem/CIFileView.swift` |
|
||||
| Link previews | OpenGraph metadata extraction and display | `Shared/Views/Chat/ChatItem/CILinkView.swift` |
|
||||
| Message reactions | Emoji reactions on sent/received messages | `Shared/Views/Chat/ChatItem/EmojiItemView.swift` |
|
||||
| Message editing | Edit previously sent messages | `Shared/Views/Chat/ComposeMessage/ComposeView.swift` |
|
||||
| Message deletion | Broadcast delete (for recipient) or internal-only delete | `Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift` |
|
||||
| Timed messages | Self-destructing messages with configurable TTL | `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` |
|
||||
| Quoted replies | Reply to specific messages with quote context | `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` |
|
||||
| Forwarding | Forward messages between chats | `Shared/Views/Chat/ChatItemForwardingView.swift` |
|
||||
| Search | Full-text search within conversations | `Shared/Views/Chat/ChatView.swift` |
|
||||
| Message reports | Report messages to group moderators | `Shared/Views/Chat/ChatView.swift` |
|
||||
|
||||
### 2. Contacts
|
||||
|
||||
Establishing, managing, and verifying contacts.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Add via SimpleX address | Connect using a SimpleX contact address | `Shared/Views/NewChat/NewChatView.swift` |
|
||||
| Add via QR code | Scan QR code to establish connection | `Shared/Views/Chat/ScanCodeView.swift` |
|
||||
| Contact requests | Accept or reject incoming contact requests | `Shared/Views/ChatList/ContactRequestView.swift` |
|
||||
| Local aliases | Set private display names for contacts | `Shared/Views/Chat/ChatInfoView.swift` |
|
||||
| Contact verification | Compare security codes out-of-band | `Shared/Views/Chat/VerifyCodeView.swift` |
|
||||
| Blocking | Block contacts from sending messages | `Shared/Views/Chat/ChatInfoView.swift` |
|
||||
| Incognito mode | Per-contact random profile generation | `Shared/Views/UserSettings/IncognitoHelp.swift` |
|
||||
| Bot detection | Identify automated/bot contacts | `SimpleXChat/ChatTypes.swift` |
|
||||
|
||||
### 3. Groups
|
||||
|
||||
Multi-party encrypted conversations with role-based management.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Create groups | Create new group with initial members | `Shared/Views/NewChat/AddGroupView.swift` |
|
||||
| Invite members | Invite by individual contact or link | `Shared/Views/Chat/Group/AddGroupMembersView.swift` |
|
||||
| Member roles | Owner, admin, moderator, member, observer | `SimpleXChat/ChatTypes.swift` |
|
||||
| Member admission | Queue-based admission with review workflow | `Shared/Views/Chat/Group/MemberAdmissionView.swift` |
|
||||
| Group links | Shareable invite links for groups | `Shared/Views/Chat/Group/GroupLinkView.swift` |
|
||||
| Business chat mode | Structured business communication groups | `Shared/Views/Chat/Group/GroupChatInfoView.swift` |
|
||||
| Content moderation | Member reports and moderator actions | `Shared/Views/Chat/Group/MemberSupportView.swift` |
|
||||
| Group preferences | Configure group-level feature settings | `Shared/Views/Chat/Group/GroupPreferencesView.swift` |
|
||||
| Member direct contacts | Establish direct chats from group membership | `Shared/Views/Chat/Group/GroupMemberInfoView.swift` |
|
||||
|
||||
### 4. Calling
|
||||
|
||||
End-to-end encrypted audio and video communication.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `Shared/Views/Call/WebRTCClient.swift` |
|
||||
| CallKit integration | Native iOS system call UI (ring, answer, decline) | `Shared/Views/Call/CallController.swift` |
|
||||
| Audio device switching | Switch between speaker, earpiece, Bluetooth | `Shared/Views/Call/CallAudioDeviceManager.swift` |
|
||||
| Call history | Call events displayed as chat items | `Shared/Views/Chat/ChatItem/CICallItemView.swift` |
|
||||
| Incoming call view | Dedicated UI for incoming call notifications | `Shared/Views/Call/IncomingCallView.swift` |
|
||||
|
||||
### 5. Privacy & Security
|
||||
|
||||
Encryption, authentication, and privacy controls.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| E2E encryption | Double-ratchet encryption for all messages | `SimpleXChat/API.swift` |
|
||||
| Post-quantum encryption | Optional PQ key exchange for direct chats | `SimpleXChat/ChatTypes.swift` |
|
||||
| Local authentication | Face ID, Touch ID, or app passcode lock | `Shared/Views/LocalAuth/LocalAuthView.swift` |
|
||||
| Hidden profiles | Password-protected profiles invisible in UI | `Shared/Views/UserSettings/HiddenProfileView.swift` |
|
||||
| Database encryption | AES encryption of local SQLite database | `Shared/Views/Database/DatabaseEncryptionView.swift` |
|
||||
| Screen privacy | Blur app content when in app switcher | `Shared/Views/UserSettings/PrivacySettings.swift` |
|
||||
| Encrypted file storage | Local files encrypted at rest | `SimpleXChat/CryptoFile.swift` |
|
||||
| Delivery receipts control | Toggle delivery/read receipts per contact/group | `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift` |
|
||||
|
||||
### 6. User Management
|
||||
|
||||
Multiple profiles and identity management.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Multiple profiles | Multiple user profiles within one app | `Shared/Views/UserSettings/UserProfilesView.swift` |
|
||||
| Active user switching | Switch between profiles via user picker | `Shared/Views/ChatList/UserPicker.swift` |
|
||||
| Incognito contacts | Per-contact random identities | `Shared/Views/UserSettings/IncognitoHelp.swift` |
|
||||
| Profile sharing | Share profile via contact address link | `Shared/Views/UserSettings/UserAddressView.swift` |
|
||||
| User muting | Mute notifications for specific profiles | `Shared/Views/ChatList/UserPicker.swift` |
|
||||
|
||||
### 7. Network
|
||||
|
||||
Server configuration, proxy support, and connectivity.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Custom SMP servers | Configure personal SMP relay servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` |
|
||||
| Custom XFTP servers | Configure personal XFTP file servers | `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` |
|
||||
| Tor/onion support | Route traffic through Tor .onion addresses | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` |
|
||||
| SOCKS5 proxy | Route connections through SOCKS5 proxy | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` |
|
||||
| Custom ICE servers | Configure WebRTC ICE/TURN servers | `Shared/Views/UserSettings/RTCServers.swift` |
|
||||
| Network timeouts | Configure connection timeout parameters | `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` |
|
||||
|
||||
### 8. Customization
|
||||
|
||||
Visual appearance and UI preferences.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Themes | Light, dark, SimpleX, black, and custom themes | `Shared/Theme/ThemeManager.swift` |
|
||||
| Wallpapers | Preset and custom chat wallpapers | `Shared/Views/Helpers/ChatWallpaper.swift` |
|
||||
| Chat bubble styling | Customize message bubble appearance | `SimpleXChat/Theme/ThemeTypes.swift` |
|
||||
| One-handed UI mode | Compact layout for single-hand use | `Shared/Views/ChatList/OneHandUICard.swift` |
|
||||
| Language selection | In-app language override | `Shared/Views/UserSettings/AppearanceSettings.swift` |
|
||||
|
||||
### 9. Data Management
|
||||
|
||||
Import, export, encryption, and storage management.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Export/import profiles | Full database export and import | `Shared/Views/Database/DatabaseView.swift` |
|
||||
| Database encryption | Encrypt/decrypt local database with passphrase | `Shared/Views/Database/DatabaseEncryptionView.swift` |
|
||||
| Local file encryption | Encrypt stored media and attachments | `SimpleXChat/CryptoFile.swift` |
|
||||
| Storage breakdown | View storage usage by category | `Shared/Views/UserSettings/StorageView.swift` |
|
||||
| Device-to-device migration | Migrate full profile between iOS devices | `Shared/Views/Migration/MigrateFromDevice.swift` |
|
||||
|
||||
### 10. Desktop Integration
|
||||
|
||||
Remote control of the mobile app from a desktop client.
|
||||
|
||||
| Feature | Description | Key Source (Swift) |
|
||||
|---------|-------------|--------------------|
|
||||
| Remote control pairing | Pair with desktop app via QR code | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` |
|
||||
| Session management | Manage active desktop control sessions | `Shared/Views/RemoteAccess/ConnectDesktopView.swift` |
|
||||
|
||||
---
|
||||
|
||||
## Navigation Map
|
||||
|
||||
```
|
||||
Onboarding
|
||||
OnboardingView.swift
|
||||
-> SimpleXInfo -> CreateProfile -> ChooseServerOperators -> SetNotificationsMode -> CreateSimpleXAddress
|
||||
-> ChatListView (home)
|
||||
|
||||
ChatListView (home)
|
||||
Shared/Views/ChatList/ChatListView.swift
|
||||
-> ChatView .................. (tap conversation row)
|
||||
-> NewChatMenuButton ......... (+ button)
|
||||
-> SettingsView .............. (gear icon)
|
||||
-> UserPicker ................ (avatar tap)
|
||||
-> TagListView ............... (tag filter bar)
|
||||
-> ServersSummaryView ........ (server status)
|
||||
|
||||
ChatView
|
||||
Shared/Views/Chat/ChatView.swift
|
||||
-> ChatInfoView .............. (contact name tap, direct chat)
|
||||
-> GroupChatInfoView ......... (group name tap, group chat)
|
||||
-> ActiveCallView ............ (call button)
|
||||
-> ComposeView ............... (message input area)
|
||||
-> ChatItemInfoView .......... (long press -> info)
|
||||
-> ChatItemForwardingView .... (long press -> forward)
|
||||
-> SecondaryChatView ......... (member support thread)
|
||||
|
||||
ChatInfoView
|
||||
Shared/Views/Chat/ChatInfoView.swift
|
||||
-> ContactPreferencesView .... (preferences)
|
||||
-> VerifyCodeView ............ (verify security code)
|
||||
|
||||
GroupChatInfoView
|
||||
Shared/Views/Chat/Group/GroupChatInfoView.swift
|
||||
-> GroupProfileView .......... (edit profile)
|
||||
-> AddGroupMembersView ....... (invite members)
|
||||
-> GroupLinkView ............. (manage group link)
|
||||
-> MemberAdmissionView ....... (admission settings)
|
||||
-> GroupPreferencesView ...... (group feature settings)
|
||||
-> GroupMemberInfoView ....... (tap member)
|
||||
-> GroupWelcomeView .......... (welcome message)
|
||||
|
||||
NewChatMenuButton
|
||||
Shared/Views/NewChat/NewChatMenuButton.swift
|
||||
-> NewChatView ............... (QR scanner / paste link)
|
||||
-> AddGroupView .............. (create group)
|
||||
-> UserAddressView ........... (create SimpleX address)
|
||||
|
||||
SettingsView
|
||||
Shared/Views/UserSettings/SettingsView.swift
|
||||
-> AppearanceSettings ........ (themes, wallpapers, UI)
|
||||
-> NetworkAndServers ......... (SMP/XFTP/proxy config)
|
||||
-> PrivacySettings ........... (privacy toggles)
|
||||
-> NotificationsView ......... (push notification mode)
|
||||
-> DatabaseView .............. (export/import/encrypt)
|
||||
-> CallSettings .............. (call preferences)
|
||||
-> StorageView ............... (storage usage)
|
||||
-> VersionView ............... (about/version)
|
||||
-> DeveloperView ............. (developer options)
|
||||
|
||||
UserPicker
|
||||
Shared/Views/ChatList/UserPicker.swift
|
||||
-> UserProfilesView .......... (manage all profiles)
|
||||
-> UserAddressView ........... (SimpleX address)
|
||||
-> PreferencesView ........... (user preferences)
|
||||
-> SettingsView .............. (app settings)
|
||||
-> ConnectDesktopView ........ (pair with desktop)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links
|
||||
- [glossary.md](glossary.md) -- Domain term glossary
|
||||
- [spec/README.md](../spec/README.md) -- Technical specification overview
|
||||
- [spec/architecture.md](../spec/architecture.md) -- Architecture specification
|
||||
- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs`
|
||||
- Swift model: `Shared/Model/ChatModel.swift`, `SimpleXChat/ChatTypes.swift`
|
||||
- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift`
|
||||
@@ -0,0 +1,83 @@
|
||||
# SimpleX Chat iOS -- Concept Index
|
||||
|
||||
> SimpleX Chat iOS concept index. Maps every product concept to its documentation and source code with bidirectional links.
|
||||
>
|
||||
> **Related spec:** [spec/api.md](../spec/api.md) | [spec/state.md](../spec/state.md) | [spec/architecture.md](../spec/architecture.md)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Feature Concepts](#section-1-feature-concepts)
|
||||
2. [Entity Index](#section-2-entity-index)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document provides a structured mapping between product-level concepts, their documentation, and their implementation in both the Swift iOS layer and the Haskell core library. All source paths are relative to `apps/ios/` for Swift and use `../../src/` prefix for Haskell files (relative to `apps/ios/`).
|
||||
|
||||
---
|
||||
|
||||
## Section 1: Feature Concepts
|
||||
|
||||
| # | Concept | Product Docs | Spec Docs | Source Files (Swift) | Source Files (Haskell) |
|
||||
|---|---------|-------------|-----------|---------------------|----------------------|
|
||||
| 1 | Chat List | [views/chat-list.md](views/chat-list.md), [views/onboarding.md](views/onboarding.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `Shared/Views/ChatList/ChatListView.swift` | `Controller.hs` (`APIGetChats`) |
|
||||
| 2 | Direct Chat | [views/chat.md](views/chat.md), [flows/messaging.md](flows/messaging.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `ChatInfoView.swift` | `Types.hs` (`Contact`), `Messages.hs` |
|
||||
| 3 | Group Chat | [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `Shared/Views/Chat/ChatView.swift`, `Group/GroupChatInfoView.swift` | `Types.hs` (`GroupInfo`, `GroupMember`) |
|
||||
| 4 | Message Composition | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `SendMessageView.swift` | `Controller.hs` (`APISendMessages`) |
|
||||
| 5 | Message Reactions | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/EmojiItemView.swift` | `Controller.hs` (`APIChatItemReaction`) |
|
||||
| 6 | Message Editing | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ComposeMessage/ComposeView.swift`, `ChatItemInfoView.swift` | `Controller.hs` (`APIUpdateChatItem`) |
|
||||
| 7 | Message Deletion | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/MarkedDeletedItemView.swift`, `DeletedItemView.swift` | `Controller.hs` (`APIDeleteChatItem`) |
|
||||
| 8 | Timed Messages | [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md) | `ChatItem/CIChatFeatureView.swift` | `Types/Preferences.hs` (`TimedMessagesPreference`) |
|
||||
| 9 | Voice Messages | [views/chat.md](views/chat.md) | [spec/client/compose.md](../spec/client/compose.md) | `ChatItem/CIVoiceView.swift`, `ComposeVoiceView.swift` | `Protocol.hs` (`MCVoice`) |
|
||||
| 10 | File Transfer | [flows/file-transfer.md](flows/file-transfer.md) | [spec/services/files.md](../spec/services/files.md) | `ChatItem/CIFileView.swift`, `SimpleXChat/FileUtils.swift` | `Files.hs`, `Store/Files.hs` |
|
||||
| 11 | Link Previews | [views/chat.md](views/chat.md) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `ChatItem/CILinkView.swift`, `ComposeLinkView.swift` | `Protocol.hs` (`MCLink`) |
|
||||
| 12 | Contact Connection | [flows/connection.md](flows/connection.md), [views/new-chat.md](views/new-chat.md) | [spec/api.md](../spec/api.md) | `NewChat/NewChatView.swift`, `QRCode.swift` | `Controller.hs` (`APIConnect`, `APIAddContact`) |
|
||||
| 13 | Contact Verification | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `Shared/Views/Chat/VerifyCodeView.swift` | `Controller.hs` (`APIVerifyContact`) |
|
||||
| 14 | Group Management | [flows/group-lifecycle.md](flows/group-lifecycle.md) | [spec/api.md](../spec/api.md), [spec/database.md](../spec/database.md) | `NewChat/AddGroupView.swift`, `Group/GroupChatInfoView.swift` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` |
|
||||
| 15 | Group Links | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/GroupLinkView.swift` | `Controller.hs` (`APICreateGroupLink`) |
|
||||
| 16 | Member Roles | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `SimpleXChat/ChatTypes.swift`, `Group/GroupMemberInfoView.swift` | `Types/Shared.hs` (`GroupMemberRole`) |
|
||||
| 17 | Audio/Video Calls | [views/call.md](views/call.md), [flows/calling.md](flows/calling.md) | [spec/services/calls.md](../spec/services/calls.md) | `Call/ActiveCallView.swift`, `CallController.swift`, `WebRTCClient.swift` | `Call.hs` (`RcvCallInvitation`, `CallType`) |
|
||||
| 18 | Push Notifications | [views/settings.md](views/settings.md) | [spec/services/notifications.md](../spec/services/notifications.md) | `Model/NtfManager.swift`, `SimpleX NSE/NotificationService.swift` | `Controller.hs` |
|
||||
| 19 | User Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/state.md](../spec/state.md), [spec/client/navigation.md](../spec/client/navigation.md) | `UserSettings/UserProfilesView.swift`, `ChatList/UserPicker.swift` | `Types.hs` (`User`), `Store/Profiles.hs` |
|
||||
| 20 | Incognito Mode | [views/contact-info.md](views/contact-info.md) | [spec/api.md](../spec/api.md) | `UserSettings/IncognitoHelp.swift` | `ProfileGenerator.hs`, `Types.hs` |
|
||||
| 21 | Hidden Profiles | [views/user-profiles.md](views/user-profiles.md) | [spec/api.md](../spec/api.md) | `UserSettings/HiddenProfileView.swift` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) |
|
||||
| 22 | Local Authentication | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `LocalAuth/LocalAuthView.swift`, `PasscodeView.swift` | N/A (iOS-only) |
|
||||
| 23 | Database Encryption | [views/settings.md](views/settings.md) | [spec/database.md](../spec/database.md) | `Database/DatabaseEncryptionView.swift`, `DatabaseView.swift` | `Controller.hs` (`APIExportArchive`) |
|
||||
| 24 | Theme System | [views/settings.md](views/settings.md) | [spec/services/theme.md](../spec/services/theme.md) | `Theme/ThemeManager.swift`, `SimpleXChat/Theme/ThemeTypes.swift` | `Types/UITheme.hs` |
|
||||
| 25 | Network Configuration | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `NetworkAndServers/NetworkAndServers.swift`, `ProtocolServersView.swift` | `Controller.hs` (`APISetNetworkConfig`) |
|
||||
| 26 | Device Migration | [flows/onboarding.md](flows/onboarding.md) | [spec/database.md](../spec/database.md) | `Migration/MigrateFromDevice.swift`, `MigrateToDevice.swift` | `Archive.hs` |
|
||||
| 27 | Remote Desktop | [views/settings.md](views/settings.md) | [spec/architecture.md](../spec/architecture.md) | `RemoteAccess/ConnectDesktopView.swift` | `Remote.hs`, `Remote/Types.hs` |
|
||||
| 28 | Chat Tags | [views/chat-list.md](views/chat-list.md) | [spec/state.md](../spec/state.md) | `ChatList/TagListView.swift`, `ChatListView.swift` | `Types.hs` (`ChatTag`), `Controller.hs` |
|
||||
| 29 | User Address | [views/settings.md](views/settings.md) | [spec/api.md](../spec/api.md) | `UserSettings/UserAddressView.swift`, `Onboarding/AddressCreationCard.swift` | `Controller.hs` (`APICreateMyAddress`) |
|
||||
| 30 | Member Support Chat | [views/group-info.md](views/group-info.md) | [spec/api.md](../spec/api.md) | `Group/MemberSupportView.swift`, `MemberAdmissionView.swift` | `Messages.hs` (`GroupChatScope`), `Controller.hs` |
|
||||
|
||||
---
|
||||
|
||||
## Section 2: Entity Index
|
||||
|
||||
Core data entities, their storage, and the operations that manage their lifecycle.
|
||||
|
||||
| Entity | DB Table (Haskell) | Created By | Read By | Mutated By | Deleted By |
|
||||
|--------|-------------------|------------|---------|------------|------------|
|
||||
| **User** | `users` | `CreateActiveUser` in `Controller.hs` | `ListUsers`, `APISetActiveUser` in `Controller.hs` | `APISetActiveUser`, `APIHideUser`, `APIUnhideUser`, `APIMuteUser`, `APIUpdateProfile` in `Controller.hs` | `APIDeleteUser` in `Controller.hs`; `Store/Profiles.hs` |
|
||||
| **Contact** | `contacts`, `contact_profiles` | `APIAddContact`, `APIConnect` in `Controller.hs` | `APIGetChat` in `Controller.hs`; `Store/Direct.hs` (getContact) | `APISetContactAlias`, `APISetConnectionAlias` in `Controller.hs`; `Store/Direct.hs` | `APIDeleteChat` in `Controller.hs`; `Store/Direct.hs` (deleteContact) |
|
||||
| **GroupInfo** | `groups`, `group_profiles` | `APINewGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroup) | `APIGetChat`, `APIGroupInfo` in `Controller.hs`; `Store/Groups.hs` | `APIUpdateGroupProfile` in `Controller.hs`; `Store/Groups.hs` (updateGroupProfile) | `APIDeleteChat` in `Controller.hs`; `Store/Groups.hs` (deleteGroup) |
|
||||
| **GroupMember** | `group_members`, `contact_profiles` | `APIAddMember`, `APIJoinGroup` in `Controller.hs`; `Store/Groups.hs` (createNewGroupMember) | `APIListMembers` in `Controller.hs`; `Store/Groups.hs` (getGroupMembers) | `APIMembersRole` in `Controller.hs`; `Store/Groups.hs` (updateGroupMemberRole) | `APIRemoveMembers` in `Controller.hs`; `Store/Groups.hs` (deleteGroupMember) |
|
||||
| **ChatItem** | `chat_items`, `chat_item_versions` | `APISendMessages` in `Controller.hs`; `Store/Messages.hs` (createNewChatItem) | `APIGetChat`, `APIGetChatItems` in `Controller.hs`; `Store/Messages.hs` (getChatItems) | `APIUpdateChatItem`, `APIChatItemReaction` in `Controller.hs`; `Store/Messages.hs` (updateChatItem) | `APIDeleteChatItem` in `Controller.hs`; `Store/Messages.hs` (deleteChatItem) |
|
||||
| **Connection** | `connections` | `createConnection` via SMP agent; `Store/Connections.hs` | `Store/Connections.hs` (getConnectionEntity) | `Store/Connections.hs` (updateConnectionStatus) | `Store/Connections.hs` (deleteConnection) |
|
||||
| **FileTransfer** | `files`, `snd_files`, `rcv_files`, `xftp_file_descriptions` | `APISendMessages` (with file), `ReceiveFile` in `Controller.hs`; `Store/Files.hs` | `Store/Files.hs` (getFileTransfer) | `Store/Files.hs` (updateFileStatus, updateFileProgress) | `Store/Files.hs` (deleteFileTransfer) |
|
||||
| **GroupLink** | `user_contact_links` | `APICreateGroupLink` in `Controller.hs`; `Store/Groups.hs` | `APIGetGroupLink` in `Controller.hs`; `Store/Groups.hs` | N/A (recreated on change) | `APIDeleteGroupLink` in `Controller.hs`; `Store/Groups.hs` |
|
||||
| **ChatTag** | `chat_tags`, `chat_tags_chats` | `APICreateChatTag` in `Controller.hs` | `APIGetChats` in `Controller.hs` | `APIUpdateChatTag`, `APISetChatTags` in `Controller.hs` | `APIDeleteChatTag` in `Controller.hs` |
|
||||
| **RcvCallInvitation** | In-memory (not persisted) | Received via `XCallInv` message in `Library/Subscriber.hs`; stored in `ChatModel.callInvitations` | `CallController.swift`, `IncomingCallView.swift` | Updated on call accept/reject in `CallManager.swift` | Removed on call end/reject; `Controller.hs` |
|
||||
|
||||
---
|
||||
|
||||
## Cross-References
|
||||
|
||||
- Product overview: [README.md](README.md)
|
||||
- Glossary: [glossary.md](glossary.md)
|
||||
- Haskell core controller: `../../src/Simplex/Chat/Controller.hs`
|
||||
- Haskell core types: `../../src/Simplex/Chat/Types.hs`
|
||||
- Haskell store layer: `../../src/Simplex/Chat/Store/` (Direct.hs, Groups.hs, Messages.hs, Files.hs, Profiles.hs, Connections.hs)
|
||||
- Swift model: `Shared/Model/ChatModel.swift`
|
||||
- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift`
|
||||
- Swift API bridge: `SimpleXChat/API.swift`, `Shared/Model/SimpleXAPI.swift`
|
||||
@@ -0,0 +1,179 @@
|
||||
# Audio/Video Call Flow
|
||||
|
||||
> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md)
|
||||
|
||||
## Overview
|
||||
|
||||
WebRTC-based audio and video calling in SimpleX Chat iOS. Calls are end-to-end encrypted with an additional shared key negotiated over the E2E encrypted SMP channel. The iOS app integrates with CallKit for native call UI (incoming call screen, lock screen integration) with a fallback mode for regions where CallKit is restricted (China). Call signaling (offer/answer/ICE candidates) is exchanged via SMP messages, not through a central signaling server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Established direct contact connection (calls are 1:1 only, not available in groups)
|
||||
- Microphone permission granted (audio calls)
|
||||
- Camera permission granted (video calls)
|
||||
- Network connectivity for WebRTC peer-to-peer or relay
|
||||
|
||||
## Step-by-Step Processes
|
||||
|
||||
### 1. Initiate Call
|
||||
|
||||
1. User opens a direct chat in `ChatView`.
|
||||
2. Taps the audio or video call button in the navigation bar.
|
||||
3. `CallController` determines call type: `CallType(media: .audio/.video, capabilities: CallCapabilities(encryption: true))`.
|
||||
4. If CallKit is enabled (`CallController.useCallKit()`):
|
||||
- `CXStartCallAction` is requested via `CXCallController`.
|
||||
- CallKit reports the outgoing call.
|
||||
- `provider(perform: CXStartCallAction)` fulfills and reports `reportOutgoingCall(startedConnectingAt:)`.
|
||||
5. Calls `apiSendCallInvitation(contact:callType:)`:
|
||||
```swift
|
||||
func apiSendCallInvitation(_ contact: Contact, _ callType: CallType) async throws
|
||||
```
|
||||
6. Sends `ChatCommand.apiSendCallInvitation(contact:callType:)`.
|
||||
7. Core sends the call invitation to the contact via SMP.
|
||||
8. `ChatModel.shared.activeCall` is set with the call state.
|
||||
|
||||
### 2. Receive Call
|
||||
|
||||
1. `ChatReceiver` receives `ChatEvent.callInvitation(callInvitation: RcvCallInvitation)`.
|
||||
2. `RcvCallInvitation` contains: `user`, `contact`, `callType`, `sharedKey`, `callUUID`, `callTs`.
|
||||
3. Processing in `processReceivedMsg`:
|
||||
- Call invitation is stored in `chatModel.callInvitations`.
|
||||
4. If CallKit is enabled:
|
||||
- `CXProvider.reportNewIncomingCall` presents the native iOS incoming call UI.
|
||||
- Works even on lock screen and in background.
|
||||
5. If CallKit is disabled (China / user preference):
|
||||
- `IncomingCallView` is shown as an in-app overlay.
|
||||
- `SoundPlayer` plays the ringtone.
|
||||
6. User chooses to accept or reject.
|
||||
|
||||
### 3. Accept Call
|
||||
|
||||
1. **Via CallKit**: User swipes to accept on the native incoming call screen.
|
||||
- `provider(perform: CXAnswerCallAction)` is triggered.
|
||||
- Waits for chat to be started if needed (`waitUntilChatStarted(timeoutMs: 30_000)`).
|
||||
- `callManager.answerIncomingCall(callUUID:)` begins WebRTC setup.
|
||||
- `fulfillOnConnect` is set -- the action is fulfilled only when WebRTC reaches connected state (required for audio/mic to work on lock screen).
|
||||
2. **Via in-app UI**: User taps "Accept" in `IncomingCallView`.
|
||||
- Directly starts WebRTC setup.
|
||||
|
||||
### 4. Reject Call
|
||||
|
||||
1. **Via CallKit**: User taps "Decline" on native UI.
|
||||
- `provider(perform: CXEndCallAction)` is triggered.
|
||||
- `callManager.endCall(callUUID:)` cleans up.
|
||||
2. **Via API**: `apiRejectCall(contact:)` sends rejection to peer.
|
||||
3. Call invitation is removed from `chatModel.callInvitations`.
|
||||
|
||||
### 5. WebRTC Setup (Signaling)
|
||||
|
||||
All signaling messages are exchanged via E2E encrypted SMP messages (no central signaling server).
|
||||
|
||||
**Caller side:**
|
||||
1. `WebRTCClient` creates a `RTCPeerConnection`.
|
||||
2. Creates SDP offer.
|
||||
3. Calls `apiSendCallOffer(contact:rtcSession:rtcIceCandidates:media:capabilities:)`:
|
||||
```swift
|
||||
func apiSendCallOffer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String,
|
||||
media: CallMediaType, capabilities: CallCapabilities) async throws
|
||||
```
|
||||
4. Constructs `WebRTCCallOffer(callType:rtcSession:)` and sends via `ChatCommand.apiSendCallOffer`.
|
||||
5. Gathers ICE candidates and sends via `apiSendCallExtraInfo(contact:rtcIceCandidates:)`.
|
||||
|
||||
**Callee side:**
|
||||
1. Receives the offer via SMP.
|
||||
2. `WebRTCClient` sets remote description from the offer.
|
||||
3. Creates SDP answer.
|
||||
4. Calls `apiSendCallAnswer(contact:rtcSession:rtcIceCandidates:)`:
|
||||
```swift
|
||||
func apiSendCallAnswer(_ contact: Contact, _ rtcSession: String, _ rtcIceCandidates: String) async throws
|
||||
```
|
||||
5. Constructs `WebRTCSession(rtcSession:rtcIceCandidates:)` and sends.
|
||||
6. Gathers and sends additional ICE candidates via `apiSendCallExtraInfo`.
|
||||
|
||||
### 6. Media Streaming
|
||||
|
||||
1. WebRTC peer connection transitions to connected state.
|
||||
2. If CallKit is used, `fulfillOnConnect` action is fulfilled (enables audio hardware).
|
||||
3. Audio/video streams are active.
|
||||
4. `ActiveCallView` displays:
|
||||
- Remote video (full screen)
|
||||
- Local video preview (picture-in-picture corner)
|
||||
- Call controls: mute, speaker, camera toggle, end call
|
||||
- Call duration timer
|
||||
5. `CallViewRenderers` manages WebRTC video rendering surfaces.
|
||||
6. Call status updates are sent via `apiCallStatus(contact:status:)`.
|
||||
|
||||
### 7. Audio Routing
|
||||
|
||||
1. `CallAudioDeviceManager` handles audio device selection.
|
||||
2. Options: earpiece (receiver), speaker, Bluetooth devices.
|
||||
3. `AudioDevicePicker` provides UI for device selection during call.
|
||||
4. Uses `AVAudioSession` for routing configuration.
|
||||
|
||||
### 8. End Call
|
||||
|
||||
1. Either party taps "End" button.
|
||||
2. Calls `apiEndCall(contact:)`:
|
||||
```swift
|
||||
func apiEndCall(_ contact: Contact) async throws
|
||||
```
|
||||
3. Sends `ChatCommand.apiEndCall(contact:)` via SMP to notify peer.
|
||||
4. `WebRTCClient` closes peer connection, releases media resources.
|
||||
5. If CallKit: `CXEndCallAction` is requested, `provider(perform: CXEndCallAction)` fulfills.
|
||||
6. `ChatModel.shared.activeCall` is cleared.
|
||||
7. A `CICallItemView` event item is added to the chat history (call duration, type).
|
||||
|
||||
### 9. CallKit-Free Mode
|
||||
|
||||
1. `CallController.isInChina` checks `SKStorefront().countryCode == "CHN"`.
|
||||
2. If in China or user disabled CallKit (`callKitEnabledGroupDefault`): `useCallKit()` returns `false`.
|
||||
3. Incoming calls use `IncomingCallView` overlay instead of native CallKit UI.
|
||||
4. `SoundPlayer` handles ringtone playback.
|
||||
5. No lock-screen call answering; app must be in foreground or notified via push.
|
||||
|
||||
## Data Structures
|
||||
|
||||
| Type | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| `CallType` | `SimpleXChat/CallTypes.swift` | `media: CallMediaType` (.audio/.video), `capabilities: CallCapabilities` |
|
||||
| `CallMediaType` | `SimpleXChat/CallTypes.swift` | `.audio` or `.video` |
|
||||
| `CallCapabilities` | `SimpleXChat/CallTypes.swift` | `encryption: Bool` for E2E call encryption support |
|
||||
| `RcvCallInvitation` | `SimpleXChat/CallTypes.swift` | Incoming call: user, contact, callType, sharedKey, callUUID, callTs |
|
||||
| `WebRTCCallOffer` | `SimpleXChat/CallTypes.swift` | SDP offer with call type and WebRTC session data |
|
||||
| `WebRTCSession` | `SimpleXChat/CallTypes.swift` | `rtcSession` (SDP) and `rtcIceCandidates` (serialized) |
|
||||
| `WebRTCExtraInfo` | `SimpleXChat/CallTypes.swift` | Additional ICE candidates sent after initial offer/answer |
|
||||
| `WebRTCCallStatus` | `SimpleXChat/CallTypes.swift` | Call lifecycle states for status reporting |
|
||||
| `CallMediaSource` | `SimpleXChat/CallTypes.swift` | `.mic`, `.camera`, `.screenAudio`, `.screenVideo`, `.unknown` |
|
||||
| `VideoCamera` | `SimpleXChat/CallTypes.swift` | `.user` (front) or `.environment` (rear) camera |
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error | Cause | Handling |
|
||||
|-------|-------|----------|
|
||||
| Chat not ready on CallKit answer | App suspended, slow startup | `waitUntilChatStarted` with 30s timeout; `action.fail()` on timeout |
|
||||
| Call invitation not found | Race condition between notification and event processing | `justRefreshCallInvitations()` retry |
|
||||
| WebRTC peer connection failure | NAT traversal, network issues | Call ends with error status |
|
||||
| CallKit action fail | Internal state mismatch | `action.fail()` called, call cleaned up |
|
||||
| No camera/mic permission | User denied permissions | Permission request dialog shown |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Shared/Views/Call/CallController.swift` | CallKit integration, CXProvider delegate, PKPushRegistry, call lifecycle management |
|
||||
| `Shared/Views/Call/CallManager.swift` | Call state management, starting/answering/ending calls |
|
||||
| `Shared/Views/Call/WebRTCClient.swift` | WebRTC peer connection, SDP offer/answer, ICE candidate handling |
|
||||
| `Shared/Views/Call/ActiveCallView.swift` | Active call UI: video renderers, controls, duration |
|
||||
| `Shared/Views/Call/CallViewRenderers.swift` | WebRTC video rendering surfaces |
|
||||
| `Shared/Views/Call/IncomingCallView.swift` | Non-CallKit incoming call overlay |
|
||||
| `Shared/Views/Call/CallAudioDeviceManager.swift` | Audio routing: speaker, earpiece, Bluetooth |
|
||||
| `Shared/Views/Call/AudioDevicePicker.swift` | Audio device selection UI |
|
||||
| `Shared/Views/Call/SoundPlayer.swift` | Ringtone and call sound playback |
|
||||
| `Shared/Views/Call/WebRTC.swift` | WebRTC configuration and utilities |
|
||||
| `SimpleXChat/CallTypes.swift` | All call-related type definitions |
|
||||
| `Shared/Model/SimpleXAPI.swift` | Call API functions: `apiSendCallInvitation`, `apiSendCallOffer`, `apiSendCallAnswer`, `apiSendCallExtraInfo`, `apiEndCall`, `apiRejectCall`, `apiCallStatus` |
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- `apps/ios/product/README.md` -- Product overview: Calls capability
|
||||
- `apps/ios/product/flows/connection.md` -- Calls require an established direct connection
|
||||
@@ -0,0 +1,159 @@
|
||||
# Connection Flow
|
||||
|
||||
> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/architecture.md](../../spec/architecture.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Establishing contact between two SimpleX Chat users. SimpleX uses no user identifiers; connections are formed through one-time invitation links or permanent SimpleX addresses. Each connection creates unique unidirectional SMP queues, ensuring no server can correlate sender and receiver. Supports incognito mode for per-contact random profile generation.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- User profile created and chat engine running
|
||||
- Network connectivity to SMP relay servers
|
||||
- For QR code scanning: camera permission granted
|
||||
|
||||
## Step-by-Step Processes
|
||||
|
||||
### 1. Create Invitation Link
|
||||
|
||||
1. User taps "+" button in `ChatListView` -> `NewChatMenuButton` -> "Add contact".
|
||||
2. `NewChatView` is presented.
|
||||
3. Calls `apiAddContact(incognito:)`:
|
||||
```swift
|
||||
func apiAddContact(incognito: Bool) async
|
||||
-> ((CreatedConnLink, PendingContactConnection)?, Alert?)
|
||||
```
|
||||
4. Internally sends `ChatCommand.apiAddContact(userId:incognito:)` to core.
|
||||
5. Core creates SMP queues and returns `ChatResponse1.invitation(user, connLinkInv, connection)`.
|
||||
6. Returns `(CreatedConnLink, PendingContactConnection)`.
|
||||
7. `CreatedConnLink` contains the invitation URI (both full and short link forms).
|
||||
8. UI displays:
|
||||
- QR code rendered by `QRCode` view (scannable by peer)
|
||||
- Share button to send link via system share sheet
|
||||
- Copy button for clipboard
|
||||
9. A `PendingContactConnection` appears in the chat list while awaiting peer.
|
||||
|
||||
### 2. Connect via Link
|
||||
|
||||
1. User receives a SimpleX link (pasted, scanned, or opened via URL scheme).
|
||||
2. If opened via deep link: `SimpleXApp.onOpenURL` sets `chatModel.appOpenUrl`.
|
||||
3. For manual entry: User pastes link in `NewChatView`.
|
||||
4. First, `apiConnectPlan(connLink:inProgress:)` is called to validate:
|
||||
```swift
|
||||
func apiConnectPlan(connLink: String, inProgress: BoxedValue<Bool>) async
|
||||
-> ((CreatedConnLink, ConnectionPlan)?, Alert?)
|
||||
```
|
||||
5. Returns `ConnectionPlan` indicating whether it is an invitation, contact address, or group link, and whether connection is already established.
|
||||
6. If valid, calls `apiConnect(incognito:connLink:)`:
|
||||
```swift
|
||||
func apiConnect(incognito: Bool, connLink: CreatedConnLink) async
|
||||
-> (ConnReqType, PendingContactConnection)?
|
||||
```
|
||||
7. Core creates the connection and returns one of:
|
||||
- `ChatResponse1.sentConfirmation(user, connection)` -- for invitation links (type: `.invitation`)
|
||||
- `ChatResponse1.sentInvitation(user, connection)` -- for contact address links (type: `.contact`)
|
||||
- `ChatResponse1.contactAlreadyExists(user, contact)` -- duplicate
|
||||
8. `PendingContactConnection` appears in chat list while awaiting peer confirmation.
|
||||
|
||||
### 3. Prepared Contact/Group Flow (Short Links)
|
||||
|
||||
1. For short links with embedded profile data, the app uses a two-phase flow.
|
||||
2. `apiPrepareContact(connLink:contactShortLinkData:)` or `apiPrepareGroup(connLink:groupShortLinkData:)` creates a local prepared chat.
|
||||
3. Returns `ChatData` with the prepared contact/group shown in UI before connecting.
|
||||
4. User can switch profiles or set incognito before committing.
|
||||
5. `apiConnectPreparedContact(contactId:incognito:msg:)` finalizes the connection.
|
||||
6. Returns `ChatResponse1.startedConnectionToContact(user, contact)`.
|
||||
|
||||
### 4. Accept Contact Request
|
||||
|
||||
1. When a peer connects via the user's SimpleX address, core generates a `ChatEvent.receivedContactRequest`.
|
||||
2. `processReceivedMsg` handles the event, adding a `UserContactRequest` to `ChatModel`.
|
||||
3. Contact request appears in `ChatListView` as a special `ContactRequestView` row.
|
||||
4. User taps "Accept":
|
||||
```swift
|
||||
func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Contact?
|
||||
```
|
||||
5. Sends `ChatCommand.apiAcceptContact(incognito:contactReqId:)`.
|
||||
6. Core returns `ChatResponse1.acceptingContactRequest(user, contact)`.
|
||||
7. Connection handshake proceeds asynchronously.
|
||||
8. User can also reject: `apiRejectContactRequest(contactReqId:)` -> `ChatResponse1.contactRequestRejected`.
|
||||
|
||||
### 5. Connection Established
|
||||
|
||||
1. Both sides complete the SMP handshake asynchronously.
|
||||
2. Core sends `ChatEvent.contactConnected(user, contact, userCustomProfile)`.
|
||||
3. `processReceivedMsg` updates `ChatModel`:
|
||||
- Contact status transitions from pending to active.
|
||||
- Chat becomes available for messaging.
|
||||
4. `NtfManager` may post a notification: "Contact connected".
|
||||
5. The `PendingContactConnection` in the chat list is replaced by the full contact chat.
|
||||
|
||||
### 6. Create SimpleX Address
|
||||
|
||||
1. User navigates to Settings or taps "Create SimpleX address" during onboarding.
|
||||
2. Calls `apiCreateUserAddress()`:
|
||||
```swift
|
||||
func apiCreateUserAddress() async throws -> CreatedConnLink?
|
||||
```
|
||||
3. Core creates a permanent address (unlike one-time invitations).
|
||||
4. Address is stored in `ChatModel.shared.userAddress`.
|
||||
5. Can be shared publicly; multiple contacts can connect via the same address.
|
||||
6. User must accept each incoming contact request individually.
|
||||
7. To delete: `apiDeleteUserAddress()` removes the address and associated SMP queues.
|
||||
|
||||
### 7. Incognito Connection
|
||||
|
||||
1. Before connecting, user toggles "Incognito" in the connection UI.
|
||||
2. `incognito: true` is passed to `apiAddContact`, `apiConnect`, or `apiAcceptContactRequest`.
|
||||
3. Core generates a random display name for this connection only.
|
||||
4. The random profile is stored per-connection; the user's real profile is never shared.
|
||||
5. Incognito status is shown with a mask icon in the chat.
|
||||
6. Can also be toggled for pending connections via `apiSetConnectionIncognito(connId:incognito:)`.
|
||||
|
||||
## Data Structures
|
||||
|
||||
| Type | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| `CreatedConnLink` | `SimpleXChat/APITypes.swift` | Contains `connFullLink` (URI) and optional `connShortLink` |
|
||||
| `PendingContactConnection` | `SimpleXChat/ChatTypes.swift` | Represents an in-progress connection before contact is established |
|
||||
| `ConnectionPlan` | `Shared/Model/AppAPITypes.swift` | Enum describing what a link will do: connect contact, join group, already connected, etc. |
|
||||
| `ConnReqType` | `Shared/Views/NewChat/NewChatView.swift` | `.invitation`, `.contact`, or `.groupLink` -- type of connection request |
|
||||
| `Contact` | `SimpleXChat/ChatTypes.swift` | Full contact model with profile, connection status, preferences |
|
||||
| `UserContactRequest` | `SimpleXChat/ChatTypes.swift` | Incoming contact request awaiting acceptance |
|
||||
| `ChatType` | `SimpleXChat/ChatTypes.swift` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` |
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error | Cause | Handling |
|
||||
|-------|-------|----------|
|
||||
| `ChatError.invalidConnReq` | Malformed or expired link | Alert: "Invalid connection link" |
|
||||
| `ChatError.unsupportedConnReq` | Link requires newer app version | Alert: "Unsupported connection link" |
|
||||
| `ChatError.errorAgent(.SMP(_, .AUTH))` | Link already used or deleted | Alert: "Connection error (AUTH)" |
|
||||
| `ChatError.errorAgent(.SMP(_, .BLOCKED(info)))` | Server operator blocked connection | Alert: "Connection blocked" with reason |
|
||||
| `ChatError.errorAgent(.SMP(_, .QUOTA))` | Too many undelivered messages | Alert: "Undelivered messages" |
|
||||
| `ChatError.errorAgent(.INTERNAL("SEUniqueID"))` | Duplicate connection attempt | Alert: "Already connected?" |
|
||||
| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable via `chatApiSendCmdWithRetry` |
|
||||
| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable via `chatApiSendCmdWithRetry` |
|
||||
| `contactAlreadyExists` | Connecting to existing contact | Alert: "Contact already exists" with contact name |
|
||||
| `errorAgent(.SMP(_, .AUTH))` on accept | Sender deleted request | Alert: "Sender may have deleted the connection request" |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Shared/Views/NewChat/NewChatView.swift` | Main connection UI: create link, paste link, QR scan |
|
||||
| `Shared/Views/NewChat/NewChatMenuButton.swift` | "+" button menu in chat list |
|
||||
| `Shared/Views/NewChat/QRCode.swift` | QR code rendering for invitation links |
|
||||
| `Shared/Views/NewChat/AddContactLearnMore.swift` | Help text explaining connection process |
|
||||
| `Shared/Views/ChatList/ContactRequestView.swift` | Incoming contact request display |
|
||||
| `Shared/Views/ChatList/ContactConnectionView.swift` | Pending connection display |
|
||||
| `Shared/Views/ChatList/ContactConnectionInfo.swift` | Connection details sheet |
|
||||
| `Shared/Model/SimpleXAPI.swift` | API functions: `apiAddContact`, `apiConnect`, `apiConnectPlan`, `apiAcceptContactRequest`, `apiCreateUserAddress` |
|
||||
| `Shared/Model/AppAPITypes.swift` | `ConnectionPlan` enum, `GroupLink` struct |
|
||||
| `SimpleXChat/APITypes.swift` | `CreatedConnLink`, `ComposedMessage`, command/response types |
|
||||
| `SimpleXChat/ChatTypes.swift` | `Contact`, `PendingContactConnection`, `UserContactRequest` |
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- `apps/ios/product/README.md` -- Product overview: Contacts capability map
|
||||
- `apps/ios/product/flows/messaging.md` -- Messaging after connection is established
|
||||
@@ -0,0 +1,209 @@
|
||||
# File Transfer Flow
|
||||
|
||||
> **Related spec:** [spec/services/files.md](../../spec/services/files.md)
|
||||
|
||||
## Overview
|
||||
|
||||
File and media sharing in SimpleX Chat iOS. Small files are sent inline within SMP messages; large files use the XFTP (eXtended File Transfer Protocol) for chunked, encrypted uploads up to 1GB. All files are encrypted end-to-end. Optional local encryption protects downloaded files at rest using AES via `CryptoFile`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Established contact or group conversation
|
||||
- For sending: photo library or file picker access permission
|
||||
- For receiving: sufficient device storage
|
||||
- XFTP relay servers configured (default servers or custom)
|
||||
|
||||
## Size Limits
|
||||
|
||||
| Category | Limit | Constant |
|
||||
|----------|-------|----------|
|
||||
| Inline image (compressed) | 255 KB | `MAX_IMAGE_SIZE` = 261,120 bytes |
|
||||
| Auto-receive image | 510 KB | `MAX_IMAGE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 |
|
||||
| Auto-receive voice | 510 KB | `MAX_VOICE_SIZE_AUTO_RCV` = MAX_IMAGE_SIZE * 2 |
|
||||
| Auto-receive video | 1,023 KB | `MAX_VIDEO_SIZE_AUTO_RCV` = 1,047,552 bytes |
|
||||
| Max file via XFTP | 1 GB | `MAX_FILE_SIZE_XFTP` = 1,073,741,824 bytes |
|
||||
| Max file via SMP | ~8 MB | `MAX_FILE_SIZE_SMP` = 8,000,000 bytes |
|
||||
| Max voice message length | 5 min | `MAX_VOICE_MESSAGE_LENGTH` = 300s |
|
||||
|
||||
## Step-by-Step Processes
|
||||
|
||||
### 1. Send Image
|
||||
|
||||
1. User taps the attachment button in `ComposeView` and selects an image.
|
||||
2. `ComposeImageView` displays the selected image preview.
|
||||
3. Image is compressed to fit within `MAX_IMAGE_SIZE` (255KB).
|
||||
4. `ComposedMessage` is built:
|
||||
```swift
|
||||
ComposedMessage(
|
||||
fileSource: CryptoFile(filePath: compressedImagePath),
|
||||
msgContent: .image(text: captionText, image: base64Thumbnail)
|
||||
)
|
||||
```
|
||||
5. `apiSendMessages(type:id:scope:composedMessages:)` is called.
|
||||
6. For images <=255KB: sent inline within the SMP message.
|
||||
7. For larger images: XFTP upload is used (see XFTP transfer below).
|
||||
8. Recipient auto-receives images up to 510KB (`MAX_IMAGE_SIZE_AUTO_RCV`).
|
||||
|
||||
### 2. Send Video
|
||||
|
||||
1. User picks a video from the library.
|
||||
2. Thumbnail is generated from the first frame.
|
||||
3. Video duration is calculated.
|
||||
4. `ComposedMessage` is built:
|
||||
```swift
|
||||
ComposedMessage(
|
||||
fileSource: CryptoFile(filePath: videoFilePath),
|
||||
msgContent: .video(text: captionText, image: base64Thumbnail, duration: durationSeconds)
|
||||
)
|
||||
```
|
||||
5. `apiSendMessages(...)` is called.
|
||||
6. Video files are typically >255KB, so XFTP upload is used.
|
||||
7. Recipient auto-receives videos up to 1,023KB (`MAX_VIDEO_SIZE_AUTO_RCV`).
|
||||
8. `CIVideoView` displays thumbnail with play button; video downloads on tap if not auto-received.
|
||||
|
||||
### 3. Send File
|
||||
|
||||
1. User taps the attachment button and selects a document via the system file picker.
|
||||
2. `ComposeFileView` shows the file name and size.
|
||||
3. `ComposedMessage` is built:
|
||||
```swift
|
||||
ComposedMessage(
|
||||
fileSource: CryptoFile(filePath: filePath),
|
||||
msgContent: .file(fileName)
|
||||
)
|
||||
```
|
||||
4. `apiSendMessages(...)` is called.
|
||||
5. If file <=255KB: sent inline via SMP.
|
||||
6. If file >255KB and <=1GB: uploaded via XFTP.
|
||||
7. Files >1GB: rejected (prevented in UI).
|
||||
8. `CIFileView` displays file icon, name, and size for the recipient.
|
||||
|
||||
### 4. Send Voice Message
|
||||
|
||||
1. User taps and holds the microphone button in `ComposeView`.
|
||||
2. `AudioRecPlay` records audio to a temporary file.
|
||||
3. `ComposeVoiceView` shows recording waveform and duration.
|
||||
4. On release (or tapping stop), recording ends.
|
||||
5. Duration is checked against `MAX_VOICE_MESSAGE_LENGTH` (5 minutes / 300 seconds).
|
||||
6. `ComposedMessage` is built:
|
||||
```swift
|
||||
ComposedMessage(
|
||||
fileSource: CryptoFile(filePath: voiceFilePath),
|
||||
msgContent: .voice(text: "", duration: durationSeconds)
|
||||
)
|
||||
```
|
||||
7. `apiSendMessages(...)` is called.
|
||||
8. Voice messages <=510KB are sent inline.
|
||||
9. Recipient auto-receives voice up to 510KB (`MAX_VOICE_SIZE_AUTO_RCV`).
|
||||
10. `CIVoiceView` renders waveform with playback controls.
|
||||
|
||||
### 5. Receive File
|
||||
|
||||
1. Core receives a message with a file reference via SMP.
|
||||
2. `ChatEvent.newChatItems` delivers the chat item with file metadata.
|
||||
3. Auto-receive logic checks:
|
||||
- File type and size against auto-receive thresholds.
|
||||
- User's auto-receive preferences.
|
||||
4. If auto-received or user taps "Download":
|
||||
```swift
|
||||
func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async
|
||||
```
|
||||
5. Internally calls `receiveFiles(user:fileIds:userApprovedRelays:auto:)`.
|
||||
6. Sends `ChatCommand.receiveFile(fileId:userApprovedRelays:encrypted:inline:)`.
|
||||
7. `encrypted` is determined by `privacyEncryptLocalFilesGroupDefault`.
|
||||
8. `userApprovedRelays` controls whether unknown XFTP relay servers are trusted.
|
||||
9. On success: `ChatResponse2.rcvFileAccepted(user, chatItem)` -- file download begins.
|
||||
10. On sender cancelled: `ChatResponse2.rcvFileAcceptedSndCancelled(user, rcvFileTransfer)`.
|
||||
11. Download progress is tracked and shown in the UI.
|
||||
12. Completed files are stored in the app's `Documents/files/` directory.
|
||||
|
||||
### 6. XFTP Transfer (Large Files)
|
||||
|
||||
**Upload (sender side):**
|
||||
1. File is encrypted locally with a random symmetric key.
|
||||
2. Encrypted file is split into chunks.
|
||||
3. Chunks are uploaded to one or more XFTP relay servers.
|
||||
4. A file description (URI with encryption key and chunk locations) is created.
|
||||
5. The file description is sent to the recipient via the SMP message.
|
||||
|
||||
**Download (recipient side):**
|
||||
1. Recipient receives the file description via SMP.
|
||||
2. Chunks are downloaded from XFTP relay servers.
|
||||
3. Chunks are reassembled and decrypted locally.
|
||||
4. File is available at the local path.
|
||||
|
||||
**Standalone file operations** (used for database migration):
|
||||
- `uploadStandaloneFile(user:file:ctrl:)` -- upload without a chat message
|
||||
- `downloadStandaloneFile(user:url:file:ctrl:)` -- download from a standalone URL
|
||||
- `standaloneFileInfo(url:ctrl:)` -- get metadata for a standalone file URL
|
||||
|
||||
### 7. Local File Encryption
|
||||
|
||||
1. If `privacyEncryptLocalFilesGroupDefault` is enabled in privacy settings:
|
||||
- Downloaded files are encrypted at rest using AES via `CryptoFile`.
|
||||
- `CryptoFile` wraps a file path with encryption metadata.
|
||||
2. Encryption key is derived and stored securely.
|
||||
3. Files are decrypted on-the-fly when accessed for viewing/playback.
|
||||
4. This protects files even if the device storage is accessed externally.
|
||||
|
||||
### 8. Unknown Relay Server Approval
|
||||
|
||||
1. When receiving a file, XFTP relay servers are checked against known/approved servers.
|
||||
2. If unknown servers are detected: `ChatError.error(.fileNotApproved(fileId, unknownServers))`.
|
||||
3. If not auto-receiving, user is shown an alert:
|
||||
- "Unknown servers! Without Tor or VPN, your IP address will be visible to these XFTP relays: [server list]."
|
||||
- Option to "Download" (approve) or cancel.
|
||||
4. On approval: `receiveFiles(user:fileIds:userApprovedRelays: true)` retries with approval.
|
||||
5. If `privacyAskToApproveRelaysGroupDefault` is disabled, relays are auto-approved.
|
||||
|
||||
## Data Structures
|
||||
|
||||
| Type | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| `CryptoFile` | `SimpleXChat/CryptoFile.swift` | File path with optional encryption key and nonce for local AES encryption |
|
||||
| `MsgContent.image` | `SimpleXChat/ChatTypes.swift` | `.image(text: String, image: String)` -- text caption + base64 thumbnail |
|
||||
| `MsgContent.video` | `SimpleXChat/ChatTypes.swift` | `.video(text: String, image: String, duration: Int)` -- caption + thumbnail + duration |
|
||||
| `MsgContent.voice` | `SimpleXChat/ChatTypes.swift` | `.voice(text: String, duration: Int)` -- empty text + duration in seconds |
|
||||
| `MsgContent.file` | `SimpleXChat/ChatTypes.swift` | `.file(String)` -- file name |
|
||||
| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message with fileSource, quotedItemId, msgContent, mentions |
|
||||
| `FileTransferMeta` | `SimpleXChat/ChatTypes.swift` | Metadata for an ongoing file transfer |
|
||||
| `RcvFileTransfer` | `SimpleXChat/ChatTypes.swift` | State of a file being received |
|
||||
| `MigrationFileLinkData` | Used for standalone file transfers during database migration |
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error | Cause | Handling |
|
||||
|-------|-------|----------|
|
||||
| `fileNotApproved(fileId, unknownServers)` | Unknown XFTP relay servers | Alert with option to approve and retry |
|
||||
| `fileCancelled` | File transfer was cancelled | Silently ignored in `receiveFiles` |
|
||||
| `fileAlreadyReceiving` | Duplicate receive request | Silently ignored |
|
||||
| `rcvFileAcceptedSndCancelled` | Sender cancelled after acceptance | Alert: "Sender cancelled file transfer" |
|
||||
| File too large | Exceeds 1GB XFTP limit | Prevented in UI picker |
|
||||
| Network errors | XFTP server unreachable | Standard retry mechanism |
|
||||
| Storage full | Insufficient device storage | System-level error |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `SimpleXChat/FileUtils.swift` | File size constants, path utilities, database file management |
|
||||
| `SimpleXChat/CryptoFile.swift` | Local file encryption/decryption with AES |
|
||||
| `SimpleXChat/ImageUtils.swift` | Image compression and thumbnail generation |
|
||||
| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | File/media attachment selection and composition |
|
||||
| `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` | Image preview in compose area |
|
||||
| `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` | File preview in compose area |
|
||||
| `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` | Voice recording UI with waveform |
|
||||
| `Shared/Views/Chat/ChatItem/CIFileView.swift` | File message display: icon, name, size, download action |
|
||||
| `Shared/Views/Chat/ChatItem/CIImageView.swift` | Image message display: thumbnail, full-screen tap |
|
||||
| `Shared/Views/Chat/ChatItem/CIVideoView.swift` | Video message display: thumbnail, play button, inline playback |
|
||||
| `Shared/Views/Chat/ChatItem/CIVoiceView.swift` | Voice message display: waveform, playback controls |
|
||||
| `Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift` | Voice message inside a framed (quoted/forwarded) context |
|
||||
| `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` | Full-screen image/video viewer |
|
||||
| `Shared/Model/SimpleXAPI.swift` | `apiSendMessages`, `receiveFile`, `receiveFiles`, `uploadStandaloneFile`, `downloadStandaloneFile` |
|
||||
| `Shared/Model/AudioRecPlay.swift` | Audio recording and playback engine for voice messages |
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- `apps/ios/product/README.md` -- Product overview: Messaging capability (file sharing)
|
||||
- `apps/ios/product/flows/messaging.md` -- File transfer is part of the message send flow
|
||||
- `apps/ios/product/views/chat.md` -- Chat view file/media display
|
||||
@@ -0,0 +1,216 @@
|
||||
# Group Lifecycle Flow
|
||||
|
||||
> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/database.md](../../spec/database.md)
|
||||
|
||||
## Overview
|
||||
|
||||
Complete group management in SimpleX Chat iOS: creating groups, inviting members, joining via links, managing roles and admission, and group deletion. Groups use the same E2E encryption as direct messages -- each member pair has independent encrypted channels. Group metadata (name, image, preferences) is distributed via the group protocol.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- User profile created and chat engine running
|
||||
- At least one established contact (to invite to a group)
|
||||
- For joining via link: a valid group link or invitation
|
||||
|
||||
## Step-by-Step Processes
|
||||
|
||||
### 1. Create Group
|
||||
|
||||
1. User taps "+" in `ChatListView` -> `NewChatMenuButton` -> "Create group".
|
||||
2. `AddGroupView` is presented for entering group name, optional image, and description.
|
||||
3. User fills in `GroupProfile(displayName:fullName:image:description:)` and taps "Create".
|
||||
4. Calls `apiNewGroup(incognito:groupProfile:)`:
|
||||
```swift
|
||||
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo
|
||||
```
|
||||
5. Sends `ChatCommand.apiNewGroup(userId:incognito:groupProfile:)` to core (synchronous).
|
||||
6. Core returns `ChatResponse2.groupCreated(user, groupInfo)`.
|
||||
7. `GroupInfo` contains the new group's ID, profile, and the creator as owner.
|
||||
8. User is navigated to `AddGroupMembersView` to optionally invite contacts.
|
||||
9. User can also create a group link at this stage.
|
||||
|
||||
### 2. Invite Members
|
||||
|
||||
1. From `GroupChatInfoView`, user taps "Add members" -> `AddGroupMembersView`.
|
||||
2. `filterMembersToAdd` filters contacts already in the group.
|
||||
3. User selects contacts and assigns roles (default: `.member`).
|
||||
4. For each selected contact, calls `apiAddMember(groupId:contactId:memberRole:)`:
|
||||
```swift
|
||||
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember
|
||||
```
|
||||
5. Core sends group invitation to the contact and returns `ChatResponse2.sentGroupInvitation(user, _, _, member)`.
|
||||
6. The invited contact receives a `CIGroupInvitationView` in their chat.
|
||||
7. Invited member's status is `.invited` until they accept.
|
||||
|
||||
### 3. Join via Link
|
||||
|
||||
1. User receives a group link (scanned or pasted).
|
||||
2. `apiConnectPlan` validates the link and identifies it as a group link.
|
||||
3. For prepared groups (short links): `apiPrepareGroup(connLink:groupShortLinkData:)` shows group info before joining.
|
||||
4. `apiConnectPreparedGroup(groupId:incognito:msg:)` or `apiConnect(incognito:connLink:)` initiates joining.
|
||||
5. Core processes the join request. Depending on group admission settings:
|
||||
- **Auto-join**: Member is added immediately.
|
||||
- **Approval required**: Member enters pending admission queue.
|
||||
6. `apiJoinGroup(groupId:)` is called for invitation-based joins:
|
||||
```swift
|
||||
func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult?
|
||||
```
|
||||
7. Returns one of:
|
||||
- `.joined(groupInfo:)` -- successfully joined
|
||||
- `.invitationRemoved` -- invitation was revoked (SMP AUTH error)
|
||||
- `.groupNotFound` -- group no longer exists
|
||||
|
||||
### 4. Member Admission
|
||||
|
||||
1. Group has admission settings configured via `MemberAdmissionView`.
|
||||
2. When a new member joins a group requiring approval, admins see pending members.
|
||||
3. Admin reviews pending member in the member list.
|
||||
4. To accept: `apiAcceptMember(groupId:groupMemberId:memberRole:)`:
|
||||
```swift
|
||||
func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember)
|
||||
```
|
||||
5. Core returns `ChatResponse2.memberAccepted(user, groupInfo, member)`.
|
||||
6. To reject: remove the pending member (same as member removal).
|
||||
7. Member support chat (`MemberSupportView`, `MemberSupportChatToolbar`) allows admins to communicate with pending members.
|
||||
|
||||
### 5. Change Member Roles
|
||||
|
||||
1. Admin/owner navigates to member info in `GroupChatInfoView`.
|
||||
2. Selects new role for the member.
|
||||
3. Calls `apiMembersRole(groupId:memberIds:memberRole:)`:
|
||||
```swift
|
||||
func apiMembersRole(_ groupId: Int64, _ memberIds: [Int64], _ memberRole: GroupMemberRole) async throws -> [GroupMember]
|
||||
```
|
||||
4. Core returns `ChatResponse2.membersRoleUser(user, _, members, _)`.
|
||||
5. Available roles (in hierarchy order):
|
||||
- `.owner` -- full control, can delete group
|
||||
- `.admin` -- can manage members, change roles (below admin)
|
||||
- `.moderator` -- can delete messages, moderate content
|
||||
- `.member` -- standard participant, can send messages
|
||||
- `.observer` -- read-only access
|
||||
6. Role changes are broadcast to all group members as group events.
|
||||
|
||||
### 6. Remove Member
|
||||
|
||||
1. Admin/owner navigates to member info -> taps "Remove".
|
||||
2. Calls `apiRemoveMembers(groupId:memberIds:withMessages:)`:
|
||||
```swift
|
||||
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember])
|
||||
```
|
||||
3. `withMessages: true` also deletes all messages from that member.
|
||||
4. Core returns `ChatResponse2.userDeletedMembers(user, updatedGroupInfo, members, withMessages)`.
|
||||
5. Removed member receives notification and loses access.
|
||||
|
||||
### 7. Block Member for All
|
||||
|
||||
1. Admin can block a member's messages from being visible to all group members.
|
||||
2. Calls `apiBlockMembersForAll(groupId:memberIds:blocked:)`:
|
||||
```swift
|
||||
func apiBlockMembersForAll(_ groupId: Int64, _ memberIds: [Int64], _ blocked: Bool) async throws -> [GroupMember]
|
||||
```
|
||||
3. Core returns `ChatResponse2.membersBlockedForAllUser(user, _, members, _)`.
|
||||
|
||||
### 8. Leave Group
|
||||
|
||||
1. User navigates to `GroupChatInfoView` -> taps "Leave group".
|
||||
2. Confirmation dialog is presented.
|
||||
3. Calls `leaveGroup(groupId:)` which wraps `apiLeaveGroup(groupId:)`:
|
||||
```swift
|
||||
func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo
|
||||
```
|
||||
4. Core returns `ChatResponse2.leftMemberUser(user, groupInfo)`.
|
||||
5. `ChatModel.shared.updateGroup(groupInfo)` updates the UI.
|
||||
6. User retains local chat history but can no longer send/receive.
|
||||
|
||||
### 9. Delete Group
|
||||
|
||||
1. Owner navigates to `GroupChatInfoView` -> taps "Delete group".
|
||||
2. Calls `apiDeleteChat(type: .group, id: groupId)`:
|
||||
```swift
|
||||
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws
|
||||
```
|
||||
3. Core notifies all members and removes the group.
|
||||
4. Chat is removed from `ChatModel.shared.chats`.
|
||||
|
||||
### 10. Group Link Management
|
||||
|
||||
**Create group link:**
|
||||
1. From `GroupLinkView` (accessible via `GroupChatInfoView`).
|
||||
2. Calls `apiCreateGroupLink(groupId:memberRole:)`:
|
||||
```swift
|
||||
func apiCreateGroupLink(_ groupId: Int64, memberRole: GroupMemberRole = .member) async throws -> GroupLink?
|
||||
```
|
||||
3. Returns `GroupLink` containing the link URI and member role.
|
||||
4. Optional: `apiAddGroupShortLink(groupId:)` generates an additional short link.
|
||||
|
||||
**Update link role:**
|
||||
- `apiGroupLinkMemberRole(groupId:memberRole:)` changes the default role for new joiners.
|
||||
|
||||
**Delete group link:**
|
||||
- `apiDeleteGroupLink(groupId:)` invalidates the link.
|
||||
|
||||
**Get existing link:**
|
||||
- `apiGetGroupLink(groupId:)` retrieves the current link (returns `nil` if none exists).
|
||||
|
||||
### 11. Group Preferences
|
||||
|
||||
1. `GroupPreferencesView` allows configuring per-feature preferences.
|
||||
2. Features controlled include:
|
||||
- Timed/disappearing messages
|
||||
- Message reactions
|
||||
- Voice messages
|
||||
- File sharing
|
||||
- Direct messages between members
|
||||
- Full message deletion
|
||||
- Message history visibility for new members
|
||||
3. Changes are saved via `apiUpdateGroup(groupId:groupProfile:)` with updated preferences.
|
||||
4. `GroupWelcomeView` manages the welcome message shown to new joiners.
|
||||
|
||||
## Data Structures
|
||||
|
||||
| Type | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| `GroupInfo` | `SimpleXChat/ChatTypes.swift` | Full group model: ID, profile, membership, preferences, business chat info |
|
||||
| `GroupProfile` | `SimpleXChat/ChatTypes.swift` | Name, full name, image, description, preferences |
|
||||
| `GroupMember` | `SimpleXChat/ChatTypes.swift` | Member model: role, status, profile, connection info |
|
||||
| `GroupMemberRole` | `SimpleXChat/ChatTypes.swift` | `.owner`, `.admin`, `.moderator`, `.member`, `.observer` |
|
||||
| `GroupMemberStatus` | `SimpleXChat/ChatTypes.swift` | Member lifecycle: `.invited`, `.accepted`, `.connected`, `.complete`, etc. |
|
||||
| `GroupLink` | `Shared/Model/AppAPITypes.swift` | Group link with URI, member role, and short link data |
|
||||
| `BusinessChatInfo` | `SimpleXChat/ChatTypes.swift` | Business chat metadata for commercial group chats |
|
||||
| `JoinGroupResult` | `Shared/Model/SimpleXAPI.swift` | `.joined(groupInfo)`, `.invitationRemoved`, `.groupNotFound` |
|
||||
| `GMember` | `Shared/Views/Chat/Group/` | View-layer wrapper around `GroupMember` for list display |
|
||||
|
||||
## Error Cases
|
||||
|
||||
| Error | Cause | Handling |
|
||||
|-------|-------|----------|
|
||||
| `errorStore(.groupNotFound)` | Group deleted or not accessible | `JoinGroupResult.groupNotFound` |
|
||||
| `errorAgent(.SMP(_, .AUTH))` | Invitation revoked | `JoinGroupResult.invitationRemoved` |
|
||||
| `errorStore(.groupLinkNotFound)` | No group link exists | `apiGetGroupLink` returns `nil` |
|
||||
| `duplicateGroupLink` | Link already exists for group | Show alert |
|
||||
| `errorAgent(.NOTICE(server, preset, expires))` | Server notice during link creation | `showClientNotice` alert |
|
||||
| Network errors | SMP/XFTP server unreachable | Retryable via `chatApiSendCmdWithRetry` |
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Shared/Views/NewChat/AddGroupView.swift` | Group creation UI |
|
||||
| `Shared/Views/Chat/Group/AddGroupMembersView.swift` | Member invitation UI |
|
||||
| `Shared/Views/Chat/Group/GroupLinkView.swift` | Group link management UI |
|
||||
| `Shared/Views/Chat/Group/GroupProfileView.swift` | Group profile editing |
|
||||
| `Shared/Views/Chat/Group/GroupPreferencesView.swift` | Feature preferences UI |
|
||||
| `Shared/Views/Chat/Group/GroupWelcomeView.swift` | Welcome message editing |
|
||||
| `Shared/Views/Chat/Group/MemberAdmissionView.swift` | Admission settings UI |
|
||||
| `Shared/Views/Chat/Group/MemberSupportView.swift` | Admin-to-pending-member chat |
|
||||
| `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` | Support chat accept/reject toolbar |
|
||||
| `Shared/Views/Chat/Group/SecondaryChatView.swift` | Secondary chat view for member support |
|
||||
| `Shared/Model/SimpleXAPI.swift` | All group API functions |
|
||||
| `Shared/Model/AppAPITypes.swift` | `GroupLink`, `ConnectionPlan` |
|
||||
| `SimpleXChat/ChatTypes.swift` | `GroupInfo`, `GroupProfile`, `GroupMember`, `GroupMemberRole` |
|
||||
|
||||
## Related Specifications
|
||||
|
||||
- `apps/ios/product/README.md` -- Product overview: Groups capability map
|
||||
- `apps/ios/product/flows/connection.md` -- Connection flow (group links use the same connect mechanism)
|
||||
- `apps/ios/product/flows/messaging.md` -- Messaging within groups
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user