diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c882b351e7..c3ef9fa088 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -111,6 +111,7 @@ jobs: arch: x86_64 runner: "ubuntu-22.04" ghc: "8.10.7" + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} - os: 22.04 os_underscore: 22_04 @@ -118,24 +119,28 @@ jobs: runner: "ubuntu-22.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5' - os: 24.04 os_underscore: 24_04 arch: x86_64 runner: "ubuntu-24.04" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237' - os: 22.04 os_underscore: 22_04 arch: aarch64 runner: "ubuntu-22.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:6a62a4157b8775eaf4959cb629e757d32d39d1f4c8ac1b0ddc2510b555cf72f3' - os: 24.04 os_underscore: 24_04 arch: aarch64 runner: "ubuntu-24.04-arm" should_run: true ghc: ${{ needs.variables.outputs.GHC_VER }} + hash: 'sha256:68434214381cb38287104e629fe8ee720167dd98cbb36ab1cbbab342515fa6ab' steps: - name: Checkout Code if: matrix.should_run == true @@ -182,6 +187,7 @@ jobs: tags: build/${{ matrix.os }}:latest build-args: | TAG=${{ matrix.os }} + HASH=${{ matrix.hash }} GHC=${{ matrix.ghc }} USER_UID=${{ steps.ids.outputs.uid }} USER_GID=${{ steps.ids.outputs.gid }} diff --git a/.gitignore b/.gitignore index 929bda7250..2f4af38cca 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ website/src/img/images/ website/src/images/ website/src/js/lottie.min.js website/src/js/ethers* +website/src/file-assets/ website/src/privacy.md # Generated files website/package/generated* diff --git a/Dockerfile.build b/Dockerfile.build index 3ddff59d12..89f8c25101 100644 --- a/Dockerfile.build +++ b/Dockerfile.build @@ -1,6 +1,7 @@ # syntax=docker/dockerfile:1.7.0-labs ARG TAG=24.04 -FROM ubuntu:${TAG} AS build +ARG HASH=sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237 +FROM ubuntu:${TAG}@${HASH} AS build ### Build stage diff --git a/README.md b/README.md index fc31c522ca..818ed7142f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ [](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)     [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat)     [](https://www.whonix.org/wiki/Chat#Recommendation)     [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) +**[Why we are building SimpleX Network](./docs/WHY.md)** + ## Welcome to SimpleX Chat! 1. 📲 [Install the app](#install-the-app). diff --git a/apps/ios/CODE.md b/apps/ios/CODE.md new file mode 100644 index 0000000000..5a8356f656 --- /dev/null +++ b/apps/ios/CODE.md @@ -0,0 +1,223 @@ +# Coding and building + +You are an expert developer for SimpleX Chat, a privacy-first decentralized messaging platform. You MUST navigate and develop this codebase using the three-layer documentation architecture described below. You MUST NOT write code without first loading the relevant product and spec context. + +## Three-Layer Documentation Architecture + +### Why this structure exists + +LLMs start each session with no persistent understanding of the codebase. Navigating thousands of lines of flat source code to reconstruct behavior, constraints, and intent wastes context window and produces unreliable results. + +The `product/`, `spec/`, and source layers form a persistent, structured representation of the system that survives across sessions. Each layer is connected to the next by bidirectional cross-references. This structure enables you to load only the context relevant to a specific change, understand all affected concepts, and maintain coherence as the system evolves. + +### The layers + +| Layer | Contains | Question it answers | +|-------|----------|-------------------| +| `product/` | Capabilities, user flows, views, business rules, glossary | **What** does the system do and why? | +| `spec/` | Technical design, API contracts, database schema, service internals | **How** is it organized technically? | +| `Shared/`, `SimpleXChat/`, `SimpleX NSE/` | Executable Swift code (iOS app) | What does it **execute**? | +| `../../src/Simplex/Chat/` | Haskell core (chat logic, protocol, database) | What does the **core** execute? | + +Each layer links to the next: +- `product/concepts.md` links every concept to its spec docs, source files, and tests in a single table — this is the primary navigation entry point +- `product/views/*.md` and `product/flows/*.md` each have a **Related spec:** line linking to their most relevant spec documents +- `product/glossary.md` uses *See: [spec/...]* references and `product/rules.md` uses **Spec:** [spec/...] references to link individual terms and rules down to spec +- `spec/` documents contain **Source:** headers and inline function links pointing down to source. Line references MUST be clickable by embedding the `#Lxx-Lyy` fragment in the link URL: [`functionName()`](Shared/Model/SimpleXAPI.swift#Lxx-Lyy). You MUST NOT duplicate line numbers in the display text — the URL fragment is sufficient. Why: redundant line numbers in display text create maintenance burden on every line shift. +- Reverse direction: the Document Map (end of this file) maps source → spec → product + +### Navigation workflow + +When the user requests any change, you MUST follow these steps before writing any code: + +1. **Identify scope.** You MUST read `product/concepts.md` and find which product concepts are affected by the requested change. Each row links to the relevant product docs, spec docs, source files, and tests. Why: concepts.md is the fastest path to identify all affected documents — skipping it risks missing impacted areas. + +2. **Load product context.** You MUST read the relevant `product/views/*.md` or `product/flows/*.md` to understand current user-facing behavior. For business constraints, you MUST read `product/rules.md`. Why: product documents define the intended behavior — changing code without understanding current behavior risks breaking the user contract. + +3. **Load spec context.** You MUST follow the product → spec links to read the relevant `spec/*.md` or `spec/services/*.md`. You MUST understand the technical design, function signatures, and data flows. Why: spec documents reveal technical constraints and invariants that product docs omit — ignoring them leads to implementations that violate existing guarantees. + +4. **Load source context.** You MUST follow the spec → source links (with line numbers) to read the relevant source files. Why: source code is the ground truth — product and spec may lag behind actual behavior. + +5. **Identify full impact.** You MUST read `spec/impact.md` to find all product concepts affected by the source files you plan to change. This determines which documents you MUST update after the code change. Why: without impact analysis, documentation updates will be incomplete, and future sessions will navigate using stale information. + +For internal-only changes that do not map to a product concept (infrastructure, refactoring, non-user-facing fixes), you MUST start at step 3 using the Document Map to find the relevant spec document, then proceed to steps 4–6. + +6. **Implement.** Make the code change in source, then you MUST update all affected documentation as described in the Change Protocol below. + +### Key navigation documents + +| Document | Purpose | When to read | +|----------|---------|-------------| +| `product/concepts.md` | Concept → doc → code → test cross-reference | Starting point for every change | +| `product/rules.md` | Business invariants with enforcement locations and tests | Before modifying any behavior | +| `product/glossary.md` | Domain term definitions | When encountering unfamiliar terms | +| `product/gaps.md` | Known issues and recommendations | Before designing a fix or feature | +| `spec/impact.md` | Source file → affected product concepts | After identifying which files to change | +| Document Map (below) | Source ↔ spec ↔ product mapping | When updating documentation | + +--- + +## Code Security + +When designing code and planning implementations, you MUST: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. Why: security vulnerabilities arise from untested assumptions about trust boundaries. +- Formulate an explicit threat model for each change — who can do which undesirable things and under which circumstances. Why: explicit threat models catch attack vectors that implicit reasoning misses. + +--- + +## Code Style + +**Follow existing code patterns — you MUST:** +- Match the style of surrounding code. Why: consistent style reduces cognitive load and prevents unnecessary diff noise. +- Use Swift structs for value types, classes for reference types, and enums with associated values for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive switch statements over default cases. Why: default cases bypass compiler checks for new enum cases and hide bugs. + +**Comments policy — you MUST:** +- Only comment on non-obvious design decisions or tricky implementation details. Why: redundant comments create maintenance burden and drift from code. +- Keep function names and type signatures self-documenting. Why: good names eliminate the need for most comments. +- Assume a competent Swift reader. Why: over-explaining trivial Swift adds noise without value. + +**Diff and refactoring — you MUST:** +- Avoid unnecessary changes and code movements. Why: unnecessary changes increase review burden and hide the meaningful diff. +- Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring itself. Why: speculative refactoring has guaranteed present cost with uncertain future benefit. +- Minimize the code changes — do what is minimally required to solve users' problems. Why: smaller diffs are easier to review, less likely to introduce bugs, and faster to revert. + +**Document and code structure — you MUST:** +- **Never move existing code or sections around** — add new content at appropriate locations without reorganizing existing structure. Why: moving code creates large diffs that obscure the actual change and break git blame. +- When adding new sections to documents, continue the existing numbering scheme. Why: consistent numbering preserves document navigability. +- Minimize diff size — prefer small, targeted changes over reorganization. Why: large diffs compound review errors and make rollback difficult. + +**Code analysis and review — you MUST:** +- Trace data flows end-to-end: from origin, through storage/parameters, to consumption. Flag values that are discarded and reconstructed from partial data (e.g. extracted from a URI missing original fields) — this is usually a bug. Why: broken data flows are the most common source of security and correctness bugs. +- Read implementations of called functions, not just signatures — if duplication involves a called function, check whether decomposing it resolves the duplication. Why: function signatures can be misleading about actual behavior. +- Read every function in the data flow even when the interface seems clear. Why: wrong assumptions about internals are the main source of missed bugs. + +--- + +## Plans + +When developing via plans (non-trivial features, multi-step changes, architectural decisions), you MUST store the plan in the `plans/` folder before implementing. Why: plans are the persistent record of design decisions and rationale — without them, future sessions cannot understand why the system was built the way it was. + +### Plan requirements + +1. **File naming.** You MUST use the format `YYYYMMDD_NN.md` (e.g., `20260211_01.md`). Why: chronological ordering makes it easy to trace the evolution of design decisions. + +2. **Plan structure.** Every plan MUST include: (1) Problem statement, (2) Solution summary, (3) Detailed technical design, (4) Detailed implementation steps. Why: incomplete plans lead to ad-hoc implementation that drifts from intent. + +3. **Consistency with product/ and spec/.** The plan MUST be consistent with the current state of `product/` and `spec/`. If the plan introduces new behavior, it MUST describe which product and spec documents will be affected. Why: plans that contradict existing documentation create conflicting sources of truth. + +4. **Adversarial self-review.** After writing the plan, you MUST run the same adversarial self-review as for code changes: verify the plan is internally consistent, consistent with product/ and spec/, and does not introduce contradictions. You MUST repeat until two consecutive passes find zero issues. Why: an incoherent plan produces incoherent implementation. + +--- + +## Change Protocol + +### The rule + +Every code change MUST include corresponding updates to `spec/` and `product/`. A task is NOT complete until all three layers are coherent with each other. Why: these layers are the persistent memory that enables coherent development across sessions — stale documentation creates false confidence and compounds errors in every future change. + +### What to update + +1. **spec/ — on every code change.** You MUST update the corresponding spec document to reflect the change. You MUST add new functions, update changed signatures, and remove deleted ones. Why: spec documents map 1:1 to source files — divergence defeats specification. + +2. **product/ — when user-visible behavior changes.** You MUST update the relevant `product/views/*.md` and any affected `product/flows/*.md`. You MUST update `product/rules.md` when business invariants change. Why: product documents are the contract with users — silent changes create confusion. + +3. **Line number references — on every code change.** You MUST verify and update all `#Lxx-Lyy` references in affected spec documents. Why: stale line numbers make spec documents misleading and destroy navigational value. + +4. **Cross-references — when adding or removing files.** You MUST add corresponding spec documents and update `spec/README.md` document index and reverse index. When adding pages, you MUST add `product/views/` and `spec/client/` documents. You MUST update the Document Map at the end of this file. Why: every source file must be covered for the navigation system to work. + +5. **Impact graph — when adding files or changing what a file affects.** You MUST update `spec/impact.md` to reflect the source file → product concept mapping. Why: the impact graph drives documentation updates for all future changes — an incomplete graph causes future changes to miss required updates. + +6. **Concept index — when adding or changing product concepts.** You MUST add or update the relevant row in `product/concepts.md` with links to product docs, spec docs, source files, and tests. Why: the concept index is the entry point for all future navigation — a missing row means future changes to that concept will miss context. + +7. **[GAP] annotations — when discovering issues.** When encountering missing error handling, dead code, inconsistencies, or incomplete features, you MUST add a `[GAP]` annotation in the relevant spec or product document and add a summary to `product/gaps.md`. Why: this builds institutional knowledge about technical debt. + +8. **[REC] annotations — when identifying improvements.** You MUST add a `[REC]` annotation in the relevant document. Why: capturing improvement ideas at discovery time preserves context that is lost later. + +9. **Preserve document structure.** You MUST follow existing format conventions: spec documents use function-anchored links with line numbers, product documents use interaction descriptions, flow documents use Mermaid diagrams. Why: consistent structure makes documents predictable and navigable. + +### Adversarial self-review + +After completing all changes (code + documentation), you MUST run an adversarial self-review. You MUST check coherence both within each layer and across layers. + +**Within-layer coherence — you MUST verify:** +- spec/ is internally consistent — no contradictory descriptions, state machines have no unreachable states, data model is referentially intact +- product/ is internally consistent — flows match views, rules match behavior descriptions + +**Across-layer coherence — you MUST verify:** +- Every new or changed function in source appears in the corresponding spec/ document +- Every user-visible behavior change in source appears in the relevant product/ document +- All `#Lxx-Lyy` line references in affected spec documents point to the correct lines +- All cross-references resolve — product → spec links, spec → source links +- `spec/impact.md` covers all affected product concepts for the changed source files +- `product/concepts.md` rows are current for any affected concepts + +**Convergence:** You MUST repeat the review-and-fix cycle until two consecutive passes find zero issues. You MUST fix all issues discovered between passes. Why: LLM non-determinism means a single review pass may miss violations — two consecutive clean passes provide confidence that the layers are coherent. + +--- + +## Document Map + +### iOS Swift Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| Shared/ContentView.swift | spec/client/navigation.md | product/views/chat-list.md | +| Shared/SimpleXApp.swift | spec/architecture.md | product/flows/onboarding.md | +| Shared/AppDelegate.swift | spec/services/notifications.md | product/flows/onboarding.md | +| Shared/Views/ChatList/ChatListView.swift | spec/client/chat-list.md | product/views/chat-list.md | +| Shared/Views/Chat/ChatView.swift | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | spec/client/compose.md | product/views/chat.md | +| Shared/Views/Chat/ChatItem/ | spec/client/chat-view.md | product/views/chat.md | +| Shared/Views/Chat/ChatInfoView.swift | spec/client/chat-view.md | product/views/contact-info.md | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupLinkView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelMembersView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | spec/client/chat-view.md | product/views/group-info.md | +| Shared/Views/NewChat/NewChatView.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/NewChat/QRCode.swift | spec/client/navigation.md | product/views/new-chat.md | +| Shared/Views/Call/ActiveCallView.swift | spec/services/calls.md | product/views/call.md | +| Shared/Views/Call/CallController.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/Call/WebRTCClient.swift | spec/services/calls.md | product/flows/calling.md | +| Shared/Views/UserSettings/SettingsView.swift | spec/client/navigation.md | product/views/settings.md | +| Shared/Views/UserSettings/AppearanceSettings.swift | spec/services/theme.md | product/views/settings.md | +| Shared/Views/UserSettings/NetworkAndServers/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/UserSettings/UserProfilesView.swift | spec/client/navigation.md | product/views/user-profiles.md | +| Shared/Views/Onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| Shared/Views/LocalAuth/ | spec/architecture.md | product/views/settings.md | +| Shared/Views/Database/ | spec/database.md | product/views/settings.md | +| Shared/Views/Migration/ | spec/database.md | product/flows/onboarding.md | +| Shared/Model/ChatModel.swift | spec/state.md | product/concepts.md | +| Shared/Model/SimpleXAPI.swift | spec/api.md, spec/architecture.md | product/concepts.md | +| Shared/Model/AppAPITypes.swift | spec/api.md | product/concepts.md | +| Shared/Model/NtfManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Model/BGManager.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Theme/ThemeManager.swift | spec/services/theme.md | product/views/settings.md | +| SimpleXChat/ChatTypes.swift | spec/state.md, spec/api.md | product/glossary.md | +| SimpleXChat/APITypes.swift | spec/api.md | product/concepts.md | +| SimpleXChat/CallTypes.swift | spec/services/calls.md | product/flows/calling.md | +| SimpleXChat/FileUtils.swift | spec/services/files.md | product/flows/file-transfer.md | +| SimpleXChat/Notifications.swift | spec/services/notifications.md | product/flows/messaging.md | +| SimpleX NSE/NotificationService.swift | spec/services/notifications.md | product/flows/messaging.md | +| Shared/Views/Chat/ChatItemsMerger.swift | spec/client/chat-view.md | product/views/chat.md | +| SimpleX SE/ShareAPI.swift | spec/api.md | product/flows/messaging.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/ios/`) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| ../../src/Simplex/Chat/Controller.hs | spec/api.md | product/concepts.md | +| ../../src/Simplex/Chat/Types.hs | spec/api.md | product/glossary.md | +| ../../src/Simplex/Chat/Core.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Protocol.hs | spec/architecture.md | product/concepts.md | +| ../../src/Simplex/Chat/Messages.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Messages/CIContent.hs | spec/api.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Call.hs | spec/services/calls.md | product/flows/calling.md | +| ../../src/Simplex/Chat/Files.hs | spec/services/files.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Messages.hs | spec/database.md | product/flows/messaging.md | +| ../../src/Simplex/Chat/Store/Groups.hs | spec/database.md | product/flows/group-lifecycle.md | +| ../../src/Simplex/Chat/Store/Direct.hs | spec/database.md | product/flows/connection.md | +| ../../src/Simplex/Chat/Store/Files.hs | spec/database.md | product/flows/file-transfer.md | +| ../../src/Simplex/Chat/Store/Profiles.hs | spec/database.md | product/views/user-profiles.md | diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 3f6998c9ec..0a401f9bf3 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -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 diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index 7adf7a0435..ba49c767da 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -4,6 +4,7 @@ // // Created by Evgeny Poberezkin on 17/01/2022. // +// Spec: spec/client/navigation.md import SwiftUI import Intents @@ -19,15 +20,18 @@ private enum NoticesSheet: Identifiable { } } +// Spec: spec/client/navigation.md#ContentView struct ContentView: View { @EnvironmentObject var chatModel: ChatModel @ObservedObject var alertManager = AlertManager.shared @ObservedObject var callController = CallController.shared + // Spec: spec/client/navigation.md#AppSheetState @ObservedObject var appSheetState = AppSheetState.shared @Environment(\.colorScheme) var colorScheme @EnvironmentObject var theme: AppTheme @EnvironmentObject var sceneDelegate: SceneDelegate + // Spec: spec/client/navigation.md#contentAccessAuthenticationExtended var contentAccessAuthenticationExtended: Bool @Environment(\.scenePhase) var scenePhase @@ -161,6 +165,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#contentView @ViewBuilder private func contentView() -> some View { if let status = chatModel.chatDbStatus, status != .ok { DatabaseErrorView(status: status) @@ -176,6 +181,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callView @ViewBuilder private func callView(_ call: Call) -> some View { if CallController.useCallKit() { ActiveCallView(call: call, canConnectCall: Binding.constant(true)) @@ -193,6 +199,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#callBanner private func activeCallInteractiveArea(_ call: Call) -> some View { HStack { Text(call.contact.displayName).font(.body).foregroundColor(.white) @@ -227,6 +234,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#lockButton private func lockButton() -> some View { Button(action: authenticateContentViewAccess) { Label("Unlock", systemImage: "lock") } } @@ -339,6 +347,7 @@ struct ContentView: View { } } + // Spec: spec/client/navigation.md#unlockedRecently private func unlockedRecently() -> Bool { if let lastSuccessfulUnlock = lastSuccessfulUnlock { return ProcessInfo.processInfo.systemUptime - lastSuccessfulUnlock < 2 @@ -426,6 +435,7 @@ struct ContentView: View { ) } + // Spec: spec/client/navigation.md#connectViaUrl func connectViaUrl() { let m = ChatModel.shared if let url = m.appOpenUrl { @@ -441,7 +451,12 @@ struct ContentView: View { func connectViaUrl_(_ url: URL) { dismissAllSheets() { var path = url.path - if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { + if path == "/r" { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + } else if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") { path.removeFirst() let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") planAndConnect( diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index e213f1c076..1131069d88 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -5,11 +5,13 @@ // Created by EP on 01/05/2025. // Copyright © 2025 SimpleX Chat. All rights reserved. // +// Spec: spec/api.md import SimpleXChat import SwiftUI // some constructors are used in SEChatCommand or NSEChatCommand types as well - they must be syncronised +// Spec: spec/api.md#ChatCommand enum ChatCommand: ChatCmdProtocol { case showActiveUser case createActiveUser(profile: Profile?, pastTimestamp: Bool) @@ -43,7 +45,7 @@ enum ChatCommand: ChatCmdProtocol { case apiGetChat(chatId: ChatId, scope: GroupChatScope?, contentTag: MsgContentTag?, pagination: ChatPagination, search: String) case apiGetChatContentTypes(chatId: ChatId, scope: GroupChatScope?) case apiGetChatItemInfo(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) case apiCreateChatTag(tag: ChatTagData) case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) case apiDeleteChatTag(tagId: Int64) @@ -59,7 +61,7 @@ enum ChatCommand: ChatCmdProtocol { case apiChatItemReaction(type: ChatType, id: Int64, scope: GroupChatScope?, itemId: Int64, add: Bool, reaction: MsgReaction) case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction) case apiPlanForwardChatItems(fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64]) - case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) + case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -68,6 +70,8 @@ enum ChatCommand: ChatCmdProtocol { case apiGetNtfConns(nonce: String, encNtfInfo: String) case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile) + case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) + case apiGetGroupRelays(groupId: Int64) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiJoinGroup(groupId: Int64) case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole) @@ -87,6 +91,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSendMemberContactInvitation(contactId: Int64, msg: MsgContent) case apiAcceptMemberContact(contactId: Int64) case apiTestProtoServer(userId: Int64, server: String) + case apiTestChatRelay(userId: Int64, address: String) case apiGetServerOperators case apiSetServerOperators(operators: [ServerOperator]) case apiGetUserServers(userId: Int64) @@ -105,6 +110,7 @@ enum ChatCommand: ChatCmdProtocol { case reconnectServer(userId: Int64, smpServer: String) case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) + case apiGetUpdatedGroupLinkData(groupId: Int64) case apiContactInfo(contactId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiContactQueueInfo(contactId: Int64) @@ -124,7 +130,7 @@ enum ChatCommand: ChatCmdProtocol { case apiChangeConnectionUser(connId: Int64, userId: Int64) case apiConnectPlan(userId: Int64, connLink: String) case apiPrepareContact(userId: Int64, connLink: CreatedConnLink, contactShortLinkData: ContactShortLinkData) - case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) + case apiPrepareGroup(userId: Int64, connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) case apiChangePreparedContactUser(contactId: Int64, newUserId: Int64) case apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) case apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) @@ -228,10 +234,11 @@ enum ChatCommand: ChatCmdProtocol { return "/_get chat \(chatId)\(scopeRef(scope))\(tag) \(pagination.cmdString)" + (search == "" ? "" : " search=\(search)") case let .apiGetChatContentTypes(chatId, scope): return "/_get content types \(chatId)\(scopeRef(scope))" case let .apiGetChatItemInfo(type, id, scope, itemId): return "/_get item info \(ref(type, id, scope: scope)) \(itemId)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))" case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id, scope: nil)) \(tagIds.map({ "\($0)" }).joined(separator: ","))" case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)" @@ -250,9 +257,10 @@ enum ChatCommand: ChatCmdProtocol { case let .apiChatItemReaction(type, id, scope, itemId, add, reaction): return "/_reaction \(ref(type, id, scope: scope)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))" case let .apiPlanForwardChatItems(type, id, scope, itemIds): return "/_forward plan \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ","))" - case let .apiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl): + case let .apiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl): let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_forward \(ref(toChatType, toChatId, scope: toScope)) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" + let asGroup = sendAsGroup ? " as_group=on" : "" + return "/_forward \(ref(toChatType, toChatId, scope: toScope))\(asGroup) \(ref(fromChatType, fromChatId, scope: fromScope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) ttl=\(ttlStr)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -261,6 +269,8 @@ enum ChatCommand: ChatCmdProtocol { case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)" case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))" case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))" + case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))" + case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)" @@ -280,6 +290,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiSendMemberContactInvitation(contactId, mc): return "/_invite member contact @\(contactId) \(mc.cmdString)" case let .apiAcceptMemberContact(contactId): return "/_accept member contact @\(contactId)" case let .apiTestProtoServer(userId, server): return "/_server test \(userId) \(server)" + case let .apiTestChatRelay(userId, address): return "/_relay test \(userId) \(address)" case .apiGetServerOperators: return "/_operators" case let .apiSetServerOperators(operators): return "/_operators \(encodeJSON(operators))" case let .apiGetUserServers(userId): return "/_servers \(userId)" @@ -298,6 +309,7 @@ enum ChatCommand: ChatCmdProtocol { case let .reconnectServer(userId, smpServer): return "/reconnect \(userId) \(smpServer)" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id, scope: nil)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" + case let .apiGetUpdatedGroupLinkData(groupId): return "/_get group link data #\(groupId)" case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiContactQueueInfo(contactId): return "/_queue info @\(contactId)" @@ -327,7 +339,7 @@ enum ChatCommand: ChatCmdProtocol { case let .apiChangeConnectionUser(connId, userId): return "/_set conn user :\(connId) \(userId)" case let .apiConnectPlan(userId, connLink): return "/_connect plan \(userId) \(connLink)" case let .apiPrepareContact(userId, connLink, contactShortLinkData): return "/_prepare contact \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(contactShortLinkData))" - case let .apiPrepareGroup(userId, connLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") \(encodeJSON(groupShortLinkData))" + case let .apiPrepareGroup(userId, connLink, directLink, groupShortLinkData): return "/_prepare group \(userId) \(connLink.connFullLink) \(connLink.connShortLink ?? "") direct=\(onOff(directLink)) \(encodeJSON(groupShortLinkData))" case let .apiChangePreparedContactUser(contactId, newUserId): return "/_set contact user @\(contactId) \(newUserId)" case let .apiChangePreparedGroupUser(groupId, newUserId): return "/_set group user #\(groupId) \(newUserId)" case let .apiConnectPreparedContact(contactId, incognito, mc): return "/_connect contact @\(contactId) incognito=\(onOff(incognito))\(maybeContent(mc))" @@ -447,6 +459,8 @@ enum ChatCommand: ChatCmdProtocol { case .apiGetNtfConns: return "apiGetNtfConns" case .apiGetConnNtfMessages: return "apiGetConnNtfMessages" case .apiNewGroup: return "apiNewGroup" + case .apiNewPublicGroup: return "apiNewPublicGroup" + case .apiGetGroupRelays: return "apiGetGroupRelays" case .apiAddMember: return "apiAddMember" case .apiJoinGroup: return "apiJoinGroup" case .apiAcceptMember: return "apiAcceptMember" @@ -466,6 +480,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSendMemberContactInvitation: return "apiSendMemberContactInvitation" case .apiAcceptMemberContact: return "apiAcceptMemberContact" case .apiTestProtoServer: return "apiTestProtoServer" + case .apiTestChatRelay: return "apiTestChatRelay" case .apiGetServerOperators: return "apiGetServerOperators" case .apiSetServerOperators: return "apiSetServerOperators" case .apiGetUserServers: return "apiGetUserServers" @@ -484,6 +499,7 @@ enum ChatCommand: ChatCmdProtocol { case .reconnectServer: return "reconnectServer" case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetMemberSettings: return "apiSetMemberSettings" + case .apiGetUpdatedGroupLinkData: return "apiGetUpdatedGroupLinkData" case .apiContactInfo: return "apiContactInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiContactQueueInfo: return "apiContactQueueInfo" @@ -643,6 +659,7 @@ enum ChatCommand: ChatCmdProtocol { } // ChatResponse is split to three enums to reduce stack size used when parsing it, parsing large enums is very inefficient. +// Spec: spec/api.md#ChatResponse0 enum ChatResponse0: Decodable, ChatAPIResult { case activeUser(user: User) case usersList(users: [UserInfo]) @@ -655,13 +672,15 @@ enum ChatResponse0: Decodable, ChatAPIResult { case chatTags(user: UserRef, userTags: [ChatTag]) case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo) case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?) + case chatRelayTestResult(user: UserRef, relayProfile: RelayProfile?, relayTestFailure: RelayTestFailure?) case serverOperatorConditions(conditions: ServerOperatorConditions) case userServers(user: UserRef, userServers: [UserOperatorServers]) - case userServersValidation(user: UserRef, serverErrors: [UserServersError]) + case userServersValidation(user: UserRef, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]) case usageConditions(usageConditions: UsageConditions, conditionsText: String, acceptedConditions: UsageConditions?) case chatItemTTL(user: UserRef, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) case contactInfo(user: UserRef, contact: Contact, connectionStats_: ConnectionStats?, customUserProfile: Profile?) + case groupInfo(user: UserRef, groupInfo: GroupInfo) case groupMemberInfo(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) case queueInfo(user: UserRef, rcvMsgInfo: RcvMsgInfo?, queueInfo: ServerQueueInfo) case contactSwitchStarted(user: UserRef, contact: Contact, connectionStats: ConnectionStats) @@ -688,6 +707,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatTags: "chatTags" case .chatItemInfo: "chatItemInfo" case .serverTestResult: "serverTestResult" + case .chatRelayTestResult: "chatRelayTestResult" case .serverOperatorConditions: "serverOperators" case .userServers: "userServers" case .userServersValidation: "userServersValidation" @@ -695,6 +715,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { case .chatItemTTL: "chatItemTTL" case .networkConfig: "networkConfig" case .contactInfo: "contactInfo" + case .groupInfo: "groupInfo" case .groupMemberInfo: "groupMemberInfo" case .queueInfo: "queueInfo" case .contactSwitchStarted: "contactSwitchStarted" @@ -723,13 +744,15 @@ enum ChatResponse0: Decodable, ChatAPIResult { case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))") case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))") case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))") + case let .chatRelayTestResult(u, relayProfile, relayTestFailure): return withUser(u, "relayProfile: \(String(describing: relayProfile))\nresult: \(String(describing: relayTestFailure))") case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))" case let .userServers(u, userServers): return withUser(u, "userServers: \(String(describing: userServers))") - case let .userServersValidation(u, serverErrors): return withUser(u, "serverErrors: \(String(describing: serverErrors))") + case let .userServersValidation(u, serverErrors, serverWarnings): return withUser(u, "serverErrors: \(String(describing: serverErrors))\nserverWarnings: \(String(describing: serverWarnings))") case let .usageConditions(usageConditions, _, acceptedConditions): return "usageConditions: \(String(describing: usageConditions))\nacceptedConditions: \(String(describing: acceptedConditions))" case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) case let .contactInfo(u, contact, connectionStats_, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats_: \(String(describing: connectionStats_))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupInfo(u, groupInfo): return withUser(u, "groupInfo: \(String(describing: groupInfo))") case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_))") case let .queueInfo(u, rcvMsgInfo, queueInfo): let msgInfo = if let info = rcvMsgInfo { encodeJSON(info) } else { "none" } @@ -764,6 +787,7 @@ enum ChatResponse0: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatResponse1 enum ChatResponse1: Decodable, ChatAPIResult { case invitation(user: UserRef, connLinkInvitation: CreatedConnLink, connection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) @@ -775,7 +799,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case sentConfirmation(user: UserRef, connection: PendingContactConnection) case sentInvitation(user: UserRef, connection: PendingContactConnection) case startedConnectionToContact(user: UserRef, contact: Contact) - case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo) + case startedConnectionToGroup(user: UserRef, groupInfo: GroupInfo, relayResults: [RelayConnectionResult]) case sentInvitationToContact(user: UserRef, contact: Contact, customUserProfile: Profile?) case contactAlreadyExists(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) @@ -896,16 +920,19 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .sentConfirmation(u, connection): return withUser(u, String(describing: connection)) case let .sentInvitation(u, connection): return withUser(u, String(describing: connection)) case let .startedConnectionToContact(u, contact): return withUser(u, String(describing: contact)) - case let .startedConnectionToGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .startedConnectionToGroup(u, groupInfo, relayResults): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nrelayResults: \(String(describing: relayResults))") case let .sentInvitationToContact(u, contact, _): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) } } } +// Spec: spec/api.md#ChatResponse2 enum ChatResponse2: Decodable, ChatAPIResult { // group responses case groupCreated(user: UserRef, groupInfo: GroupInfo) + case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay]) + case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay]) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool) @@ -956,6 +983,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { var responseType: String { switch self { case .groupCreated: "groupCreated" + case .publicGroupCreated: "publicGroupCreated" + case .groupRelays: "groupRelays" case .sentGroupInvitation: "sentGroupInvitation" case .userAcceptedGroupSent: "userAcceptedGroupSent" case .userDeletedMembers: "userDeletedMembers" @@ -1002,6 +1031,8 @@ enum ChatResponse2: Decodable, ChatAPIResult { var details: String { switch self { case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)") + case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)") case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)") @@ -1046,6 +1077,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { } } +// Spec: spec/api.md#ChatEvent enum ChatEvent: Decodable, ChatAPIResult { case chatSuspended case contactSwitch(user: UserRef, contact: Contact, switchProgress: SwitchProgress) @@ -1080,10 +1112,12 @@ enum ChatEvent: Decodable, ChatAPIResult { case deletedMember(user: UserRef, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember, withMessages: Bool) case leftMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case groupDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) - case userJoinedGroup(user: UserRef, groupInfo: GroupInfo) + case userJoinedGroup(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember) case joinedGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case connectedToGroupMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember, memberContact: Contact?) case groupUpdated(user: UserRef, toGroup: GroupInfo) + case groupLinkDataUpdated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay], relaysChanged: Bool) + case groupRelayUpdated(user: UserRef, groupInfo: GroupInfo, member: GroupMember, groupRelay: GroupRelay) case newMemberContactReceivedInv(user: UserRef, contact: Contact, groupInfo: GroupInfo, member: GroupMember) // receiving file events case rcvFileAccepted(user: UserRef, chatItem: AChatItem) @@ -1160,6 +1194,8 @@ enum ChatEvent: Decodable, ChatAPIResult { case .joinedGroupMember: "joinedGroupMember" case .connectedToGroupMember: "connectedToGroupMember" case .groupUpdated: "groupUpdated" + case .groupLinkDataUpdated: "groupLinkDataUpdated" + case .groupRelayUpdated: "groupRelayUpdated" case .newMemberContactReceivedInv: "newMemberContactReceivedInv" case .rcvFileAccepted: "rcvFileAccepted" case .rcvFileAcceptedSndCancelled: "rcvFileAcceptedSndCancelled" @@ -1236,10 +1272,12 @@ enum ChatEvent: Decodable, ChatAPIResult { case let .deletedMember(u, groupInfo, byMember, deletedMember, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)\nwithMessages: \(withMessages)") case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") - case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo, _): return withUser(u, String(describing: groupInfo)) case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .connectedToGroupMember(u, groupInfo, member, memberContact): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nmemberContact: \(String(describing: memberContact))") case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkDataUpdated(u, groupInfo, groupLink, groupRelays, relaysChanged): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)\nrelaysChanged: \(relaysChanged)") + case let .groupRelayUpdated(u, groupInfo, member, groupRelay): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\ngroupRelay: \(groupRelay)") case let .newMemberContactReceivedInv(u, contact, groupInfo, member): return withUser(u, "contact: \(contact)\ngroupInfo: \(groupInfo)\nmember: \(member)") case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails @@ -1278,6 +1316,7 @@ enum ChatEvent: Decodable, ChatAPIResult { struct NewUser: Encodable { var profile: Profile? var pastTimestamp: Bool + var userChatRelay: Bool = false } enum ChatPagination { @@ -1325,8 +1364,14 @@ enum ContactAddressPlan: Decodable, Hashable { case contactViaAddress(contact: Contact) } +public struct GroupShortLinkInfo: Decodable, Hashable { + public var direct: Bool + public var groupRelays: [String] + public var publicGroupId: String? +} + enum GroupLinkPlan: Decodable, Hashable { - case ok(groupSLinkData_: GroupShortLinkData?) + case ok(groupSLinkInfo_: GroupShortLinkInfo?, groupSLinkData_: GroupShortLinkData?) case ownLink(groupInfo: GroupInfo) case connectingConfirmReconnect case connectingProhibit(groupInfo_: GroupInfo?) @@ -1706,6 +1751,7 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { var `operator`: ServerOperator? var smpServers: [UserServer] var xftpServers: [UserServer] + var chatRelays: [UserChatRelay] var id: String { if let op = self.operator { @@ -1735,21 +1781,28 @@ struct UserOperatorServers: Identifiable, Equatable, Codable { static var sampleData1 = UserOperatorServers( operator: ServerOperator.sampleData1, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) static var sampleDataNilOperator = UserOperatorServers( operator: nil, smpServers: [UserServer.sampleData.preset], - xftpServers: [UserServer.sampleData.xftpPreset] + xftpServers: [UserServer.sampleData.xftpPreset], + chatRelays: [] ) } +public enum UserServersWarning: Decodable { + case noChatRelays(user: UserRef?) +} + enum UserServersError: Decodable { case noServers(protocol: ServerProtocol, user: UserRef?) case storageMissing(protocol: ServerProtocol, user: UserRef?) case proxyMissing(protocol: ServerProtocol, user: UserRef?) case duplicateServer(protocol: ServerProtocol, duplicateServer: String, duplicateHost: String) + case duplicateChatRelayAddress(duplicateChatRelay: String, duplicateAddress: String) var globalError: String? { switch self { @@ -1907,6 +1960,11 @@ struct UserServer: Identifiable, Equatable, Codable, Hashable { } } +struct RelayConnectionResult: Decodable { + var relayMember: GroupMember + var relayError: ChatError? +} + enum ProtocolTestStep: String, Decodable, Equatable { case connect case disconnect @@ -1958,6 +2016,41 @@ struct ProtocolTestFailure: Decodable, Error, Equatable { } } +public enum RelayTestStep: String, Decodable { + case getLink + case decodeLink + case connect + case waitResponse + case verify + + var text: String { + switch self { + case .getLink: return NSLocalizedString("Get link", comment: "relay test step") + case .decodeLink: return NSLocalizedString("Decode link", comment: "relay test step") + case .connect: return NSLocalizedString("Connect", comment: "relay test step") + case .waitResponse: return NSLocalizedString("Wait response", comment: "relay test step") + case .verify: return NSLocalizedString("Verify", comment: "relay test step") + } + } +} + +public struct RelayTestFailure: Decodable, Error { + public var rtfStep: RelayTestStep + public var rtfError: ChatError + + var localizedDescription: String { + let err = String.localizedStringWithFormat(NSLocalizedString("Test failed at step %@.", comment: "relay test failure"), rtfStep.text) + switch rtfError { + case .errorAgent(agentError: .SMP(_, .AUTH)): + return err + " " + NSLocalizedString("Server requires authorization to connect to relay, check password.", comment: "relay test error") + case .errorAgent(agentError: .BROKER(_, .NETWORK(.unknownCAError))): + return err + " " + NSLocalizedString("Fingerprint in server address does not match certificate.", comment: "relay test error") + default: + return err + " " + String.localizedStringWithFormat(NSLocalizedString("Error: %@.", comment: "relay test error"), String(describing: rtfError)) + } + } +} + struct MigrationFileLinkData: Codable { let networkConfig: NetworkConfig? diff --git a/apps/ios/Shared/Model/BGManager.swift b/apps/ios/Shared/Model/BGManager.swift index 25eab6c69e..aa4dfa24f8 100644 --- a/apps/ios/Shared/Model/BGManager.swift +++ b/apps/ios/Shared/Model/BGManager.swift @@ -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") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index f1f4e686bd..9c23ac6307 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 22/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/state.md import Foundation import Combine @@ -53,6 +54,7 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { } // analogue for SecondaryContextFilter in Kotlin +// Spec: spec/state.md#SecondaryItemsModelFilter enum SecondaryItemsModelFilter { case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) case msgContentTagContext(contentTag: MsgContentTag) @@ -68,6 +70,7 @@ enum SecondaryItemsModelFilter { } // analogue for ChatsContext in Kotlin +// Spec: spec/state.md#ItemsModel class ItemsModel: ObservableObject { static let shared = ItemsModel(secondaryIMFilter: nil) public var secondaryIMFilter: SecondaryItemsModelFilter? @@ -103,12 +106,14 @@ class ItemsModel: ObservableObject { .store(in: &bag) } + // Spec: spec/state.md#loadSecondaryChat static func loadSecondaryChat(_ chatId: ChatId, chatFilter: SecondaryItemsModelFilter, willNavigate: @escaping () -> Void = {}) { let im = ItemsModel(secondaryIMFilter: chatFilter) ChatModel.shared.secondaryIM = im im.loadOpenChat(chatId, willNavigate: willNavigate) } + // Spec: spec/state.md#loadOpenChat func loadOpenChat(_ chatId: ChatId, willNavigate: @escaping () -> Void = {}) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -134,6 +139,7 @@ class ItemsModel: ObservableObject { } } + // Spec: spec/state.md#loadOpenChatNoWait func loadOpenChatNoWait(_ chatId: ChatId, _ openAroundItemId: ChatItem.ID? = nil) { navigationTimeoutTask?.cancel() loadChatTask?.cancel() @@ -179,6 +185,7 @@ class PreloadState { } } +// Spec: spec/state.md#ChatTagsModel class ChatTagsModel: ObservableObject { static let shared = ChatTagsModel() @@ -326,6 +333,30 @@ class ConnectProgressManager: ObservableObject { } } +class ChannelRelaysModel: ObservableObject { + static let shared = ChannelRelaysModel() + @Published var groupId: Int64? = nil + @Published var groupRelays: [GroupRelay] = [] + + func set(groupId: Int64, groupRelays: [GroupRelay]) { + self.groupId = groupId + self.groupRelays = groupRelays + } + + func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) { + if groupId == groupInfo.groupId, + let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) { + groupRelays[i] = relay + } + } + + func reset() { + groupId = nil + groupRelays = [] + } +} + +// Spec: spec/state.md#ChatModel final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -355,9 +386,13 @@ final class ChatModel: ObservableObject { @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? + @Published var creatingChannelId: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @Published var membersLoaded = false + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + @Published var channelRelayHostnames: [Int64: [String]] = [:] // items in the terminal view @Published var showingTerminal = false @Published var terminalItems: [TerminalItem] = [] @@ -383,6 +418,7 @@ final class ChatModel: ObservableObject { @Published var showCallView = false @Published var activeCallViewIsCollapsed = false // remote desktop + // Spec: spec/architecture.md#remoteCtrlSession @Published var remoteCtrlSession: RemoteCtrlSession? // currently showing invitation @Published var showingInvitation: ShowingInvitation? @@ -423,6 +459,7 @@ final class ChatModel: ObservableObject { userAddress?.shortLinkDataSet ?? true } + // Spec: spec/state.md#getUser func getUser(_ userId: Int64) -> User? { currentUser?.userId == userId ? currentUser @@ -433,6 +470,7 @@ final class ChatModel: ObservableObject { users.firstIndex { $0.user.userId == user.userId } } + // Spec: spec/state.md#updateUser func updateUser(_ user: User) { if let i = getUserIndex(user) { users[i].user = user @@ -442,6 +480,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#removeUser func removeUser(_ user: User) { if let i = getUserIndex(user) { users.remove(at: i) @@ -452,6 +491,7 @@ final class ChatModel: ObservableObject { chats.first(where: { $0.id == id }) != nil } + // Spec: spec/state.md#getChat func getChat(_ id: String) -> Chat? { chats.first(where: { $0.id == id }) } @@ -506,6 +546,7 @@ final class ChatModel: ObservableObject { chats.firstIndex(where: { $0.id == id }) } + // Spec: spec/state.md#addChat func addChat(_ chat: Chat) { if chatId == nil { withAnimation { addChat_(chat, at: 0) } @@ -519,6 +560,7 @@ final class ChatModel: ObservableObject { chats.insert(chat, at: position) } + // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { @@ -570,6 +612,7 @@ final class ChatModel: ObservableObject { } } + // Spec: spec/state.md#replaceChat func replaceChat(_ id: String, _ chat: Chat) { if let i = getChatIndex(id) { chats[i] = chat @@ -1054,6 +1097,7 @@ final class ChatModel: ObservableObject { NtfManager.shared.changeNtfBadgeCount(by: by) } + // Spec: spec/state.md#totalUnreadCountForAllUsers func totalUnreadCountForAllUsers() -> Int { var unread: Int = 0 for chat in chats { @@ -1153,6 +1197,7 @@ final class ChatModel: ObservableObject { return (prevMember, memberIds.count) } + // Spec: spec/state.md#popChat func popChat(_ id: String) { if let i = getChatIndex(id) { // no animation here, for it not to look like it just moved when leaving the chat @@ -1176,14 +1221,26 @@ final class ChatModel: ObservableObject { showingInvitation?.connChatUsed = true } + // Spec: spec/state.md#removeChat func removeChat(_ id: String) { + var groupId: Int64? withAnimation { if let i = getChatIndex(id) { let removed = chats.remove(at: i) + groupId = removed.chatInfo.groupInfo?.groupId ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats) removeWallpaperFilesFromChat(removed) } } + if chatId == id { + groupMembers = [] + groupMembersIndexes.removeAll() + // Remove channelRelayHostnames for this channel only, preserving other prepared channels + if let gId = groupId { + channelRelayHostnames.removeValue(forKey: gId) + } + membersLoaded = false + } } func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { @@ -1192,13 +1249,19 @@ final class ChatModel: ObservableObject { updateGroup(groupInfo) return false } - // update current chat - if chatId == groupInfo.id { + // update current chat or channel being created + if chatId == groupInfo.id || creatingChannelId == groupInfo.id { if let i = groupMembersIndexes[member.groupMemberId] { + let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus withAnimation(.default) { self.groupMembers[i].wrapped = member self.groupMembers[i].created = Date.now } + // Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array, + // so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly. + if connStatusChanged { + objectWillChange.send() + } return false } else { withAnimation { @@ -1248,6 +1311,7 @@ struct NTFContactRequest { var chatId: String } +// Spec: spec/state.md#Chat final class Chat: ObservableObject, Identifiable, ChatLike { @Published var chatInfo: ChatInfo @Published var chatItems: [ChatItem] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 79f4ef2f09..c6c6e88d8c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -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)) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 46ee753438..e527df1abd 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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(bgDelay: Double? = nil, f: @escaping () -> T) -> T { return r } +// Spec: spec/api.md#chatSendCmdSync @inline(__always) func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) throws -> R { let res: APIResult = chatApiSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdSync func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) -> APIResult { if log { logger.debug("chatSendCmd \(cmd.cmdType)") @@ -112,12 +116,14 @@ func chatApiSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = tru return resp } +// Spec: spec/api.md#chatSendCmd @inline(__always) func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, log: Bool = true) async throws -> R { let res: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, ctrl: ctrl, log: log) return try apiResult(res) } +// Spec: spec/api.md#chatApiSendCmdWithRetry func chatApiSendCmdWithRetry(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, inProgress: BoxedValue? = nil, retryNum: Int32 = 0) async -> APIResult? { let r: APIResult = await chatApiSendCmd(cmd, bgTask: bgTask, bgDelay: bgDelay, retryNum: retryNum) if inProgress == nil || inProgress?.boxedValue == true, @@ -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(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil, ctrl: chat_ctrl? = nil, retryNum: Int32 = 0, log: Bool = true) async -> APIResult { await withCheckedContinuation { cont in @@ -226,6 +233,7 @@ func apiResult(_ res: APIResult) throws -> R { } } +// Spec: spec/api.md#chatRecvMsg func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> APIResult? { await withCheckedContinuation { cont in _ = withBGTask(bgDelay: msgDelay) { () -> APIResult? in @@ -346,6 +354,7 @@ func apiStopChat() async throws { } } +// Spec: spec/architecture.md#apiActivateChat func apiActivateChat() { chatReopenStore() do { @@ -355,6 +364,7 @@ func apiActivateChat() { } } +// Spec: spec/architecture.md#apiSuspendChat func apiSuspendChat(timeoutMicroseconds: Int) { do { try sendCommandOkRespSync(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds)) @@ -363,12 +373,14 @@ func apiSuspendChat(timeoutMicroseconds: Int) { } } +// Spec: spec/services/files.md#apiSetAppFilePaths func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String, ctrl: chat_ctrl? = nil) throws { let r: ChatResponse2 = try chatSendCmdSync(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder), ctrl: ctrl) if case .cmdOk = r { return } throw r.unexpected } +// Spec: spec/services/files.md#apiSetEncryptLocalFiles func apiSetEncryptLocalFiles(_ enable: Bool) throws { try sendCommandOkRespSync(.apiSetEncryptLocalFiles(enable: enable)) } @@ -491,8 +503,8 @@ func apiPlanForwardChatItems(type: ChatType, id: Int64, scope: GroupChatScope?, throw r.unexpected } -func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { - let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) +func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, toScope: GroupChatScope?, sendAsGroup: Bool = false, fromChatType: ChatType, fromChatId: Int64, fromScope: GroupChatScope?, itemIds: [Int64], ttl: Int?) async -> [ChatItem]? { + let cmd: ChatCommand = .apiForwardChatItems(toChatType: toChatType, toChatId: toChatId, toScope: toScope, sendAsGroup: sendAsGroup, fromChatType: fromChatType, fromChatId: fromChatId, fromScope: fromScope, itemIds: itemIds, ttl: ttl) return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) } @@ -524,8 +536,8 @@ func apiReorderChatTags(tagIds: [Int64]) async throws { try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds)) } -func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { - let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, live: live, ttl: ttl, composedMessages: composedMessages) +func apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool = false, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? { + let cmd: ChatCommand = .apiSendMessages(type: type, id: id, scope: scope, sendAsGroup: sendAsGroup, live: live, ttl: ttl, composedMessages: composedMessages) return await processSendMessageCmd(toChatType: type, cmd: cmd) } @@ -746,6 +758,15 @@ func testProtoServer(server: String) async throws -> Result<(), ProtocolTestFail throw r.unexpected } +func testChatRelay(address: String) async throws -> (RelayProfile?, RelayTestFailure?) { + let userId = try currentUserId("testChatRelay") + let r: ChatResponse0 = try await chatSendCmd(.apiTestChatRelay(userId: userId, address: address)) + if case let .chatRelayTestResult(_, relayProfile, relayTestFailure) = r { + return (relayProfile, relayTestFailure) + } + throw r.unexpected +} + func getServerOperators() async throws -> ServerOperatorConditions { let r: ChatResponse0 = try await chatSendCmd(.apiGetServerOperators) if case let .serverOperatorConditions(conditions) = r { return conditions } @@ -783,10 +804,10 @@ func setUserServers(userServers: [UserOperatorServers]) async throws { throw r.unexpected } -func validateServers(userServers: [UserOperatorServers]) async throws -> [UserServersError] { +func validateServers(userServers: [UserOperatorServers]) async throws -> ([UserServersError], [UserServersWarning]) { let userId = try currentUserId("validateServers") let r: ChatResponse0 = try await chatSendCmd(.apiValidateServers(userId: userId, userServers: userServers)) - if case let .userServersValidation(_, serverErrors) = r { return serverErrors } + if case let .userServersValidation(_, serverErrors, serverWarnings) = r { return (serverErrors, serverWarnings) } logger.error("validateServers error: \(String(describing: r))") throw r.unexpected } @@ -878,6 +899,12 @@ func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSett try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings)) } +func apiGetUpdatedGroupLinkData(_ groupId: Int64) async -> GroupInfo? { + let r: APIResult = await chatApiSendCmd(.apiGetUpdatedGroupLinkData(groupId: groupId)) + if case let .result(.groupInfo(_, groupInfo)) = r { return groupInfo } + return nil +} + func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r: ChatResponse0 = try await chatSendCmd(.apiContactInfo(contactId: contactId)) if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } @@ -1109,9 +1136,9 @@ func apiPrepareContact(connLink: CreatedConnLink, contactShortLinkData: ContactS throw r.unexpected } -func apiPrepareGroup(connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { +func apiPrepareGroup(connLink: CreatedConnLink, directLink: Bool, groupShortLinkData: GroupShortLinkData) async throws -> ChatData { let userId = try currentUserId("apiPrepareGroup") - let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, groupShortLinkData: groupShortLinkData)) + let r: ChatResponse1 = try await chatSendCmd(.apiPrepareGroup(userId: userId, connLink: connLink, directLink: directLink, groupShortLinkData: groupShortLinkData)) if case let .newPreparedChat(_, chat) = r { return chat } throw r.unexpected } @@ -1135,9 +1162,9 @@ func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgConten return nil } -func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> GroupInfo? { +func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? { let r: APIResult? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg)) - if case let .result(.startedConnectionToGroup(_, groupInfo)) = r { return groupInfo } + if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) } if let r { AlertManager.shared.showAlert(apiConnectResponseAlert(r)) } return nil } @@ -1455,6 +1482,7 @@ func standaloneFileInfo(url: String, ctrl: chat_ctrl? = nil) async -> MigrationF } } +// Spec: spec/services/files.md#receiveFile func receiveFile(user: any UserLike, fileId: Int64, userApprovedRelays: Bool = false, auto: Bool = false) async { await receiveFiles( user: user, @@ -1573,6 +1601,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool } } +// Spec: spec/services/files.md#cancelFile func cancelFile(user: User, fileId: Int64) async { if let chatItem = await apiCancelFile(fileId: fileId) { await chatItemSimpleUpdate(user, chatItem) @@ -1595,12 +1624,14 @@ func setLocalDeviceName(_ displayName: String) throws { try sendCommandOkRespSync(.setLocalDeviceName(displayName: displayName)) } +// Spec: spec/architecture.md#connectRemoteCtrl func connectRemoteCtrl(desktopAddress: String) async throws -> (RemoteCtrlInfo?, CtrlAppInfo, String) { let r: ChatResponse2 = try await chatSendCmd(.connectRemoteCtrl(xrcpInvitation: desktopAddress)) if case let .remoteCtrlConnecting(rc_, ctrlAppInfo, v) = r { return (rc_, ctrlAppInfo, v) } throw r.unexpected } +// Spec: spec/architecture.md#findKnownRemoteCtrl func findKnownRemoteCtrl() async throws { try await sendCommandOkResp(.findKnownRemoteCtrl) } @@ -1810,6 +1841,22 @@ func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInf throw r.unexpected } +func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay])? { + let userId = try currentUserId("apiNewPublicGroup") + let r: APIResult? = await chatApiSendCmdWithRetry(.apiNewPublicGroup(userId: userId, incognito: incognito, relayIds: relayIds, groupProfile: groupProfile)) + switch r { + case let .result(.publicGroupCreated(_, groupInfo, groupLink, groupRelays)): + return (groupInfo, groupLink, groupRelays) + default: if let r { throw r.unexpected } else { return nil } + } +} + +func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] { + let r: APIResult = await chatApiSendCmd(.apiGetGroupRelays(groupId: groupId)) + if case let .result(.groupRelays(_, _, relays)) = r { return relays } + return [] +} + func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) if case let .sentGroupInvitation(_, _, _, member) = r { return member } @@ -2078,6 +2125,7 @@ private func chatInitialized(start: Bool, refreshInvitations: Bool) throws { } } +// Spec: spec/architecture.md#startChat func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { logger.debug("startChat") let m = ChatModel.shared @@ -2199,6 +2247,7 @@ private func getUserChatDataAsync(keepingChatId: String?) async throws { } } +// Spec: spec/architecture.md#ChatReceiver class ChatReceiver { private var receiveLoop: Task? private var receiveMessages = true @@ -2244,6 +2293,7 @@ class ChatReceiver { } } +// Spec: spec/api.md#processReceivedMsg func processReceivedMsg(_ res: ChatEvent) async { let m = ChatModel.shared logger.debug("processReceivedMsg: \(res.responseType)") @@ -2442,9 +2492,9 @@ func processReceivedMsg(_ res: ChatEvent) async { } case let .groupLinkConnecting(user, groupInfo, hostMember): if !active(user) { return } - await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) if let hostConn = hostMember.activeConn { m.dismissConnReqView(hostConn.id) m.removeChat(hostConn.id) @@ -2507,10 +2557,11 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(groupInfo) } } - case let .userJoinedGroup(user, groupInfo): + case let .userJoinedGroup(user, groupInfo, hostMember): if active(user) { await MainActor.run { m.updateGroup(groupInfo) + _ = m.upsertGroupMember(groupInfo, hostMember) } if m.chatId == groupInfo.id { if groupInfo.membership.memberPending { @@ -2542,6 +2593,23 @@ func processReceivedMsg(_ res: ChatEvent) async { m.updateGroup(toGroup) } } + case let .groupLinkDataUpdated(user, groupInfo, _, groupRelays, _): + if active(user) { + await MainActor.run { + m.updateGroup(groupInfo) + let relaysModel = ChannelRelaysModel.shared + if relaysModel.groupId == groupInfo.groupId { + relaysModel.set(groupId: groupInfo.groupId, groupRelays: groupRelays) + } + } + } + case let .groupRelayUpdated(user, groupInfo, member, groupRelay): + if active(user) { + await MainActor.run { + _ = m.upsertGroupMember(groupInfo, member) + ChannelRelaysModel.shared.updateRelay(groupInfo, groupRelay) + } + } case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _): if active(user) { await MainActor.run { diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index e1a6bb61e8..1e9a97c31b 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -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 diff --git a/apps/ios/Shared/Theme/Theme.swift b/apps/ios/Shared/Theme/Theme.swift index 3bd8f00c25..1f98b23a1d 100644 --- a/apps/ios/Shared/Theme/Theme.swift +++ b/apps/ios/Shared/Theme/Theme.swift @@ -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 } diff --git a/apps/ios/Shared/Theme/ThemeManager.swift b/apps/ios/Shared/Theme/ThemeManager.swift index 4166619d04..b9a35163cf 100644 --- a/apps/ios/Shared/Theme/ThemeManager.swift +++ b/apps/ios/Shared/Theme/ThemeManager.swift @@ -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 }) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index ab7a47b944..754bcb2715 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -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) { diff --git a/apps/ios/Shared/Views/Call/CallController.swift b/apps/ios/Shared/Views/Call/CallController.swift index 1f28180e87..9df0c2f0b7 100644 --- a/apps/ios/Shared/Views/Call/CallController.swift +++ b/apps/ios/Shared/Views/Call/CallController.swift @@ -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 } diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index db7910836e..2ce04e4b80 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -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() diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 37f3b982a1..e158b9374f 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -57,6 +57,18 @@ struct ChatInfoToolbar: View { .padding(.top, -2) } } + .if (channelSubscriberCount != nil) { v in + VStack(spacing: 0) { + v + if let count = channelSubscriberCount { + Text(subscriberCountStr(count)) + .font(.caption) + .foregroundColor(theme.colors.secondary) + .lineLimit(1) + .padding(.top, -2) + } + } + } if let contact = chat.chatInfo.contact, contact.ready && contact.active, let chatSubStatus = m.chatSubStatus, @@ -69,6 +81,17 @@ struct ChatInfoToolbar: View { .frame(width: 220) } + private var channelSubscriberCount: Int64? { + if case let .group(groupInfo, _) = chat.chatInfo, + groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + count + } else { + nil + } + } + private var contactVerifiedShield: Text { (Text(Image(systemName: "checkmark.shield")) + textSpace) .font(.caption) @@ -102,6 +125,12 @@ struct ChatInfoToolbar: View { } } +public func subscriberCountStr(_ count: Int64) -> String { + count == 1 + ? String.localizedStringWithFormat(NSLocalizedString("%d subscriber", comment: "channel subscriber count"), count) + : String.localizedStringWithFormat(NSLocalizedString("%d subscribers", comment: "channel subscriber count"), count) +} + struct ChatInfoToolbar_Previews: PreviewProvider { static var previews: some View { ChatInfoToolbar(chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])) diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ad82af05e2..c17d8e23a8 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift index 30f5e7a589..93ffb9f042 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/AnimatedImageView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift index 0283e9c07e..e5f3c05eed 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CICallItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index b2b4441646..5521470d07 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift index 1375b87a5a..49a086d45a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIEventView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift index 67f7b69e2c..dcd6ea579c 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index 1b9376b5db..639de1dbc9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index 3fcf578875..ddb58fdfd1 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift index d1f49f635a..b56f1f9f2a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIImageView.swift @@ -5,10 +5,12 @@ // Created by JRoberts on 12/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CIImageView struct CIImageView: View { @EnvironmentObject var m: ChatModel let chatItem: ChatItem @@ -96,12 +98,13 @@ struct CIImageView: View { if img.imageData == nil { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } else { - SwiftyGif(image: img) - .frame(width: w, height: w * img.size.height / img.size.width) - .scaledToFit() + SwiftyGif(image: img, contentMode: .scaleAspectFill) + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() } if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { loadingIndicator() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift index 5e9fa691de..80cccbf907 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift @@ -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? diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift index f07e90b953..b3fdd3f8e3 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CILinkView.swift @@ -5,10 +5,12 @@ // Created by Ian Davies on 07/04/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat +// Spec: spec/client/chat-view.md#CILinkView struct CILinkView: View { @EnvironmentObject var theme: AppTheme let linkPreview: LinkPreview @@ -19,7 +21,8 @@ struct CILinkView: View { if let uiImage = imageFromBase64(linkPreview.image) { Image(uiImage: uiImage) .resizable() - .scaledToFit() + .aspectRatio(1 / heightRatio(uiImage.size), contentMode: .fill) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) .if(!blurred) { v in v.simultaneousGesture(TapGesture().onEnded { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift index 2898a318a9..4719c3dcdc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index fc73778239..e3bc654ac9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 3201332c1e..ec23dc15a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index eacbe9360a..e1172dab92 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -5,12 +5,14 @@ // Created by Avently on 30/03/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import AVKit import SimpleXChat import Combine +// Spec: spec/client/chat-view.md#CIVideoView struct CIVideoView: View { @EnvironmentObject var m: ChatModel private let chatItem: ChatItem @@ -185,7 +187,8 @@ struct CIVideoView: View { ZStack(alignment: .center) { let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) VideoPlayerView(player: player, url: url, showControls: false) - .frame(width: w, height: w * preview.size.height / preview.size.width) + .frame(width: w, height: w * heightRatio(preview.size)) + .clipped() .onChange(of: m.stopPreviousRecPlay) { playingUrl in if playingUrl != url { player.pause() @@ -313,8 +316,9 @@ struct CIVideoView: View { return ZStack(alignment: .topTrailing) { Image(uiImage: img) .resizable() - .scaledToFit() - .frame(width: w) + .scaledToFill() + .frame(width: w, height: w * heightRatio(img.size)) + .clipped() .modifier(PrivacyBlur(blurred: $blurred)) if !blurred || !showDownloadButton(chatItem.file?.fileStatus) { fileStatusIcon() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 47aee2a586..820074542f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift index ed2340b6c4..fb5d36ab12 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/DeletedItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift index 250d9d5636..04f36c97a4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/EmojiItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 0b6f249b9c..123f7289bb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index c9c9952688..ec8bc852c0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift index f243a83142..e14683684d 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FullScreenMediaView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift index 47a30f6cf3..fdf3743aac 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index c6a5d0353c..953f4e8c82 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 2a1b526893..77bd41c5b8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 13/03/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -23,6 +24,7 @@ private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont. return res } +// Spec: spec/client/chat-view.md#MsgContentView struct MsgContentView: View { @ObservedObject var chat: Chat @Environment(\.showTimestamp) var showTimestamp: Bool @@ -320,6 +322,7 @@ func messageText( var bold: UIFont? var italic: UIFont? var snippet: UIFont? + var small: UIFont? var mention: UIFont? var secretIdx: Int = 0 for ft in fts { @@ -351,6 +354,10 @@ func messageText( attrs[.backgroundColor] = secretColor } hasSecrets = true + case .small: + small = small ?? UIFont.preferredFont(forTextStyle: .footnote) + attrs[.font] = small + attrs[.foregroundColor] = UIColor.secondaryLabel case let .colored(color): if let c = color.uiColor { attrs[.foregroundColor] = UIColor(c) diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 87c6ba92f8..3858d15252 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 5f48c18881..138aed6c65 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -38,6 +38,7 @@ extension EnvironmentValues { } } +// Spec: spec/client/chat-view.md#ChatItemView struct ChatItemView: View { @ObservedObject var chat: Chat @ObservedObject var im: ItemsModel @@ -194,7 +195,7 @@ struct ChatItemContentView: View { } private func pendingReviewEventItemText() -> Text { - Text(chatItem.content.text) + Text(chatItem.content.text(isChannel: chat.chatInfo.isChannel)) .font(.caption) .foregroundColor(theme.colors.secondary) .fontWeight(.bold) @@ -208,9 +209,9 @@ struct ChatItemContentView: View { .font(.caption) .foregroundColor(secondaryColor) .fontWeight(.light) - + chatEventText(chatItem, secondaryColor) + + chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } else { - return chatEventText(chatItem, secondaryColor) + return chatEventText(chatItem, secondaryColor, isChannel: chat.chatInfo.isChannel) } } @@ -233,7 +234,7 @@ struct ChatItemContentView: View { return if count <= 1 { nil } else if ns.count == 0 { - Text("\(count) group events") + chat.chatInfo.isChannel ? Text("\(count) channel events") : Text("\(count) group events") } else if count > ns.count { Text(members) + textSpace + Text("and \(count - ns.count) other events") } else { @@ -274,8 +275,8 @@ func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text, _ secondaryColor chatEventText(Text(eventText) + textSpace + ts, secondaryColor) } -func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text { - chatEventText("\(ci.content.text)", ci.timestampText, secondaryColor) +func chatEventText(_ ci: ChatItem, _ secondaryColor: Color, isChannel: Bool = false) -> Text { + chatEventText("\(ci.content.text(isChannel: isChannel))", ci.timestampText, secondaryColor) } struct ChatItemView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 5f2102b8bc..0b074c6370 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -267,6 +267,7 @@ struct ListItem: Hashable { case .directRcv: 1 case .groupSnd: 2 case let .groupRcv(mem): "\(mem.groupMemberId) \(mem.displayName) \(mem.memberStatus.rawValue) \(mem.memberRole.rawValue) \(mem.image?.hash ?? 0)".hash + case .channelRcv: 3 case .localSnd: 4 case .localRcv: 5 } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index dc1228fce8..1898fd2851 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -13,6 +14,7 @@ import Combine private let memberImageSize: CGFloat = 34 +// Spec: spec/client/chat-view.md#ChatView struct ChatView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -63,13 +65,13 @@ struct ChatView: View { @State private var showUserSupportChatSheet = false @State private var showCommandsMenu = false @State private var supportChatMemberInfoLinkActive = false - @State private var scrollView: EndlessScrollView = EndlessScrollView(frame: .zero) @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial let userSupportScopeInfo: GroupChatScopeInfo = .memberSupport(groupMember_: nil) + // Spec: spec/client/chat-view.md#body var body: some View { if #available(iOS 16.0, *) { viewBody @@ -132,12 +134,6 @@ struct ChatView: View { .padding(.top) } if selectedChatItems == nil { - let reason = chat.chatInfo.userCantSendReason - let composeEnabled = ( - chat.chatInfo.sendMsgEnabled || - (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || // allow to join prepared group without message - (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) // allow to accept or reject contact request - ) ComposeView( chat: chat, im: im, @@ -146,17 +142,8 @@ struct ChatView: View { keyboardVisible: $keyboardVisible, keyboardHiddenDate: $keyboardHiddenDate, selectedRange: $selectedRange, - disabledText: reason?.composeLabel + disabledText: chat.chatInfo.userCantSendReason?.composeLabel ) - .disabled(!composeEnabled) - .if(!composeEnabled) { v in - v.disabled(true).onTapGesture { - AlertManager.shared.showAlertMsg( - title: "You can't send messages!", - message: reason?.alertMessage - ) - } - } } else { SelectedItemsBottomToolbar( im: im, @@ -266,6 +253,18 @@ struct ChatView: View { AddGroupMembersView(chat: chat, groupInfo: groupInfo) } } + .appSheet(isPresented: $showGroupLinkSheet) { + if case let .group(groupInfo, _) = cInfo { + GroupLinkView( + groupId: groupInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: $groupLinkMemberRole, + showTitle: true, + creatingGroup: false, + isChannel: groupInfo.useRelays + ) + } + } .sheet(isPresented: Binding( get: { !forwardedChatItems.isEmpty }, set: { isPresented in @@ -348,6 +347,13 @@ struct ChatView: View { if let openAround = chatModel.openAroundItemId, let index = mergedItems.boxedValue.indexInParentItems[openAround] { scrollView.scrollToItem(index) + } else if let viewedIdx = mergedItems.boxedValue.items.firstIndex(where: { !$0.hasUnread() }) { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + if viewedIdx > 0 { + scrollView.scrollToItem(viewedIdx - 1) + } else { + scrollView.scrollToBottom() + } } else if let unreadIndex = mergedItems.boxedValue.items.lastIndex(where: { $0.hasUnread() }) { scrollView.scrollToItem(unreadIndex) } else { @@ -402,6 +408,7 @@ struct ChatView: View { chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false + ChannelRelaysModel.shared.reset() } } } @@ -530,32 +537,51 @@ struct ChatView: View { case let .direct(contact): HStack { let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser + let canStartCall = callsPrefEnabled && contact.ready && contact.active && chatModel.activeCall == nil if let call = chatModel.activeCall, call.contact.id == cInfo.id { endCallButton(call) - } else { - contentFilterMenu(withLabel: false) - } - Menu { - if callsPrefEnabled && chatModel.activeCall == nil { + } else if canStartCall { + // Call button always in toolbar; tap opens Audio/Video submenu + Menu { Button { CallController.shared.startCall(contact, .audio) } label: { Label("Audio call", systemImage: "phone") } - .disabled(!contact.ready || !contact.active) Button { CallController.shared.startCall(contact, .video) } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready || !contact.active) - } - if let call = chatModel.activeCall, call.contact.id == cInfo.id { - contentFilterMenu(withLabel: true) + } label: { + Image(systemName: "phone") } + } else if chatModel.activeCall == nil { + // Calls unavailable: show filter button in place of call button + contentFilterMenu(withLabel: false) + } + Menu { searchButton() ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) + // Filter options in menu when call button is shown (or during any active call) + if !availableContent.isEmpty && (canStartCall || chatModel.activeCall != nil) { + Divider() + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } } label: { Image(systemName: "ellipsis") } @@ -565,17 +591,8 @@ struct ChatView: View { contentFilterMenu(withLabel: false) Menu { if groupInfo.canAddMembers { - if (chat.chatInfo.incognito) { + if chat.chatInfo.incognito || groupInfo.useRelays { groupLinkButton() - .appSheet(isPresented: $showGroupLinkSheet) { - GroupLinkView( - groupId: groupInfo.groupId, - groupLink: $groupLink, - groupLinkMemberRole: $groupLinkMemberRole, - showTitle: true, - creatingGroup: false - ) - } } else { addMembersButton() } @@ -588,7 +605,26 @@ struct ChatView: View { } case .local: HStack { - contentFilterMenu(withLabel: false) + if !availableContent.isEmpty { + Menu { + ForEach(availableContent, id: \.self) { type in + Button { + setContentFilter(type) + } label: { + Label(type.label, systemImage: contentFilter == type ? type.iconFilled : type.icon) + } + } + if contentFilter != nil { + Button { + closeSearch() + } label: { + Label("All messages", systemImage: "bubble.left.and.text.bubble.right") + } + } + } label: { + Image(systemName: "ellipsis") + } + } searchButton() } default: @@ -668,6 +704,7 @@ struct ChatView: View { .frame(width: 220) } + // Spec: spec/client/chat-view.md#initChatView private func initChatView() { let cInfo = chat.chatInfo // This check prevents the call to apiContactInfo after the app is suspended, and the database is closed. @@ -697,6 +734,25 @@ struct ChatView: View { } } } + if case let .group(groupInfo, _) = cInfo, groupInfo.useRelays { + Task { await chatModel.loadGroupMembers(groupInfo) } + if groupInfo.membership.memberRole == .owner { + Task { + let relays = await apiGetGroupRelays(groupInfo.groupId) + await MainActor.run { + ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays) + } + } + } else { + Task { + if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) { + await MainActor.run { + chatModel.updateGroup(gInfo) + } + } + } + } + } updateAvailableContent() } if chatModel.draftChatId == cInfo.id && !composeState.forwarding, @@ -727,6 +783,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#scrollToItem private func scrollToItem(_ itemId: ChatItem.ID) { Task { do { @@ -760,6 +817,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchToolbar private func searchToolbar() -> some View { let placeholder: LocalizedStringKey = contentFilter?.searchPlaceholder ?? "Search" return HStack(spacing: 12) { @@ -797,6 +855,7 @@ struct ChatView: View { ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } + // Spec: spec/client/chat-view.md#filtered private func filtered(_ reversedChatItems: Array) -> Array { reversedChatItems .enumerated() @@ -810,6 +869,7 @@ struct ChatView: View { .map { $0.element } } + // Spec: spec/client/chat-view.md#chatItemsList private func chatItemsList() -> some View { let cInfo = chat.chatInfo return GeometryReader { g in @@ -857,6 +917,7 @@ struct ChatView: View { selectedChatItems: $selectedChatItems, forwardedChatItems: $forwardedChatItems, searchText: $searchText, + contentFilter: $contentFilter, closeKeyboardAndRun: closeKeyboardAndRun ) } @@ -1021,12 +1082,12 @@ struct ChatView: View { switch groupInfo.businessChat?.chatType { case .none: if groupInfo.nextConnectPrepared { - "Tap Join group" + groupInfo.useRelays ? "Tap Join channel" : "Tap Join group" } else { switch (groupInfo.membership.memberStatus) { - case .memInvited: "Join group" - case .memCreator: "Your group" - default: "Group" + case .memInvited: groupInfo.useRelays ? "Join channel" : "Join group" + case .memCreator: groupInfo.useRelays ? "Your channel" : "Your group" + default: groupInfo.useRelays ? "Channel" : "Group" } } case .business: @@ -1054,10 +1115,14 @@ struct ChatView: View { nil } case let .group(groupInfo, _): - switch (groupInfo.membership.memberStatus) { - case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil - case .memAccepted: "connecting…" - default: nil + if groupInfo.useRelays { + nil + } else { + switch (groupInfo.membership.memberStatus) { + case .memUnknown: groupInfo.preparedGroup?.connLinkStartedConnection == true ? "connecting…" : nil + case .memAccepted: "connecting…" + default: nil + } } default: nil } @@ -1083,6 +1148,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#searchTextChanged private func searchTextChanged(_ s: String) { Task { await loadChat(chat: chat, im: im, contentTag: contentFilter?.contentTag, search: s) @@ -1260,6 +1326,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#callButton private func callButton(_ contact: Contact, _ media: CallMediaType, imageName: String) -> some View { Button { CallController.shared.startCall(contact, media) @@ -1374,7 +1441,11 @@ struct ChatView: View { } } } label: { - Label("Group link", systemImage: "link.badge.plus") + if case let .group(gInfo, _) = chat.chatInfo, gInfo.useRelays { + Label("Channel link", systemImage: "link") + } else { + Label("Group link", systemImage: "link.badge.plus") + } } } @@ -1397,6 +1468,7 @@ struct ChatView: View { )) } + // Spec: spec/client/chat-view.md#deletedSelectedMessages private func deletedSelectedMessages() async { await MainActor.run { withAnimation { @@ -1405,6 +1477,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#forwardSelectedMessages private func forwardSelectedMessages() { Task { do { @@ -1515,6 +1588,7 @@ struct ChatView: View { } } + // Spec: spec/client/chat-view.md#loadChatItems private func loadChatItems(_ chat: Chat, _ pagination: ChatPagination) async -> Bool { if loadingMoreItems { return false } await MainActor.run { @@ -1555,6 +1629,7 @@ struct ChatView: View { VoiceItemState.chatView = [:] } + // Spec: spec/client/chat-view.md#onChatItemsUpdated func onChatItemsUpdated() { if !mergedItems.boxedValue.isActualState() { //logger.debug("Items are not actual, waiting for the next update: \(String(describing: mergedItems.boxedValue.splits)) \(im.chatState.splits), \(mergedItems.boxedValue.indexInParentItems.count) vs \(im.reversedChatItems.count)") @@ -1582,6 +1657,7 @@ struct ChatView: View { ) } + // Spec: spec/client/chat-view.md#ChatItemWithMenu private struct ChatItemWithMenu: View { @ObservedObject var im: ItemsModel @EnvironmentObject var m: ChatModel @@ -1616,12 +1692,14 @@ struct ChatView: View { @Binding var forwardedChatItems: [ChatItem] @Binding var searchText: String + @Binding var contentFilter: ContentFilter? var closeKeyboardAndRun: (@escaping () -> Void) -> Void @State private var allowMenu: Bool = true @State private var markedRead = false @State private var markReadTask: Task? = nil @State private var actionSheet: SomeActionSheet? = nil + @State private var swipeOffset: CGFloat = 0 var revealed: Bool { revealedItems.contains(chatItem.id) } @@ -1638,6 +1716,8 @@ struct ChatView: View { let sameMemberAndDirection = if case .groupRcv(let prevGroupMember) = prevItem.chatDir, case .groupRcv(let groupMember) = chatItem.chatDir { groupMember.groupMemberId == prevGroupMember.groupMemberId + } else if case .channelRcv = chatItem.chatDir, case .channelRcv = prevItem.chatDir { + true } else { chatItem.chatDir.sent == prevItem.chatDir.sent } @@ -1653,16 +1733,21 @@ struct ChatView: View { func shouldShowAvatar(_ current: ChatItem, _ older: ChatItem?) -> Bool { let oldIsGroupRcv = switch older?.chatDir { case .groupRcv: true + case .channelRcv: true default: false } let sameMember = switch (older?.chatDir, current.chatDir) { case (.groupRcv(let oldMember), .groupRcv(let member)): oldMember.memberId == member.memberId + case (.channelRcv, .channelRcv): + true default: false } if case .groupRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { return true + } else if case .channelRcv = current.chatDir, (older == nil || (!oldIsGroupRcv || !sameMember)) { + return true } else { return false } @@ -1773,7 +1858,7 @@ struct ChatView: View { private var searchIsNotBlank: Bool { get { - searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + (searchText.count > 0 && !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) || contentFilter != nil } } @@ -1828,7 +1913,74 @@ struct ChatView: View { _ itemSeparation: ItemSeparation ) -> some View { let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 - if case let .groupRcv(member) = ci.chatDir, + if case .channelRcv = ci.chatDir, + case let .group(groupInfo, _) = chat.chatInfo { + if showAvatar { + VStack(alignment: .leading, spacing: 4) { + if ci.content.showMemberName { + Group { + Group { + if #available(iOS 16.0, *) { + MemberLayout(spacing: 16, msgWidth: msgWidth) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .padding(.trailing, 8) + } + } else { + HStack(spacing: 16) { + Text(groupInfo.chatViewName) + .lineLimit(1) + Text(NSLocalizedString("channel", comment: "shown as sender role for channel messages")) + .fontWeight(.semibold) + .lineLimit(1) + .layoutPriority(1) + } + } + } + .frame( + maxWidth: maxWidth, + alignment: chatItem.chatDir.sent ? .trailing : .leading + ) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0)) + .padding(.top, 3) + } + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.trailing, 12) + } + HStack(alignment: .top, spacing: 10) { + ProfileImage(imageStr: groupInfo.image, iconName: groupInfo.chatIconName, size: memberImageSize, backgroundColor: theme.colors.background) + .simultaneousGesture(TapGesture().onEnded { + showChatInfoSheet = true + }) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } + } + } + } + .padding(.bottom, bottomPadding) + .padding(.trailing) + .padding(.leading, 12) + } else { + HStack(alignment: .center, spacing: 0) { + if selectedChatItems != nil && ci.canBeDeletedForSelf { + SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) + .padding(.leading, 12) + } + chatItemWithMenu(ci, range, maxWidth, itemSeparation) + .padding(.trailing) + .padding(.leading, 10 + memberImageSize + 12) + } + .padding(.bottom, bottomPadding) + } + } else if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo, _) = chat.chatInfo { if showAvatar { VStack(alignment: .leading, spacing: 4) { @@ -1956,33 +2108,69 @@ struct ChatView: View { func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading - return VStack(alignment: alignment.horizontal, spacing: 3) { - HStack { - if ci.chatDir.sent { - goToItemButton(true) + let live = composeState.liveMessage != nil + let canReply = ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live && !ci.localNote && selectedChatItems == nil + return ZStack(alignment: .trailing) { + Image(systemName: "arrowshape.turn.up.left") + .font(.system(size: 18)) + .foregroundColor(.secondary) + .opacity(min(1, -swipeOffset / 30)) + .offset(x: swipeOffset + 40) + VStack(alignment: alignment.horizontal, spacing: 3) { + HStack { + if ci.chatDir.sent { + goToItemButton(true) + } + ChatItemView( + chat: chat, + im: im, + chatItem: ci, + scrollToItem: scrollToItem, + scrollToItemId: $scrollToItemId, + maxWidth: maxWidth, + allowMenu: $allowMenu + ) + .environment(\.revealed, revealed) + .environment(\.showTimestamp, itemSeparation.timestamp) + .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) + .contextMenu { menu(ci, range, live: live) } + .accessibilityLabel("") + if !ci.chatDir.sent { + goToItemButton(false) + } } - ChatItemView( - chat: chat, - im: im, - chatItem: ci, - scrollToItem: scrollToItem, - scrollToItemId: $scrollToItemId, - maxWidth: maxWidth, - allowMenu: $allowMenu - ) - .environment(\.revealed, revealed) - .environment(\.showTimestamp, itemSeparation.timestamp) - .modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed))) - .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } - .accessibilityLabel("") - if !ci.chatDir.sent { - goToItemButton(false) + if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { + chatItemReactions(ci) + .padding(.bottom, 4) } } - if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { - chatItemReactions(ci) - .padding(.bottom, 4) - } + .offset(x: swipeOffset) + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 10) + .onChanged { value in + guard canReply else { return } + let x = value.translation.width + if x < 0 { + swipeOffset = max(x * 0.63, -56) + } + } + .onEnded { _ in + if swipeOffset < -42 { + withAnimation { + if composeState.editing { + composeState = ComposeState(contextItem: .quotedItem(chatItem: ci)) + } else { + composeState = composeState.copy(contextItem: .quotedItem(chatItem: ci)) + } + } + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + withAnimation(.spring(response: 0.25)) { + swipeOffset = 0 + } + } + ) } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { @@ -2028,6 +2216,7 @@ struct ChatView: View { switch (prevItem?.chatDir) { case .groupSnd: return true case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId + case .channelRcv: return true default: return false } } @@ -2693,6 +2882,7 @@ struct ChatView: View { } } +// Spec: spec/client/chat-view.md#FloatingButtonModel class FloatingButtonModel: ObservableObject { @ObservedObject var im: ItemsModel @@ -2775,6 +2965,7 @@ private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } +// Spec: spec/client/chat-view.md#deleteMessages private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) { let itemIds = deletingItems if itemIds.count > 0 { @@ -2878,6 +3069,7 @@ private func buildTheme() -> AppTheme { } } +// Spec: spec/client/chat-view.md#ReactionContextMenu struct ReactionContextMenu: View { @EnvironmentObject var m: ChatModel let groupInfo: GroupInfo @@ -3027,6 +3219,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { } } +// Spec: spec/client/chat-view.md#ContentFilter enum ContentFilter: CaseIterable { case images case videos diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3745d0f0b8..f37eb614b9 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -1,3 +1,4 @@ +// Spec: spec/client/compose.md import SwiftUI import SimpleXChat @@ -6,6 +7,7 @@ import PhotosUI let MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview enum ComposePreview { case noPreview case linkPreview(linkPreview: LinkPreview?) @@ -14,6 +16,7 @@ enum ComposePreview { case filePreview(fileName: String, file: URL) } +// Spec: spec/client/compose.md#ComposeContextItem enum ComposeContextItem: Equatable { case noContextItem case quotedItem(chatItem: ChatItem) @@ -22,12 +25,14 @@ enum ComposeContextItem: Equatable { case reportedItem(chatItem: ChatItem, reason: ReportReason) } +// Spec: spec/client/compose.md#VoiceMessageRecordingState enum VoiceMessageRecordingState { case noRecording case recording case finished } +// Spec: spec/client/compose.md#LiveMessage struct LiveMessage { var chatItem: ChatItem var typedMsg: String @@ -36,6 +41,7 @@ struct LiveMessage { typealias MentionedMembers = [String: CIMention] +// Spec: spec/client/compose.md#ComposeState struct ComposeState { var message: String var parsedMessage: [FormattedText] @@ -256,6 +262,7 @@ struct ComposeState { } } +// Spec: spec/client/compose.md#chatItemPreview func chatItemPreview(chatItem: ChatItem) -> ComposePreview { switch chatItem.content.msgContent { case .text: @@ -276,6 +283,7 @@ func chatItemPreview(chatItem: ChatItem) -> ComposePreview { } } +// Spec: spec/client/compose.md#UploadContent enum UploadContent: Equatable { case simpleImage(image: UIImage) case animatedImage(image: UIImage) @@ -317,6 +325,7 @@ enum UploadContent: Equatable { } } +// Spec: spec/client/compose.md#ComposeView struct ComposeView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -355,12 +364,16 @@ struct ComposeView: View { @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS, store: groupDefaults) private var privacySanitizeLinks = false @State private var updatingCompose = false + @State private var relayListExpanded = false + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared + // Spec: spec/client/compose.md#body var body: some View { VStack(spacing: 0) { Divider() if chat.chatInfo.nextConnectPrepared, + !composeState.inProgress, let user = chatModel.currentUser { ContextProfilePickerView( chat: chat, @@ -369,85 +382,152 @@ struct ComposeView: View { Divider() } - if let groupInfo = chat.chatInfo.groupInfo, - case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, - case let .memberSupport(member) = groupScopeInfo, - let member = member, - member.memberPending, - composeState.contextItem == .noContextItem, - composeState.noPreview { - ContextPendingMemberActionsView( - groupInfo: groupInfo, - member: member - ) - Divider() - } - - if case let .reportedItem(_, reason) = composeState.contextItem { - reportReasonView(reason) - Divider() - } - // preference checks should match checks in forwarding list - let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) - let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) - let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) - let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited - if simplexLinkProhibited { - msgNotAllowedView("SimpleX links not allowed", icon: "link") - Divider() - } else if fileProhibited { - msgNotAllowedView("Files and media not allowed", icon: "doc") - Divider() - } else if voiceProhibited { - msgNotAllowedView("Voice messages not allowed", icon: "mic") - Divider() - } - contextItemView() - switch (composeState.editing, composeState.preview) { - case (true, .filePreview): EmptyView() - case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed - default: previewView() - } - - let contact = chat.chatInfo.contact - - if chat.chatInfo.groupInfo?.nextConnectPrepared == true { - if chat.chatInfo.groupInfo?.businessChat == nil { - connectButtonView("Join group", icon: "person.2.fill", connect: connectPreparedGroup) + if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays, + ![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) { + if gInfo.membership.memberRole == .owner { + let relays = channelRelaysModel.groupId == gInfo.groupId + ? channelRelaysModel.groupRelays : [] + let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + if !relays.isEmpty && activeCount < relays.count { + ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount) + } } else { - sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) - } - } else if contact?.nextSendGrpInv == true { - contextSendMessageToConnect("Send direct message to connect") - Divider() - HStack (alignment: .center) { - attachmentAndCommandsButtons().disabled(true) - sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) - } - .padding(.horizontal, 12) - } else if let contact, - contact.nextConnectPrepared == true, - let linkType = contact.preparedContact?.uiConnLinkType { - switch linkType { - case .inv: - connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) - case .con: - if contact.isBot { - connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) - } else { - sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted() + let relayMembers = chatModel.groupMembers + .filter { $0.wrapped.memberRole == .relay } + .sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") } + let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress + let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count + let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count + let failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count + let errorCount = deletedCount + failedCount + let resolvedCount = connectedCount + deletedCount + let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count + if total > 0, !showProgress || resolvedCount < total { + subscriberChannelRelayBar( + hostnames: hostnames, + relayMembers: relayMembers, + connectedCount: connectedCount, + errorCount: errorCount, + total: total, + showProgress: showProgress + ) } } - } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { - ContextContactRequestActionsView(contactRequestId: crId) - } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { - ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) - } else { - HStack (alignment: .center) { - attachmentAndCommandsButtons() - sendMessageView(disableSendButton) + } + + let composeEnabled = ( + chat.chatInfo.sendMsgEnabled || + (chat.chatInfo.groupInfo?.nextConnectPrepared ?? false) || + (chat.chatInfo.contact?.nextAcceptContactRequest ?? false) + ) + Group { + + if let groupInfo = chat.chatInfo.groupInfo, + case let .groupChatScopeContext(groupScopeInfo) = im.secondaryIMFilter, + case let .memberSupport(member) = groupScopeInfo, + let member = member, + member.memberPending, + composeState.contextItem == .noContextItem, + composeState.noPreview { + ContextPendingMemberActionsView( + groupInfo: groupInfo, + member: member + ) + Divider() + } + + if case let .reportedItem(_, reason) = composeState.contextItem { + reportReasonView(reason) + Divider() + } + // preference checks should match checks in forwarding list + let simplexLinkProhibited = im.secondaryIMFilter == nil && hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = im.secondaryIMFilter == nil && composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) + let disableSendButton = simplexLinkProhibited || fileProhibited || voiceProhibited + if simplexLinkProhibited { + msgNotAllowedView("SimpleX links not allowed", icon: "link") + Divider() + } else if fileProhibited { + msgNotAllowedView("Files and media not allowed", icon: "doc") + Divider() + } else if voiceProhibited { + msgNotAllowedView("Voice messages not allowed", icon: "mic") + Divider() + } + contextItemView() + switch (composeState.editing, composeState.preview) { + case (true, .filePreview): EmptyView() + case (true, .voicePreview): EmptyView() // ? we may allow playback when editing is allowed + default: previewView() + } + + let contact = chat.chatInfo.contact + + if chat.chatInfo.groupInfo?.nextConnectPrepared == true { + if chat.chatInfo.groupInfo?.businessChat == nil { + let isChannel = chat.chatInfo.groupInfo?.useRelays == true + connectButtonView( + isChannel ? "Join channel" : "Join group", + icon: isChannel ? "antenna.radiowaves.left.and.right.circle.fill" : "person.2.fill", + connect: connectPreparedGroup + ) + } else { + sendContactRequestView(disableSendButton, icon: "briefcase.fill", sendRequest: connectPreparedGroup) + } + } else if contact?.nextSendGrpInv == true { + contextSendMessageToConnect("Send direct message to connect") + Divider() + HStack (alignment: .center) { + attachmentAndCommandsButtons().disabled(true) + sendMessageView(disableSendButton, sendToConnect: sendMemberContactInvitation) + } + .padding(.horizontal, 12) + } else if let contact, + contact.nextConnectPrepared == true, + let linkType = contact.preparedContact?.uiConnLinkType { + switch linkType { + case .inv: + connectButtonView("Connect", icon: "person.fill.badge.plus", connect: sendConnectPreparedContact) + case .con: + if contact.isBot { + connectButtonView("Connect", icon: "bolt.fill", connect: sendConnectPreparedContact) + } else { + sendContactRequestView(disableSendButton, icon: "person.fill.badge.plus", sendRequest: sendConnectPreparedContactRequest) + } + } + } else if contact?.nextAcceptContactRequest == true, let crId = contact?.contactRequestId { + ContextContactRequestActionsView(contactRequestId: crId) + } else if let ct = contact, ct.nextAcceptContactRequest, let groupDirectInv = ct.groupDirectInv { + ContextMemberContactActionsView(contact: ct, groupDirectInv: groupDirectInv) + } else { + HStack (alignment: .center) { + attachmentAndCommandsButtons() + sendMessageView( + disableSendButton, + placeholder: chat.chatInfo.groupInfo.map { gi in + gi.useRelays && gi.membership.memberRole >= .owner + ? NSLocalizedString("Broadcast", comment: "compose placeholder for channel owner") + : nil + } ?? nil + ) + } + .padding(.horizontal, 12) + } + + } // Group + .disabled(!composeEnabled) + .if(!composeEnabled) { v in + v.onTapGesture { + if let reason = chat.chatInfo.userCantSendReason { + AlertManager.shared.showAlertMsg( + title: "You can't send messages!", + message: reason.alertMessage + ) + } } - .padding(.horizontal, 12) } } .background { @@ -643,18 +723,175 @@ struct ComposeView: View { } } + private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View { + let total = relays.count + let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + return VStack(spacing: 0) { + relayBarHeader { + if activeCount + failedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } + } + if relayListExpanded { + ForEach(sorted) { relay in + let failedErr = relayMemberConnFailed(relay) + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + ownerRelayDetailRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + ownerRelayDetailRow(relay, connFailed: false) + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + + private func subscriberChannelRelayBar( + hostnames: [String], + relayMembers: [GMember], + connectedCount: Int, + errorCount: Int, + total: Int, + showProgress: Bool + ) -> some View { + VStack(spacing: 0) { + relayBarHeader { + if showProgress && connectedCount + errorCount < total { + RelayProgressIndicator(active: connectedCount, total: total) + } + if showProgress { + if errorCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total)) + } + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total)) + } + } + if relayListExpanded { + if relayMembers.isEmpty { + ForEach(hostnames, id: \.self) { relay in + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(relay))) + .foregroundColor(theme.colors.secondary) + Spacer() + } + } + } else { + ForEach(relayMembers) { member in + let m = member.wrapped + let host = m.relayLink.map { hostFromRelayLink($0) } + let failedErr = m.activeConn?.connFailedErr + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + subscriberRelayDetailRow(m, host: host, connFailed: true) + } + .buttonStyle(.plain) + } else { + subscriberRelayDetailRow(m, host: host, connFailed: false) + } + } + } + } + } + .padding(.bottom, relayListExpanded ? 4 : 0) + .animation(nil, value: relayListExpanded) + } + + private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) + .foregroundColor(theme.colors.secondary) + Spacer() + let status = relayConnStatus(m) + Circle() + .fill(status.color) + .frame(width: 8, height: 8) + Text(status.text) + .foregroundColor(theme.colors.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + } + } + } + + private func relayBarHeader(@ViewBuilder content: () -> Content) -> some View { + Button { + withAnimation(nil) { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + content() + Spacer() + Image(systemName: relayListExpanded ? "chevron.down" : "chevron.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(theme.colors.secondary) + .opacity(0.7) + } + .font(.callout) + .foregroundColor(theme.colors.secondary) + .padding(.top, 8) + .padding(.bottom, relayListExpanded ? 4 : 8) + .padding(.leading, 12) + .padding(.trailing) + } + } + + private func relayBarDetailRow(@ViewBuilder content: () -> Content) -> some View { + HStack { + content() + } + .font(.caption) + .padding(.leading, 12) + .padding(.trailing) + .padding(.vertical, 2) + } + + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { Button(action: connect) { ZStack(alignment: .trailing) { Label(label, systemImage: icon) .frame(maxWidth: .infinity) - if composeState.progressByTimeout { + if composeState.progressByTimeout && chat.chatInfo.groupInfo?.useRelays != true { ProgressView() .padding() } } } - .frame(height: 60) + .frame(height: 57) .disabled(composeState.inProgress) } @@ -679,6 +916,7 @@ struct ComposeView: View { .padding(.horizontal, 12) } + // Spec: spec/client/compose.md#sendMessageView private func sendMessageView(_ disableSendButton: Bool, placeholder: String? = nil, sendToConnect: (() -> Void)? = nil) -> some View { ZStack(alignment: .leading) { SendMessageView( @@ -840,9 +1078,12 @@ struct ComposeView: View { await sending() let mc = connectCheckLinkPreview() let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault - if let groupInfo = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { + if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) { await MainActor.run { self.chatModel.updateGroup(groupInfo) + self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId) + self.chatModel.groupMembers = relayResults.map { GMember($0.relayMember) } + self.chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -878,6 +1119,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#addMediaContent private func addMediaContent(_ content: UploadContent) async { if let img = await resizeImageToStrSize(content.uiImage, maxDataSize: 14000) { var newMedia: [(String, UploadContent?)] = [] @@ -906,6 +1148,7 @@ struct ComposeView: View { getMaxFileSize(.xftp) } + // Spec: spec/client/compose.md#sendLiveMessage private func sendLiveMessage() async { let typedMsg = composeState.message let lm = composeState.liveMessage @@ -923,6 +1166,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#updateLiveMessage private func updateLiveMessage() async { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { @@ -941,6 +1185,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#liveMessageToSend private func liveMessageToSend(_ lm: LiveMessage, _ t: String) -> String? { let s = t != lm.typedMsg ? truncateToWords(t) : t return s != lm.sentMsg && (lm.sentMsg != nil || !s.isEmpty) ? s : nil @@ -1087,6 +1332,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessage private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { @@ -1095,6 +1341,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#sendMessageAsync private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message @@ -1305,6 +1552,7 @@ struct ComposeView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, scope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, live: live, ttl: ttl, composedMessages: msgs @@ -1330,6 +1578,7 @@ struct ComposeView: View { toChatType: chat.chatInfo.chatType, toChatId: chat.chatInfo.apiId, toScope: chat.chatInfo.groupChatScope(), + sendAsGroup: chat.chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, fromChatType: fromChatInfo.chatType, fromChatId: fromChatInfo.apiId, fromScope: fromChatInfo.groupChatScope(), @@ -1361,6 +1610,7 @@ struct ComposeView: View { await MainActor.run { composeState.inProgress = true } } + // Spec: spec/client/compose.md#startVoiceMessageRecording private func startVoiceMessageRecording() async { startingRecording = true let fileName = generateNewFileName("voice", "m4a") @@ -1401,6 +1651,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#finishVoiceMessageRecording private func finishVoiceMessageRecording() { audioRecorder?.stop() audioRecorder = nil @@ -1411,6 +1662,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#allowVoiceMessagesToContact private func allowVoiceMessagesToContact() { if case let .direct(contact) = chat.chatInfo { allowFeatureToContact(contact, .voice) @@ -1436,12 +1688,14 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#cancelVoiceMessageRecording private func cancelVoiceMessageRecording(_ fileName: String) { stopPlayback.toggle() audioRecorder?.stop() removeFile(fileName) } + // Spec: spec/client/compose.md#clearState private func clearState(live: Bool = false) { if live { composeState.inProgress = false @@ -1455,11 +1709,13 @@ struct ComposeView: View { startingRecording = false } + // Spec: spec/client/compose.md#saveCurrentDraft private func saveCurrentDraft() { chatModel.draft = composeState chatModel.draftChatId = chat.id } + // Spec: spec/client/compose.md#clearCurrentDraft private func clearCurrentDraft() { if chatModel.draftChatId == chat.id { chatModel.draft = nil @@ -1467,6 +1723,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#showLinkPreview private func showLinkPreview(_ parsedMsg: [FormattedText]?) { prevLinkUrl = linkUrl (linkUrl, hasSimplexLink) = getMessageLinks(parsedMsg) @@ -1486,6 +1743,7 @@ struct ComposeView: View { } } + // Spec: spec/client/compose.md#getMessageLinks private func getMessageLinks(_ parsedMsg: [FormattedText]?) -> (url: String?, hasSimplexLink: Bool) { guard let parsedMsg else { return (nil, false) } let simplexLink = parsedMsgHasSimplexLink(parsedMsg) @@ -1512,6 +1770,7 @@ struct ComposeView: View { composeState = composeState.copy(preview: .noPreview) } + // Spec: spec/client/compose.md#loadLinkPreview private func loadLinkPreview(_ urlStr: String) { if pendingLinkUrl == urlStr, let url = URL(string: urlStr) { composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 07cd61583b..713f462c27 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift index 3154f16f5b..6b18c0c5ef 100644 --- a/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupMembersView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift new file mode 100644 index 0000000000..abcadc6c3f --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelMembersView.swift @@ -0,0 +1,96 @@ +// +// ChannelMembersView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelMembersView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + + var body: some View { + let members = chatModel.groupMembers + .filter { m in + let s = m.wrapped.memberStatus + return s != .memLeft && s != .memRemoved && m.wrapped.memberRole != .relay + } + if groupInfo.isOwner { + let subscriberCount = groupInfo.groupSummary.publicMemberCount ?? Int64(members.count + 1) + List { + Section(header: Text(subscriberCountStr(subscriberCount)).foregroundColor(theme.colors.secondary)) { + memberRow(GMember(groupInfo.membership), user: true, showRole: true) + ForEach(members) { member in + memberRow(member, user: false, showRole: member.wrapped.memberRole >= .owner) + } + } + } + } else { + let owners = members.filter { $0.wrapped.memberRole >= .owner } + List { + Section(header: Text("Owners").foregroundColor(theme.colors.secondary)) { + ForEach(owners) { member in + memberRow(member, user: false, showRole: false) + } + } + } + } + } + + @ViewBuilder private func memberRow(_ gMember: GMember, user: Bool, showRole: Bool) -> some View { + let member = gMember.wrapped + let nameText = Text(member.chatViewName) + .foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground) + let displayName = member.verified + ? (Text(Image(systemName: "checkmark.shield")) + textSpace) + .font(.caption).baselineOffset(2).kerning(-2) + .foregroundColor(theme.colors.secondary) + nameText + : nameText + let row = HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + displayName + .lineLimit(1) + if user { + Text("you") + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + } + Spacer() + if showRole { + Text(member.memberRole.text) + .foregroundColor(theme.colors.secondary) + } + } + if user { + row + } else { + NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: gMember, + scrollToItemId: Binding.constant(nil) + ) + .navigationBarHidden(false) + } label: { + row + } + } + } +} + +#Preview { + ChannelMembersView( + chat: Chat.sampleData, + groupInfo: GroupInfo.sampleData + ) +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift new file mode 100644 index 0000000000..1a4e384e24 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -0,0 +1,129 @@ +// +// ChannelRelaysView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 20.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChannelRelaysView: View { + @ObservedObject var chat: Chat + var groupInfo: GroupInfo + @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme + @State private var groupRelays: [GroupRelay] = [] + + var body: some View { + List { + relaysList() + } + .onAppear { + Task { + await chatModel.loadGroupMembers(groupInfo) + if groupInfo.isOwner { + groupRelays = await apiGetGroupRelays(groupInfo.groupId) + } + } + } + } + + @ViewBuilder private func relaysList() -> some View { + let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay } + if relayMembers.isEmpty { + Section { + Text("No chat relays") + .foregroundColor(theme.colors.secondary) + } + } else { + Section { + ForEach(relayMembers) { member in + NavigationLink { + GroupMemberInfoView( + groupInfo: groupInfo, + chat: chat, + groupMember: member, + scrollToItemId: Binding.constant(nil), + groupRelay: groupRelays.first(where: { $0.groupMemberId == member.wrapped.groupMemberId }) + ) + .navigationBarHidden(false) + } label: { + let statusText = groupInfo.isOwner + ? ownerRelayStatusText(member.wrapped) + : subscriberRelayStatusText(member.wrapped) + relayMemberRow(member.wrapped, statusText: statusText) + } + } + } footer: { + Text("Chat relays forward messages to channel subscribers.") + } + } + } + + private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + relayConnStatus(member).text + } + } + + private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if case .failed = member.activeConn?.connStatus { + "failed" + } else if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text + ?? relayConnStatus(member).text + } + } + + private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(statusText) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } +} + +func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { + switch member.activeConn?.connStatus { + case .ready: ("connected", .green) + case .deleted: ("deleted", .red) + case .failed: ("failed", .red) + default: ("connecting", .yellow) + } +} + +func hostFromRelayLink(_ link: String) -> String { + if let ft = parseSimpleXMarkdown(link) { + for f in ft { + if case let .simplexLink(_, _, _, smpHosts) = f.format, + let host = smpHosts.first { + return host + } + } + } + return link +} + +#Preview { + ChannelRelaysView(chat: Chat.sampleData, groupInfo: GroupInfo.sampleData) +} diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 96b5e2898a..c02f4dae36 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -5,12 +5,14 @@ // Created by JRoberts on 14.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat let SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20 +// Spec: spec/client/chat-view.md#GroupChatInfoView struct GroupChatInfoView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -88,22 +90,57 @@ struct GroupChatInfoView: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - Section { - if groupInfo.canAddMembers && groupInfo.businessChat == nil { - groupLinkButton() + if groupInfo.useRelays && groupInfo.membership.memberIncognito { + Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) { + HStack { + Text("Your random profile") + Spacer() + Text(groupInfo.membership.chatViewName) + .foregroundStyle(.indigo) + } } - if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { - memberSupportButton() + } + + if groupInfo.useRelays { + Section { + // TODO [relays] allow other owners to manage channel link (requires protocol changes to share link ownership) + if groupInfo.isOwner && groupLink != nil { + channelLinkButton() + } else if let link = groupInfo.groupProfile.publicGroup?.groupLink { + SimpleXLinkQRCode(uri: link) + Button { + showShareSheet(items: [simplexChatLink(link)]) + } label: { + Label("Share link", systemImage: "square.and.arrow.up") + } + } + if groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole >= .owner }) { + channelMembersButton() + } + } footer: { + if !groupInfo.isOwner && groupInfo.groupProfile.publicGroup?.groupLink != nil { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + .foregroundColor(theme.colors.secondary) + } } - if groupInfo.canModerate { - GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } else { + Section { + if groupInfo.canAddMembers && groupInfo.businessChat == nil { + groupLinkButton() + } + if groupInfo.businessChat == nil && groupInfo.membership.memberRole >= .moderator { + memberSupportButton() + } + if groupInfo.canModerate { + GroupReportsChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + if groupInfo.membership.memberActive + && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { + UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) + } + } header: { + Text("") } - if groupInfo.membership.memberActive - && (groupInfo.membership.memberRole < .moderator || groupInfo.membership.supportChat != nil) { - UserSupportChatNavLink(chat: chat, groupInfo: groupInfo, scrollToItemId: $scrollToItemId) - } - } header: { - Text("") } Section { @@ -113,22 +150,28 @@ struct GroupChatInfoView: View { if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) { addOrEditWelcomeMessage() } - GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + if !groupInfo.useRelays { + GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences) + } } footer: { - let label: LocalizedStringKey = ( - groupInfo.businessChat == nil - ? "Only group owners can change group preferences." - : "Only chat owners can change preferences." - ) - Text(label) - .foregroundColor(theme.colors.secondary) + if !groupInfo.useRelays { + let label: LocalizedStringKey = ( + groupInfo.businessChat == nil + ? "Only group owners can change group preferences." + : "Only chat owners can change preferences." + ) + Text(label) + .foregroundColor(theme.colors.secondary) + } } Section { - if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { - sendReceiptsOption() - } else { - sendReceiptsOptionDisabled() + if !groupInfo.useRelays { + if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { + sendReceiptsOption() + } else { + sendReceiptsOptionDisabled() + } } NavigationLink { ChatWallpaperEditorSheet(chat: chat) @@ -140,7 +183,7 @@ struct GroupChatInfoView: View { Text("Delete chat messages from your device.") } - if !groupInfo.nextConnectPrepared { + if !groupInfo.nextConnectPrepared && !groupInfo.useRelays { Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) { if groupInfo.canAddMembers { if (chat.chatInfo.incognito) { @@ -172,12 +215,18 @@ struct GroupChatInfoView: View { } Section { + if groupInfo.useRelays && (groupInfo.isOwner || members.contains(where: { $0.wrapped.memberRole == .relay })) { + channelRelaysButton() + } clearChatButton() if groupInfo.canDelete { deleteGroupButton() } if groupInfo.membership.memberCurrentOrPending { - leaveGroupButton() + if !groupInfo.useRelays || !groupInfo.isOwner + || members.contains(where: { $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }) { + leaveGroupButton() + } } } @@ -218,13 +267,15 @@ struct GroupChatInfoView: View { sendReceiptsUserDefault = currentUser.sendRcptsSmallGroups } sendReceipts = SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault) - do { - if let gLink = try apiGetGroupLink(groupInfo.groupId) { - groupLink = gLink - groupLinkMemberRole = gLink.acceptMemberRole + if !groupInfo.useRelays || groupInfo.isOwner { + do { + if let gLink = try apiGetGroupLink(groupInfo.groupId) { + groupLink = gLink + groupLinkMemberRole = gLink.acceptMemberRole + } + } catch let error { + logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } - } catch let error { - logger.error("GroupChatInfoView apiGetGroupLink: \(responseError(error))") } } } @@ -257,6 +308,14 @@ struct GroupChatInfoView: View { .lineLimit(4) .fixedSize(horizontal: false, vertical: true) } + if groupInfo.useRelays, + let count = groupInfo.groupSummary.publicMemberCount, + count > 0 { + Text(subscriberCountStr(count)) + .font(.subheadline) + .foregroundColor(theme.colors.secondary) + .padding(.bottom, 2) + } } .frame(maxWidth: .infinity, alignment: .center) } @@ -297,7 +356,9 @@ struct GroupChatInfoView: View { let buttonWidth = g.size.width / 4 HStack(alignment: .center, spacing: 8) { searchButton(width: buttonWidth) - if groupInfo.canAddMembers { + if groupInfo.useRelays && groupInfo.isOwner { + channelLinkActionButton(width: buttonWidth) + } else if !groupInfo.useRelays && groupInfo.canAddMembers { addMembersActionButton(width: buttonWidth) } if let nextNtfMode = chat.chatInfo.nextNtfMode { @@ -358,6 +419,23 @@ struct GroupChatInfoView: View { .disabled(!groupInfo.ready) } + private func channelLinkActionButton(width: CGFloat) -> some View { + ZStack { + InfoViewButton(image: "link", title: "link", width: width) { + groupLinkNavLinkActive = true + } + + NavigationLink(isActive: $groupLinkNavLinkActive) { + groupLinkDestinationView() + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() + } + .disabled(!groupInfo.ready) + } + private func addMembersButton() -> some View { let label: LocalizedStringKey = switch groupInfo.businessChat?.chatType { case .customer: "Add team members" @@ -453,7 +531,9 @@ struct GroupChatInfoView: View { } private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" @@ -543,19 +623,51 @@ struct GroupChatInfoView: View { } } + private func channelLinkButton() -> some View { + NavigationLink { + groupLinkDestinationView() + } label: { + Label("Channel link", systemImage: "link") + } + } + private func groupLinkDestinationView() -> some View { GroupLinkView( groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole, showTitle: false, - creatingGroup: false + creatingGroup: false, + isChannel: groupInfo.useRelays ) - .navigationBarTitle("Group link") + .navigationBarTitle(groupInfo.useRelays ? "Channel link" : "Group link") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(.large) } + private func channelMembersButton() -> some View { + let label: LocalizedStringKey = groupInfo.isOwner ? "Subscribers" : "Owners" + return NavigationLink { + ChannelMembersView(chat: chat, groupInfo: groupInfo) + .navigationTitle(label) + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label(label, systemImage: "person.2") + } + } + + private func channelRelaysButton() -> some View { + NavigationLink { + ChannelRelaysView(chat: chat, groupInfo: groupInfo) + .navigationTitle("Chat relays") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Chat relays", systemImage: "externaldrive.connected.to.line.below") + } + } + struct UserSupportChatNavLink: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme @@ -650,7 +762,7 @@ struct GroupChatInfoView: View { groupProfile: groupInfo.groupProfile ) } label: { - Label("Edit group profile", systemImage: "pencil") + Label(groupInfo.useRelays ? "Edit channel profile" : "Edit group profile", systemImage: "pencil") } } @@ -672,7 +784,7 @@ struct GroupChatInfoView: View { } @ViewBuilder private func deleteGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group" : "Delete chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat" Button(role: .destructive) { alert = .deleteGroupAlert } label: { @@ -691,7 +803,7 @@ struct GroupChatInfoView: View { } private func leaveGroupButton() -> some View { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" + let label: LocalizedStringKey = groupInfo.useRelays ? "Leave channel" : groupInfo.businessChat == nil ? "Leave group" : "Leave chat" return Button(role: .destructive) { alert = .leaveGroupAlert } label: { @@ -702,7 +814,7 @@ struct GroupChatInfoView: View { // TODO reuse this and clearChatAlert with ChatInfoView private func deleteGroupAlert() -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -739,9 +851,11 @@ struct GroupChatInfoView: View { } private func leaveGroupAlert() -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) @@ -792,9 +906,13 @@ struct GroupChatInfoView: View { func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) { showAlert( - NSLocalizedString("Remove member?", comment: "alert title"), + groupInfo.useRelays + ? NSLocalizedString("Remove subscriber?", comment: "alert title") + : NSLocalizedString("Remove member?", comment: "alert title"), message: - groupInfo.businessChat == nil + groupInfo.useRelays + ? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message") + : groupInfo.businessChat == nil ? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message") : NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"), actions: {[ @@ -836,10 +954,18 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool } func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text { - groupInfo.businessChat == nil ? ( - groupInfo.membership.memberCurrent ? Text("Group will be deleted for all members - this cannot be undone!") : Text("Group will be deleted for you - this cannot be undone!") + groupInfo.useRelays ? ( + groupInfo.membership.memberCurrent + ? Text("Channel will be deleted for all subscribers - this cannot be undone!") + : Text("Channel will be deleted for you - this cannot be undone!") + ) : groupInfo.businessChat == nil ? ( + groupInfo.membership.memberCurrent + ? Text("Group will be deleted for all members - this cannot be undone!") + : Text("Group will be deleted for you - this cannot be undone!") ) : ( - groupInfo.membership.memberCurrent ? Text("Chat will be deleted for all members - this cannot be undone!") : Text("Chat will be deleted for you - this cannot be undone!") + groupInfo.membership.memberCurrent + ? Text("Chat will be deleted for all members - this cannot be undone!") + : Text("Chat will be deleted for you - this cannot be undone!") ) } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift index bc1ac4ab65..56ee370402 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupLinkView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 15.10.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -16,6 +17,7 @@ struct GroupLinkView: View { @Binding var groupLinkMemberRole: GroupMemberRole var showTitle: Bool = false var creatingGroup: Bool = false + var isChannel: Bool = false var linkCreatedCb: (() -> Void)? = nil @State private var showShortLink = true @State private var creatingLink = false @@ -59,12 +61,16 @@ struct GroupLinkView: View { List { Group { if showTitle { - Text("Group link") + Text(isChannel ? "Channel link" : "Group link") .font(.largeTitle) .bold() .fixedSize(horizontal: false, vertical: true) } - Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + if isChannel { + Text("You can share a link or a QR code - anybody will be able to join the channel.") + } else { + Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") + } } .listRowBackground(Color.clear) .listRowSeparator(.hidden) @@ -72,15 +78,17 @@ struct GroupLinkView: View { Section { if let groupLink = groupLink { - Picker("Initial role", selection: $groupLinkMemberRole) { - ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in - Text(role.text) + if !isChannel { + Picker("Initial role", selection: $groupLinkMemberRole) { + ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in + Text(role.text) + } } + .frame(height: 36) } - .frame(height: 36) SimpleXCreatedLinkQRCode(link: groupLink.connLinkContact, short: $showShortLink) .id("simplex-qrcode-view-for-\(groupLink.connLinkContact.simplexChatUri(short: showShortLink))") - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { Button { upgradeAndShareLinkAlert() } label: { @@ -88,7 +96,7 @@ struct GroupLinkView: View { } } Button { - if groupLink.shouldBeUpgraded { + if !isChannel && groupLink.shouldBeUpgraded { upgradeAndShareLinkAlert(groupLink: groupLink) } else { groupLink.shareAddress(short: showShortLink) @@ -97,7 +105,7 @@ struct GroupLinkView: View { Label("Share link", systemImage: "square.and.arrow.up") } - if !creatingGroup { + if !creatingGroup && !isChannel { Button(role: .destructive) { alert = .deleteLink } label: { Label("Delete link", systemImage: "trash") } @@ -109,7 +117,7 @@ struct GroupLinkView: View { .disabled(creatingLink) } } header: { - if let groupLink, groupLink.connLinkContact.connShortLink != nil { + if !isChannel, let groupLink, groupLink.connLinkContact.connShortLink != nil { ToggleShortLinkHeader(text: Text(""), link: groupLink.connLinkContact, short: $showShortLink) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 207c2170a3..af7054db01 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -5,6 +5,7 @@ // Created by JRoberts on 25.07.2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-view.md import SwiftUI import SimpleXChat @@ -19,6 +20,7 @@ struct GroupMemberInfoView: View { @Binding var scrollToItemId: ChatItem.ID? var navigation: Bool = false var openedFromSupportChat: Bool = false + var groupRelay: GroupRelay? = nil @State private var connectionStats: ConnectionStats? = nil @State private var connectionCode: String? = nil @State private var connectionLoaded: Bool = false @@ -31,6 +33,26 @@ struct GroupMemberInfoView: View { @State private var justOpened = true @State private var progressIndicator = false + private var channelMemberSectionHeader: LocalizedStringKey { + if groupInfo.useRelays { + switch groupMember.wrapped.memberRole { + case .relay: "Relay" + case .owner: "Owner" + default: "Subscriber" + } + } else { + "Member" + } + } + + private var relaySectionFooter: LocalizedStringKey { + if groupInfo.isOwner { + "Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." + } else { + "You connected to the channel via this relay link." + } + } + enum GroupMemberInfoViewAlert: Identifiable { case blockMemberAlert(mem: GroupMember) case unblockMemberAlert(mem: GroupMember) @@ -88,13 +110,15 @@ struct GroupMemberInfoView: View { .listRowSeparator(.hidden) .padding(.bottom, 18) - infoActionButtons(member) - .padding(.horizontal) - .frame(maxWidth: .infinity) - .frame(height: infoViewActionButtonHeight) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + if !groupInfo.useRelays { + infoActionButtons(member) + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: infoViewActionButtonHeight) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + } if connectionLoaded { @@ -102,10 +126,14 @@ struct GroupMemberInfoView: View { Section { if !openedFromSupportChat && groupInfo.membership.memberRole >= .moderator + && member.memberRole != .relay && (member.memberRole < .moderator || member.supportChat != nil) { MemberInfoSupportChatNavLink(groupInfo: groupInfo, member: groupMember, scrollToItemId: $scrollToItemId) } - if let code = connectionCode { verifyCodeButton(code) } + if let code = connectionCode, + !(groupInfo.useRelays && member.memberRole == .relay) { + verifyCodeButton(code) + } if let connStats = connectionStats, connStats.ratchetSyncAllowed { synchronizeConnectionButton() @@ -140,11 +168,11 @@ struct GroupMemberInfoView: View { } } - Section(header: Text("Member").foregroundColor(theme.colors.secondary)) { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Group" : "Chat" + Section { + let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat" infoRow(label, groupInfo.displayName) - if let roles = member.canChangeRoleTo(groupInfo: groupInfo) { + if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) { Picker("Change role", selection: $newRole) { ForEach(roles) { role in Text(role.text) @@ -154,6 +182,23 @@ struct GroupMemberInfoView: View { } else { infoRow("Role", member.memberRole.text) } + if let link = member.relayLink { + infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link))) + } + if let address = groupRelay?.userChatRelay.address { + infoRow("Relay address", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(address))) + Button { + showShareSheet(items: [simplexChatLink(address)]) + } label: { + Label("Share relay address", systemImage: "square.and.arrow.up") + } + } + } header: { + Text(channelMemberSectionHeader).foregroundColor(theme.colors.secondary) + } footer: { + if groupInfo.useRelays && member.memberRole == .relay { + Text(relaySectionFooter).foregroundColor(theme.colors.secondary) + } } if let connStats = connectionStats { @@ -188,9 +233,22 @@ struct GroupMemberInfoView: View { } } + if let connFailedErr = member.activeConn?.connFailedErr { + Section { + Text(connFailedErr) + .foregroundColor(theme.colors.secondary) + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Connection failed") + } + } + } + if groupInfo.membership.memberRole >= .moderator { adminDestructiveSection(member) - } else { + } else if !groupInfo.useRelays { nonAdminBlockSection(member) } @@ -202,16 +260,18 @@ struct GroupMemberInfoView: View { let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel) infoRow("Connection", connLevelDesc) } - Button ("Debug delivery") { - Task { - do { - if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { - await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + if !groupInfo.useRelays || member.memberRole == .relay { + Button ("Debug delivery") { + Task { + do { + if let info = try await apiGroupMemberQueueInfo(groupInfo.apiId, member.groupMemberId) { + await MainActor.run { alert = .queueInfo(info: queueInfoText(info)) } + } + } catch let e { + logger.error("apiContactQueueInfo error: \(responseError(e))") + let a = getErrorAlert(e, "Error") + await MainActor.run { alert = .error(title: a.title, error: a.message) } } - } catch let e { - logger.error("apiContactQueueInfo error: \(responseError(e))") - let a = getErrorAlert(e, "Error") - await MainActor.run { alert = .error(title: a.title, error: a.message) } } } } @@ -575,7 +635,9 @@ struct GroupMemberInfoView: View { blockForAllButton(mem) } } - if canRemove { + // TODO [relays] removing relay should also remove its link from group link data; + // TODO - removing last relay should be prohibited or show warning + if canRemove && mem.memberRole != .relay { if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft { deleteMemberMessagesButton(mem) } else { @@ -637,7 +699,7 @@ struct GroupMemberInfoView: View { Button(role: .destructive) { showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss) } label: { - Label("Remove member", systemImage: "trash") + Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash") .foregroundColor(.red) } } @@ -817,7 +879,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Block member for all?"), + title: Text(gInfo.useRelays ? "Block subscriber for all?" : "Block member for all?"), message: Text("All new messages from \(mem.chatViewName) will be hidden!"), primaryButton: .destructive(Text("Block for all")) { blockMemberForAll(gInfo, mem, true) @@ -828,7 +890,7 @@ func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert { Alert( - title: Text("Unblock member for all?"), + title: Text(gInfo.useRelays ? "Unblock subscriber for all?" : "Unblock member for all?"), message: Text("Messages from \(mem.chatViewName) will be shown!"), primaryButton: .default(Text("Unblock for all")) { blockMemberForAll(gInfo, mem, false) diff --git a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift index 69587c0152..24a52b4b60 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupProfileView.swift @@ -42,7 +42,7 @@ struct GroupProfileView: View { Section { HStack { - TextField("Group display name", text: $groupProfile.displayName) + TextField(groupInfo.useRelays ? "Channel display name" : "Group display name", text: $groupProfile.displayName) .focused($focusDisplayName) if !validNewProfileName { Button { @@ -54,7 +54,7 @@ struct GroupProfileView: View { } let fullName = groupInfo.groupProfile.fullName if fullName != "" && fullName != groupProfile.displayName { - TextField("Group full name (optional)", text: $groupProfile.fullName) + TextField(groupInfo.useRelays ? "Channel full name (optional)" : "Group full name (optional)", text: $groupProfile.fullName) } HStack { TextField("Short description", text: $shortDescr) @@ -67,7 +67,7 @@ struct GroupProfileView: View { } } } footer: { - Text("Group profile is stored on members' devices, not on the servers.") + Text(groupInfo.useRelays ? "Channel profile is stored on subscribers' devices and on the chat relays." : "Group profile is stored on members' devices, not on the servers.") } Section { @@ -80,11 +80,11 @@ struct GroupProfileView: View { currentProfileHash == groupProfile.hashValue && (groupInfo.groupProfile.shortDescr ?? "") == shortDescr.trimmingCharacters(in: .whitespaces) ) - Button("Save group profile", action: saveProfile) + Button(groupInfo.useRelays ? "Save channel profile" : "Save group profile", action: saveProfile) .disabled(!canUpdateProfile) } } - .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { + .confirmationDialog(groupInfo.useRelays ? "Channel image" : "Group image", isPresented: $showChooseSource, titleVisibility: .visible) { Button("Take picture") { showTakePhoto = true } @@ -130,9 +130,15 @@ struct GroupProfileView: View { .onDisappear { if canUpdateProfile { showAlert( - title: NSLocalizedString("Save group profile?", comment: "alert title"), - message: NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), - buttonTitle: NSLocalizedString("Save (and notify members)", comment: "alert button"), + title: groupInfo.useRelays + ? NSLocalizedString("Save channel profile?", comment: "alert title") + : NSLocalizedString("Save group profile?", comment: "alert title"), + message: groupInfo.useRelays + ? NSLocalizedString("Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers.", comment: "alert message") + : NSLocalizedString("Group profile was changed. If you save it, the updated profile will be sent to group members.", comment: "alert message"), + buttonTitle: groupInfo.useRelays + ? NSLocalizedString("Save (and notify subscribers)", comment: "alert button") + : NSLocalizedString("Save (and notify members)", comment: "alert button"), buttonAction: saveProfile, cancelButton: true ) @@ -142,14 +148,14 @@ struct GroupProfileView: View { switch a { case let .saveError(err): return Alert( - title: Text("Error saving group profile"), + title: Text(groupInfo.useRelays ? "Error saving channel profile" : "Error saving group profile"), message: Text(err) ) case let .invalidName(name): return createInvalidNameAlert(name, $groupProfile.displayName) } } - .navigationBarTitle("Group profile") + .navigationBarTitle(groupInfo.useRelays ? "Channel profile" : "Group profile") .modifier(ThemedBackground(grouped: true)) .navigationBarTitleDisplayMode(focusDisplayName ? .inline : .large) } diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 75a6840c4e..3dc27c08f6 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -196,7 +196,9 @@ struct MemberSupportView: View { } private func memberStatus(_ member: GroupMember) -> LocalizedStringKey { - if member.activeConn?.connDisabled ?? false { + if case .failed = member.activeConn?.connStatus { + return "failed" + } else if member.activeConn?.connDisabled ?? false { return "disabled" } else if member.activeConn?.connInactive ?? false { return "inactive" diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 4937bca20e..b4590fc124 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -40,6 +40,7 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes { dynamicSizes[font] ?? defaultDynamicSizes } +// Spec: spec/client/chat-list.md#ChatListNavLink struct ChatListNavLink: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -90,6 +91,7 @@ struct ChatListNavLink: View { .actionSheet(item: $actionSheet) { $0.actionSheet } } + // Spec: spec/client/chat-list.md#contactNavLink private func contactNavLink(_ contact: Contact) -> some View { Group { if contact.isContactCard { @@ -211,6 +213,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#groupNavLink @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { switch (groupInfo.membership.memberStatus) { case .memInvited: @@ -241,7 +244,7 @@ struct ChatListNavLink: View { } .swipeActions(edge: .trailing) { tagChatButton(chat) - if (groupInfo.membership.memberCurrentOrPending) { + if groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) { leaveGroupChatButton(groupInfo) } if groupInfo.canDelete { @@ -266,7 +269,7 @@ struct ChatListNavLink: View { let showReportsButton = chat.chatStats.reportsCount > 0 && groupInfo.membership.memberRole >= .moderator let showClearButton = !chat.chatItems.isEmpty let showDeleteGroup = groupInfo.canDelete - let showLeaveGroup = groupInfo.membership.memberCurrentOrPending + let showLeaveGroup = groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner) let totalNumberOfButtons = 1 + (showReportsButton ? 1 : 0) + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0) if showClearButton && totalNumberOfButtons <= 3 { @@ -295,6 +298,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#noteFolderNavLink private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { NavLinkPlain( chatId: chat.chatInfo.id, @@ -325,6 +329,7 @@ struct ChatListNavLink: View { .tint(chat.chatInfo.incognito ? .indigo : theme.colors.primary) } + // Spec: spec/client/chat-list.md#markReadButton @ViewBuilder private func markReadButton() -> some View { if chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat { Button { @@ -344,6 +349,7 @@ struct ChatListNavLink: View { } + // Spec: spec/client/chat-list.md#toggleFavoriteButton @ViewBuilder private func toggleFavoriteButton() -> some View { if chat.chatInfo.chatSettings?.favorite == true { Button { @@ -362,6 +368,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#toggleNtfsButton @ViewBuilder private func toggleNtfsButton(chat: Chat) -> some View { if let nextMode = chat.chatInfo.nextNtfMode { Button { @@ -382,6 +389,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#clearChatButton private func clearChatButton() -> some View { Button { AlertManager.shared.showAlert(clearChatAlert()) @@ -483,6 +491,7 @@ struct ChatListNavLink: View { .tint(.red) } + // Spec: spec/client/chat-list.md#contactRequestNavLink private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { ContactRequestView(contactRequest: contactRequest, chat: chat) .frameCompat(height: dynamicRowHeight) @@ -517,6 +526,7 @@ struct ChatListNavLink: View { } } + // Spec: spec/client/chat-list.md#contactConnectionNavLink private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { ContactConnectionView(chat: chat) .frameCompat(height: dynamicRowHeight) @@ -555,7 +565,7 @@ struct ChatListNavLink: View { } private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" + let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel?" : groupInfo.businessChat == nil ? "Delete group?" : "Delete chat?" return Alert( title: Text(label), message: deleteGroupAlertMessage(groupInfo), @@ -610,9 +620,11 @@ struct ChatListNavLink: View { } private func leaveGroupAlert(_ groupInfo: GroupInfo) -> Alert { - let titleLabel: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" + let titleLabel: LocalizedStringKey = groupInfo.useRelays ? "Leave channel?" : groupInfo.businessChat == nil ? "Leave group?" : "Leave chat?" let messageLabel: LocalizedStringKey = ( - groupInfo.businessChat == nil + groupInfo.useRelays + ? "You will stop receiving messages from this channel. Chat history will be preserved." + : groupInfo.businessChat == nil ? "You will stop receiving messages from this group. Chat history will be preserved." : "You will stop receiving messages from this chat. Chat history will be preserved." ) diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index efaba518a9..3050b0d4cd 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -5,6 +5,7 @@ // Created by Evgeny Poberezkin on 27/01/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/client/chat-list.md import SwiftUI import SimpleXChat @@ -31,6 +32,7 @@ enum UserPickerSheet: Identifiable { } } +// Spec: spec/client/chat-list.md#PresetTag enum PresetTag: Int, Identifiable, CaseIterable, Equatable { case groupReports = 0 case favorites = 1 @@ -46,6 +48,7 @@ enum PresetTag: Int, Identifiable, CaseIterable, Equatable { } } +// Spec: spec/client/chat-list.md#ActiveFilter enum ActiveFilter: Identifiable, Equatable { case presetTag(PresetTag) case userTag(ChatTag) @@ -61,13 +64,14 @@ enum ActiveFilter: Identifiable, Equatable { } class SaveableSettings: ObservableObject { - @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: []) + @Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [], serverWarnings: []) } struct ServerSettings { public var currUserServers: [UserOperatorServers] public var userServers: [UserOperatorServers] public var serverErrors: [UserServersError] + public var serverWarnings: [UserServersWarning] } struct UserPickerSheetView: View { @@ -135,6 +139,7 @@ struct UserPickerSheetView: View { } } +// Spec: spec/client/chat-list.md#ChatListView struct ChatListView: View { @EnvironmentObject var chatModel: ChatModel @StateObject private var connectProgressManager = ConnectProgressManager.shared @@ -160,6 +165,7 @@ struct ChatListView: View { @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial + // Spec: spec/client/chat-list.md#body var body: some View { if #available(iOS 16.0, *) { viewBody.scrollDismissesKeyboard(.immediately) @@ -445,6 +451,7 @@ struct ChatListView: View { } + // Spec: spec/client/chat-list.md#unreadBadge private func unreadBadge(size: CGFloat = 18) -> some View { Circle() .frame(width: size, height: size) @@ -464,11 +471,13 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#stopAudioPlayer func stopAudioPlayer() { VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() } VoiceItemState.smallView = [:] } + // Spec: spec/client/chat-list.md#filteredChats private func filteredChats() -> [Chat] { if let linkChatId = searchChatFilteredBySimplexLink { return chatModel.chats.filter { $0.id == linkChatId } @@ -511,6 +520,7 @@ struct ChatListView: View { } } + // Spec: spec/client/chat-list.md#searchString func searchString() -> String { searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase } @@ -574,6 +584,7 @@ struct SubsStatusIndicator: View { } } +// Spec: spec/client/chat-list.md#ChatListSearchBar struct ChatListSearchBar: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -875,6 +886,7 @@ struct TagsView: View { } } + // Spec: spec/client/chat-list.md#setActiveFilter private func setActiveFilter(filter: ActiveFilter) { if filter != chatTagsModel.activeFilter { chatTagsModel.activeFilter = filter @@ -895,6 +907,7 @@ func chatStoppedIcon() -> some View { } } +// Spec: spec/client/chat-list.md#presetTagMatchesChat func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool { switch tag { case .groupReports: diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index be2c456802..3524ceff18 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -9,6 +9,7 @@ import SwiftUI import SimpleXChat +// Spec: spec/client/chat-list.md#ChatPreviewView struct ChatPreviewView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme @@ -295,7 +296,7 @@ struct ChatPreviewView: View { } func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) { - let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() + let itemText = cItem.meta.itemDeleted == nil ? cItem.text(isChannel: chat.chatInfo.isChannel) : markedDeletedText() let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil let r = messageText(itemText, itemFormattedText, sender: cItem.meta.showGroupAsSender ? nil : cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix()) return (Text(AttributedString(r.string)), r.hasSecrets) diff --git a/apps/ios/Shared/Views/ChatList/TagListView.swift b/apps/ios/Shared/Views/ChatList/TagListView.swift index 79d122eabf..f484ce8938 100644 --- a/apps/ios/Shared/Views/ChatList/TagListView.swift +++ b/apps/ios/Shared/Views/ChatList/TagListView.swift @@ -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 diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index b1cd4015c6..63d28e3624 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -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 diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 441a164f8a..dbc25e536f 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift index 02a1b87826..f7f253a617 100644 --- a/apps/ios/Shared/Views/Database/DatabaseErrorView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseErrorView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 04/09/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -78,12 +79,25 @@ struct DatabaseErrorView: View { fileNameText(dbFile) } case let .downgrade(downMigrations): + let warnings = downMigrationWarnings(downMigrations).reversed() titleText("Database downgrade") + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .resizable() + .frame(width: 40, height: 36) + .foregroundColor(.red) Text("Warning: you may lose some data!") .bold() .padding(.horizontal, 25) .multilineTextAlignment(.center) - + if !warnings.isEmpty { + ForEach(warnings, id: \.self) { warning in + Text(warning) + .bold() + .multilineTextAlignment(.center) + .padding(.horizontal, 25) + } + } migrationsText(downMigrations) Spacer() VStack(spacing: 10) { diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index a7e61b3105..d5d70abaea 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift index 79c0a42ae0..76bdc898d5 100644 --- a/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift +++ b/apps/ios/Shared/Views/Database/MigrateToAppGroupView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 86a5dc7aaa..2ef928c7c5 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -100,6 +100,7 @@ class OpenChatAlertViewController: UIViewController { private let profileName: String private let profileFullName: String private let profileImage: UIView + private let subtitle: String? private let cancelTitle: String private let confirmTitle: String private let onCancel: () -> Void @@ -109,6 +110,7 @@ class OpenChatAlertViewController: UIViewController { profileName: String, profileFullName: String, profileImage: UIView, + subtitle: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String = "Open", onCancel: @escaping () -> Void, @@ -117,6 +119,7 @@ class OpenChatAlertViewController: UIViewController { self.profileName = profileName self.profileFullName = profileFullName self.profileImage = profileImage + self.subtitle = subtitle self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.onCancel = onCancel @@ -171,6 +174,18 @@ class OpenChatAlertViewController: UIViewController { profileViews.append(fullNameLabel) } + // Subtitle label (e.g. subscriber count) + if let subtitle { + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = UIFont.preferredFont(forTextStyle: .footnote) + subtitleLabel.textColor = .secondaryLabel + subtitleLabel.numberOfLines = 1 + subtitleLabel.textAlignment = .center + subtitleLabel.translatesAutoresizingMaskIntoConstraints = false + profileViews.append(subtitleLabel) + } + // Horizontal stack for image + name let stack = UIStackView(arrangedSubviews: profileViews) stack.axis = .vertical @@ -291,6 +306,7 @@ func showOpenChatAlert( profileFullName: String, profileImage: Content, theme: AppTheme, + subtitle: String? = nil, cancelTitle: String = "Cancel", confirmTitle: String = "Open", onCancel: @escaping () -> Void = {}, @@ -306,6 +322,7 @@ func showOpenChatAlert( profileName: profileName, profileFullName: profileFullName, profileImage: hostedView, + subtitle: subtitle, cancelTitle: cancelTitle, confirmTitle: confirmTitle, onCancel: onCancel, diff --git a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift index 33acf22ebe..71316cc5aa 100644 --- a/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift +++ b/apps/ios/Shared/Views/Helpers/VideoPlayerView.swift @@ -29,6 +29,7 @@ struct VideoPlayerView: UIViewRepresentable { func makeUIView(context: UIViewRepresentableContext) -> UIView { let controller = AVPlayerViewController() controller.showsPlaybackControls = showControls + controller.videoGravity = .resizeAspectFill if #available(iOS 16.0, *) { controller.speeds = [] } diff --git a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift index 85ef85c611..902a3f95d7 100644 --- a/apps/ios/Shared/Views/Helpers/ViewModifiers.swift +++ b/apps/ios/Shared/Views/Helpers/ViewModifiers.swift @@ -17,6 +17,15 @@ extension View { self } } + + @inline(__always) + @ViewBuilder func compactSectionSpacing() -> some View { + if #available(iOS 17, *) { + self.listSectionSpacing(.compact) + } else { + self + } + } } extension Notification.Name { diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index c21ff9be8b..36608c58d6 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -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 diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift index 4a6f8e7549..6df31b4d59 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeEntry.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 10/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift index ca30fa5ce8..046a3fd1fc 100644 --- a/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/PasscodeView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 11/04/2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI diff --git a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift index 7ec3ee1a42..995b9f5b0d 100644 --- a/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift +++ b/apps/ios/Shared/Views/LocalAuth/SetAppPasscodeView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift index 0af8fa7ad8..2ff376701c 100644 --- a/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateFromDevice.swift @@ -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 diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 93fe19cf33..cb3832b727 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -5,6 +5,7 @@ // Created by Avently on 23.02.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/database.md import SwiftUI import SimpleXChat @@ -373,10 +374,12 @@ struct MigrateToDevice: View { "Upgrade and open chat", "", .yesUp) - case .downgrade: + case let .downgrade(downMigrations): ("Database downgrade", "Downgrade and open chat", - NSLocalizedString("Warning: you may lose some data!", comment: ""), + ([NSLocalizedString("Warning: you may lose some data!", comment: "")] + + downMigrationWarnings(downMigrations).reversed()) + .joined(separator: "\n"), .yesUpDown) case let .migrationError(mtrError): ("Incompatible database version", diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift new file mode 100644 index 0000000000..098cccef1b --- /dev/null +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -0,0 +1,473 @@ +// +// AddChannelView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct AddChannelView: View { + @EnvironmentObject var m: ChatModel + @EnvironmentObject var theme: AppTheme + @StateObject private var channelRelaysModel = ChannelRelaysModel.shared + @StateObject private var ss = SaveableSettings() + @State private var profile = GroupProfile(displayName: "", fullName: "") + @FocusState private var focusDisplayName: Bool + @State private var showChooseSource = false + @State private var showImagePicker = false + @State private var showTakePhoto = false + @State private var chosenImage: UIImage? = nil + @State private var hasRelays = true + @State private var groupInfo: GroupInfo? = nil + @State private var groupLink: GroupLink? = nil + @State private var groupRelays: [GroupRelay] = [] + @State private var creationInProgress = false + @State private var showLinkStep = false + @State private var relayListExpanded = false + + var body: some View { + Group { + if showLinkStep, let gInfo = groupInfo { + linkStepView(gInfo) + } else if let gInfo = groupInfo { + progressStepView(gInfo) + } else { + profileStepView() + } + } + } + + // MARK: - Step 1: Profile + + private func profileStepView() -> some View { + List { + Group { + ZStack(alignment: .center) { + ZStack(alignment: .topTrailing) { + ProfileImage(imageStr: profile.image, size: 128) + if profile.image != nil { + Button { + profile.image = nil + } label: { + Image(systemName: "multiply") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 12) + } + } + } + editImageButton { showChooseSource = true } + .buttonStyle(BorderlessButtonStyle()) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + Section { + channelNameTextField() + NavigationLink { + NetworkAndServers() + .navigationTitle("Network & servers") + .modifier(ThemedBackground(grouped: true)) + .environmentObject(ss) + } label: { + let color: Color = hasRelays ? .accentColor : .orange + settingsRow("externaldrive.connected.to.line.below", color: color) { + Text("Configure relays").foregroundColor(color) + } + } + let canCreate = canCreateProfile() && hasRelays && !creationInProgress + Button(action: createChannel) { + settingsRow("checkmark", color: canCreate ? theme.colors.primary : theme.colors.secondary) { Text("Create public channel") } + } + .disabled(!canCreate) + } footer: { + if !hasRelays { + ServersWarningView(warnStr: NSLocalizedString("Enable at least one chat relay in Network & Servers.", comment: "channel creation warning")) + } else { + let name = ChatModel.shared.currentUser?.displayName ?? "" + Text("Your profile **\(name)** will be shared with channel relays and subscribers.\nRelays can access channel messages.") + .foregroundColor(theme.colors.secondary) + } + } + .compactSectionSpacing() + } + .onAppear { + Task { hasRelays = await checkHasRelays() } + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + focusDisplayName = true + } + } + .confirmationDialog("Channel image", isPresented: $showChooseSource, titleVisibility: .visible) { + Button("Take picture") { showTakePhoto = true } + Button("Choose from library") { showImagePicker = true } + } + .fullScreenCover(isPresented: $showTakePhoto) { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + CameraImagePicker(image: $chosenImage) + } + } + .sheet(isPresented: $showImagePicker) { + LibraryImagePicker(image: $chosenImage) { _ in + await MainActor.run { showImagePicker = false } + } + } + .onChange(of: chosenImage) { image in + Task { + let resized: String? = if let image { + await resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) + } else { + nil + } + await MainActor.run { profile.image = resized } + } + } + .modifier(ThemedBackground(grouped: true)) + } + + private func channelNameTextField() -> some View { + ZStack(alignment: .leading) { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + if name != mkValidName(name) { + Button { + showInvalidChannelNameAlert() + } label: { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } else { + Image(systemName: "pencil").foregroundColor(theme.colors.secondary) + } + TextField("Enter channel name…", text: $profile.displayName) + .padding(.leading, 36) + .focused($focusDisplayName) + .submitLabel(.continue) + .onSubmit { + if canCreateProfile() && hasRelays { createChannel() } + } + } + } + + private func canCreateProfile() -> Bool { + let name = profile.displayName.trimmingCharacters(in: .whitespaces) + return name != "" && validDisplayName(name) + } + + private func createChannel() { + focusDisplayName = false + profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces) + profile.groupPreferences = GroupPreferences(history: GroupPreference(enable: .on)) + creationInProgress = true + Task { + do { + let enabledRelays = try await chooseRandomRelays() + let relayIds = enabledRelays.compactMap { $0.chatRelayId } + guard !relayIds.isEmpty else { + await MainActor.run { + creationInProgress = false + hasRelays = false + } + return + } + guard let (gInfo, gLink, gRelays) = try await apiNewPublicGroup( + incognito: false, relayIds: relayIds, groupProfile: profile + ) else { + await MainActor.run { creationInProgress = false } + return + } + await MainActor.run { + m.updateGroup(gInfo) + m.creatingChannelId = gInfo.id + groupInfo = gInfo + groupLink = gLink + groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } + channelRelaysModel.set(groupId: gInfo.groupId, groupRelays: gRelays) + creationInProgress = false + } + } catch { + await MainActor.run { + creationInProgress = false + showAlert( + NSLocalizedString("Error creating channel", comment: "alert title"), + message: responseError(error) + ) + } + } + } + } + + private let maxRelays = 3 + + private func chooseRandomRelays() async throws -> [UserChatRelay] { + let servers = try await getUserServers() + // Operator relays are grouped per operator; custom relays (nil operator) + // are treated independently to maximize trust distribution. + var operatorGroups: [[UserChatRelay]] = [] + var customRelays: [UserChatRelay] = [] + for op in servers { + let relays = op.chatRelays.filter { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + guard !relays.isEmpty else { continue } + if op.operator != nil { + operatorGroups.append(relays.shuffled()) + } else { + customRelays = relays.shuffled() + } + } + var selected: [UserChatRelay] = [] + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if let relay = customRelays.first { + selected.append(relay) + customRelays.removeFirst() + if selected.count >= maxRelays { return selected } + } + // Round-robin across shuffled groups to distribute relays across operators. + var groups = operatorGroups + customRelays.map { [$0] } + groups.shuffle() + let maxDepth = groups.map(\.count).max() ?? 0 + for depth in 0..= maxRelays { return selected } + } + } + } + return selected + } + + private func checkHasRelays() async -> Bool { + guard let servers = try? await getUserServers() else { return false } + return servers.contains { op in + op.chatRelays.contains { $0.enabled && !$0.deleted && $0.chatRelayId != nil } + } + } + + // MARK: - Step 2: Progress + + private func progressStepView(_ gInfo: GroupInfo) -> some View { + let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + let total = groupRelays.count + return List { + Group { + ProfileImage(imageStr: gInfo.groupProfile.image, size: 128) + .frame(maxWidth: .infinity, alignment: .center) + + Text(gInfo.groupProfile.displayName) + .font(.headline) + .frame(maxWidth: .infinity, alignment: .center) + } + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) + + Section { + Button { + withAnimation { relayListExpanded.toggle() } + } label: { + HStack(spacing: 8) { + if activeCount + failedCount < total { + RelayProgressIndicator(active: activeCount, total: total) + } + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + } + Spacer() + Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down") + .foregroundColor(theme.colors.secondary) + } + } + .foregroundColor(theme.colors.onBackground) + + if relayListExpanded { + ForEach(groupRelays) { relay in + let failed = relayMemberConnFailed(relay) + if let err = failed { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + relayRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + relayRow(relay, connFailed: false) + } + } + } + } + .compactSectionSpacing() + + Section { + Button("Channel link") { + if activeCount >= total { + showLinkStep = true + } else if activeCount > 0 { + let actions: [UIAlertAction] = if activeCount + failedCount < total { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ] + } else { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + cancelAlertAction + ] + } + showAlert( + NSLocalizedString("Not all relays connected", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total), + actions: { actions } + ) + } + } + .disabled(activeCount == 0) + } + } + .navigationTitle("Creating channel") + .navigationBarBackButtonHidden(true) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Cancel") { cancelChannelCreation(gInfo) } + } + } + .onChange(of: channelRelaysModel.groupRelays) { relays in + guard channelRelaysModel.groupId == gInfo.groupId else { return } + groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } + if relays.allSatisfy({ $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }) { + showLinkStep = true + channelRelaysModel.reset() + } + } + } + + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + + private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + + // MARK: - Step 3: Link + + private func linkStepView(_ gInfo: GroupInfo) -> some View { + GroupLinkView( + groupId: gInfo.groupId, + groupLink: $groupLink, + groupLinkMemberRole: Binding.constant(.observer), // TODO [relays] starting role should be communicated in protocol from owner to relays + showTitle: false, + creatingGroup: true, + isChannel: true + ) { + m.creatingChannelId = nil + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + dismissAllSheets(animated: true) { + ItemsModel.shared.loadOpenChat(gInfo.id) + } + } + } + .navigationBarTitle("Channel link") + } + + private func cancelChannelCreation(_ gInfo: GroupInfo) { + m.creatingChannelId = nil + channelRelaysModel.reset() + dismissAllSheets(animated: true) + Task { + do { + try await apiDeleteChat(type: .group, id: gInfo.apiId) + await MainActor.run { m.removeChat(gInfo.id) } + } catch { + logger.error("cancelChannelCreation error: \(responseError(error))") + } + } + } + + // MARK: - Helpers + + private func showInvalidChannelNameAlert() { + let validName = mkValidName(profile.displayName) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + profile.displayName = validName + }, + cancelAlertAction + ]} + ) + } + } + +} + +func relayDisplayName(_ relay: GroupRelay) -> String { + if !relay.userChatRelay.displayName.isEmpty { return relay.userChatRelay.displayName } + if let domain = relay.userChatRelay.domains.first { return domain } + if let link = relay.relayLink { return hostFromRelayLink(link) } + return "relay \(relay.groupRelayId)" +} + +func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View { + let color: Color = connFailed ? .red : (status == .rsActive ? .green : .yellow) + let text: LocalizedStringKey = connFailed ? "failed" : status.text + return HStack(spacing: 4) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(text) + .font(.caption) + .foregroundStyle(.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + .font(.caption) + } + } +} + +struct RelayProgressIndicator: View { + var active: Int + var total: Int + + var body: some View { + if active == 0 { + ProgressView() + .frame(width: 20, height: 20) + } else { + ZStack { + Circle() + .stroke(Color(uiColor: .tertiaryLabel), style: StrokeStyle(lineWidth: 2.5)) + Circle() + .trim(from: 0, to: Double(active) / Double(max(total, 1))) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 20, height: 20) + } + } +} + +#Preview { + AddChannelView() +} diff --git a/apps/ios/Shared/Views/NewChat/AddGroupView.swift b/apps/ios/Shared/Views/NewChat/AddGroupView.swift index 901b2deeab..c74e016974 100644 --- a/apps/ios/Shared/Views/NewChat/AddGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/AddGroupView.swift @@ -88,7 +88,7 @@ struct AddGroupView: View { } .listRowBackground(Color.clear) .listRowSeparator(.hidden) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowInsets(EdgeInsets(top: 8, leading: 0, bottom: 8, trailing: 0)) Section { groupNameTextField() @@ -108,6 +108,7 @@ struct AddGroupView: View { focusDisplayName = false } } + .compactSectionSpacing() } .onAppear() { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift index 7adb04cb7e..8e62923f3f 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatMenuButton.swift @@ -125,6 +125,14 @@ struct NewChatSheet: View { } label: { Label("Create group", systemImage: "person.2.circle.fill") } + NavigationLink { + AddChannelView() + .navigationTitle("Create public channel") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + Label("Create public channel (BETA)", systemImage: "antenna.radiowaves.left.and.right.circle.fill") + } } if (showArchive) { diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index 3de1fdb972..63fb7f5221 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.11.2023. // Copyright © 2023 SimpleX Chat. All rights reserved. // +// Spec: spec/client/navigation.md import SwiftUI import SimpleXChat @@ -73,6 +74,7 @@ func showKeepInvitationAlert() { ChatModel.shared.showingInvitation = nil } +// Spec: spec/client/navigation.md#NewChatView struct NewChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @@ -988,42 +990,67 @@ private func showOwnGroupLinkConfirmConnectSheet( dismiss: Bool, cleanup: (() -> Void)? ) { - showSheet( - String.localizedStringWithFormat( - NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), - groupInfo.displayName - ), - actions: {[ - UIAlertAction( - title: NSLocalizedString("Open group", comment: "new chat action"), - style: .default, - handler: { _ in - openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) - } + if groupInfo.useRelays { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("This is your link for channel %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use current profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) - } + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open channel", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } else { + showSheet( + String.localizedStringWithFormat( + NSLocalizedString("Join your group?\nThis is your link for group %@!", comment: "new chat action"), + groupInfo.displayName ), - UIAlertAction( - title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), - style: .destructive, - handler: { _ in - connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) - } - ), - UIAlertAction( - title: NSLocalizedString("Cancel", comment: "new chat action"), - style: .default, - handler: { _ in - cleanup?() - } - ) - ]} - ) + actions: {[ + UIAlertAction( + title: NSLocalizedString("Open group", comment: "new chat action"), + style: .default, + handler: { _ in + openKnownGroup(groupInfo, dismiss: dismiss, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use current profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Use new incognito profile", comment: "new chat action"), + style: .destructive, + handler: { _ in + connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true, cleanup: cleanup) + } + ), + UIAlertAction( + title: NSLocalizedString("Cancel", comment: "new chat action"), + style: .default, + handler: { _ in + cleanup?() + } + ) + ]} + ) + } } private func showPrepareContactAlert( @@ -1072,30 +1099,47 @@ private func showPrepareContactAlert( private func showPrepareGroupAlert( connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, theme: AppTheme, dismiss: Bool, cleanup: (() -> Void)? ) { + let isChannel = !(groupShortLinkInfo?.direct ?? true) + let subscriberCount = groupShortLinkData.publicGroupData.map { "\($0.publicMemberCount) subscribers" } showOpenChatAlert( profileName: groupShortLinkData.groupProfile.displayName, profileFullName: groupShortLinkData.groupProfile.fullName, - profileImage: ProfileImage(imageStr: groupShortLinkData.groupProfile.image, iconName: "person.2.circle.fill", size: alertProfileImageSize), + profileImage: + ProfileImage( + imageStr: groupShortLinkData.groupProfile.image, + iconName: isChannel + ? "antenna.radiowaves.left.and.right.circle.fill" + : "person.2.circle.fill", + size: alertProfileImageSize + ), theme: theme, + subtitle: isChannel ? subscriberCount : nil, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), - confirmTitle: NSLocalizedString("Open new group", comment: "new chat action"), + confirmTitle: isChannel + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open new group", comment: "new chat action"), onCancel: { cleanup?() }, onConfirm: { Task { do { - let chat = try await apiPrepareGroup(connLink: connectionLink, groupShortLinkData: groupShortLinkData) + let chat = try await apiPrepareGroup(connLink: connectionLink, directLink: groupShortLinkInfo?.direct ?? true, groupShortLinkData: groupShortLinkData) await MainActor.run { + if let relays = groupShortLinkInfo?.groupRelays, !relays.isEmpty, + case let .group(gInfo, _) = chat.chatInfo { + ChatModel.shared.channelRelayHostnames[gInfo.groupId] = relays + } ChatModel.shared.addChat(Chat(chat)) openKnownChat(chat.id, dismiss: dismiss, cleanup: cleanup) } } catch let error { logger.error("showPrepareGroupAlert apiPrepareGroup error: \(error.localizedDescription)") - showAlert(NSLocalizedString("Error opening group", comment: ""), message: responseError(error)) + showAlert(NSLocalizedString(isChannel ? "Error opening channel" : "Error opening group", comment: "alert title"), message: responseError(error)) await MainActor.run { cleanup?() } @@ -1136,6 +1180,7 @@ private func showOpenKnownGroupAlert( theme: AppTheme, dismiss: Bool ) { + let subscriberCount = groupInfo.groupSummary.publicMemberCount.map { "\($0) subscribers" } showOpenChatAlert( profileName: groupInfo.groupProfile.displayName, profileFullName: groupInfo.groupProfile.fullName, @@ -1146,9 +1191,15 @@ private func showOpenKnownGroupAlert( size: alertProfileImageSize ), theme: theme, + subtitle: groupInfo.useRelays ? subscriberCount : nil, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: - groupInfo.businessChat == nil + groupInfo.useRelays + ? ( groupInfo.nextConnectPrepared + ? NSLocalizedString("Open new channel", comment: "new chat action") + : NSLocalizedString("Open channel", comment: "new chat action") + ) + : groupInfo.businessChat == nil ? ( groupInfo.nextConnectPrepared ? NSLocalizedString("Open new group", comment: "new chat action") : NSLocalizedString("Open group", comment: "new chat action") @@ -1163,6 +1214,7 @@ private func showOpenKnownGroupAlert( ) } +// Spec: spec/client/navigation.md#planAndConnect func planAndConnect( _ shortOrFullLink: String, theme: AppTheme, @@ -1171,6 +1223,14 @@ func planAndConnect( filterKnownContact: ((Contact) -> Void)? = nil, filterKnownGroup: ((GroupInfo) -> Void)? = nil ) { + if case .simplexLink(_, .relay, _, _) = strHasSingleSimplexLink(shortOrFullLink)?.format { + showAlert( + NSLocalizedString("Relay address", comment: "alert title"), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", comment: "alert message") + ) + cleanup?() + return + } ConnectProgressManager.shared.cancelConnectProgress() let inProgress = BoxedValue(true) connectTask(inProgress) @@ -1329,12 +1389,13 @@ func planAndConnect( } case let .groupLink(glp): switch glp { - case let .ok(groupSLinkData_): + case let .ok(groupShortLinkInfo_, groupSLinkData_): if let groupSLinkData = groupSLinkData_ { logger.debug("planAndConnect, .groupLink, .ok, short link data present") await MainActor.run { showPrepareGroupAlert( connectionLink: connectionLink, + groupShortLinkInfo: groupShortLinkInfo_, groupShortLinkData: groupSLinkData, theme: theme, dismiss: dismiss, diff --git a/apps/ios/Shared/Views/NewChat/QRCode.swift b/apps/ios/Shared/Views/NewChat/QRCode.swift index c9054f30da..2b38065bd9 100644 --- a/apps/ios/Shared/Views/NewChat/QRCode.swift +++ b/apps/ios/Shared/Views/NewChat/QRCode.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift index c8d0faafa7..f22d59fcac 100644 --- a/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift +++ b/apps/ios/Shared/Views/Onboarding/AddressCreationCard.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift index 33ffa04a50..b5598c1f85 100644 --- a/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift +++ b/apps/ios/Shared/Views/Onboarding/ChooseServerOperators.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index f119beec50..7301c0421d 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift index 03b0fcba1a..ab84bed7df 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateSimpleXAddress.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift index 7452d74e91..263b55a42d 100644 --- a/apps/ios/Shared/Views/Onboarding/HowItWorks.swift +++ b/apps/ios/Shared/Views/Onboarding/HowItWorks.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift index 8f448dc508..daef95fbc6 100644 --- a/apps/ios/Shared/Views/Onboarding/OnboardingView.swift +++ b/apps/ios/Shared/Views/Onboarding/OnboardingView.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift index 31865e7af9..717405b03b 100644 --- a/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift +++ b/apps/ios/Shared/Views/Onboarding/SetNotificationsMode.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift index 9f41a37b1d..80f35c1190 100644 --- a/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift +++ b/apps/ios/Shared/Views/Onboarding/SimpleXInfo.swift @@ -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 diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index 916e3f9e78..8a7ab465d4 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -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 diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 02dec5a618..54a60eed19 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -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(_ string: String) -> T? { do { return try YAMLDecoder().decode(T.self, from: string) @@ -1150,6 +1156,7 @@ private func decodeYAML(_ 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) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift index 3a536c7b17..74d38b050b 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift @@ -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 diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift new file mode 100644 index 0000000000..4a5cbab184 --- /dev/null +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift @@ -0,0 +1,403 @@ +// +// ChatRelayView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 23.02.2026. +// Copyright © 2026 SimpleX Chat. All rights reserved. +// +// Spec: spec/architecture.md + +import SwiftUI +import SimpleXChat + +@ViewBuilder func showRelayTestStatus(relay: UserChatRelay) -> some View { + switch relay.tested { + case .some(true): Image(systemName: "checkmark").foregroundColor(.green) + case .some(false): Image(systemName: "multiply").foregroundColor(.red) + case .none: Color.clear + } +} + +func validRelayName(_ name: String) -> Bool { + name != "" && validDisplayName(name) +} + +func showInvalidRelayNameAlert(_ name: Binding) { + let validName = mkValidName(name.wrappedValue) + if validName == "" { + showAlert(NSLocalizedString("Invalid name!", comment: "alert title")) + } else { + showAlert( + NSLocalizedString("Invalid name!", comment: "alert title"), + message: String.localizedStringWithFormat(NSLocalizedString("Correct name to %@?", comment: "alert message"), validName), + actions: {[ + UIAlertAction(title: NSLocalizedString("Ok", comment: "alert action"), style: .default) { _ in + name.wrappedValue = validName + }, + cancelAlertAction + ]} + ) + } +} + +func validRelayAddress(_ address: String) -> Bool { + if let parsedMd = parseSimpleXMarkdown(address), + parsedMd.count == 1, + case .simplexLink(_, .relay, _, _) = parsedMd.first?.format { + true + } else { + false + } +} + +func addChatRelay( + _ relay: UserChatRelay, + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, + _ dismiss: DismissAction +) { + let nameEmpty = relay.displayName.trimmingCharacters(in: .whitespaces).isEmpty + let addressEmpty = relay.address.trimmingCharacters(in: .whitespaces).isEmpty + if nameEmpty && addressEmpty { + dismiss() + } else if !validRelayName(relay.displayName) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else if !validRelayAddress(relay.address) { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } else if let i = userServers.wrappedValue.firstIndex(where: { $0.operator == nil }) { + userServers[i].wrappedValue.chatRelays.append(relay) + validateServers_(userServers, serverErrors, serverWarnings) + dismiss() + } else { // Shouldn't happen + dismiss() + showAlert(NSLocalizedString("Error adding relay", comment: "alert title")) + } +} + +struct ChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + @State var relayToEdit: UserChatRelay + var backLabel: LocalizedStringKey + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + if relay.preset { + presetRelay() + } else { + customRelay(validName: validName, validAddress: validAddress) + } + if testing { + ProgressView().scaleEffect(2) + } + } + .modifier(BackButton(label: backLabel, disabled: Binding.constant(false)) { + if validName && validAddress { + relay = relayToEdit + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } else if !validName { + dismiss() + showAlert( + NSLocalizedString("Invalid relay name!", comment: "alert title"), + message: NSLocalizedString("Check relay name and try again.", comment: "alert message") + ) + } else { + dismiss() + showAlert( + NSLocalizedString("Invalid relay address!", comment: "alert title"), + message: NSLocalizedString("Check relay address and try again.", comment: "alert message") + ) + } + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + if relayToEdit.address == relay.address { + relayToEdit.tested = relay.tested + relayToEdit.displayName = relay.displayName + } else { + relayToEdit.tested = nil + } + } + } + + private func relayNameHeader(validName: Bool) -> some View { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } + } + } + } + + private func presetRelay() -> some View { + List { + Section(header: Text("Preset relay address").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.address) + .textSelection(.enabled) + } + Section(header: Text("Preset relay name").foregroundColor(theme.colors.secondary)) { + Text(relayToEdit.displayName) + } + useRelaySection() + } + } + + private func customRelay(validName: Bool, validAddress: Bool) -> some View { + List { + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + Section { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + relayNameHeader(validName: validName) + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + useRelaySection(valid: validAddress) + Section { + Button(role: .destructive) { + relay.deleted = true + validateServers_($userServers, $serverErrors, $serverWarnings) + dismiss() + } label: { + Label("Delete relay", systemImage: "trash") + .foregroundColor(.red) + } + } + } + } + + private func useRelaySection(valid: Bool = true) -> some View { + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!valid || testing) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } +} + +struct ChatRelayViewLink: View { + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @Binding var relay: UserChatRelay + var duplicateRelayAddresses: Set + var backLabel: LocalizedStringKey + @Binding var selectedServer: String? + + var body: some View { + NavigationLink(tag: relay.id, selection: $selectedServer) { + ChatRelayView( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: $relay, + relayToEdit: relay, + backLabel: backLabel + ) + .navigationBarTitle("Chat relay") + .modifier(ThemedBackground(grouped: true)) + .navigationBarTitleDisplayMode(.large) + } label: { + HStack { + Group { + if duplicateRelayAddresses.contains(relay.address) { + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } else if !relay.enabled { + Image(systemName: "slash.circle").foregroundColor(theme.colors.secondary) + } else { + showRelayTestStatus(relay: relay) + } + } + .frame(width: 16, alignment: .center) + .padding(.trailing, 4) + + let displayName = !relay.displayName.isEmpty ? relay.displayName : relay.domains.first ?? relay.address + let v = Text(displayName).lineLimit(1) + if relay.enabled { + v + } else { + v.foregroundColor(theme.colors.secondary) + } + } + } + } +} + +struct NewChatRelayView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var theme: AppTheme + @Binding var userServers: [UserOperatorServers] + @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] + @State private var relayToEdit = UserChatRelay( + chatRelayId: nil, address: "", name: "", domains: [], + preset: false, tested: nil, enabled: true, deleted: false + ) + @State private var showTestFailure = false + @State private var testing = false + @State private var testFailure: RelayTestFailure? + + var body: some View { + let validName = validRelayName(relayToEdit.displayName) + let validAddress = validRelayAddress(relayToEdit.address) + ZStack { + List { + Section { + TextEditor(text: $relayToEdit.address) + .multilineTextAlignment(.leading) + .autocorrectionDisabled(true) + .autocapitalization(.none) + .allowsTightening(true) + .lineLimit(10) + .frame(height: 144) + .padding(-6) + } header: { + HStack { + Text("Your relay address") + .foregroundColor(theme.colors.secondary) + if !validAddress { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + } + } + } + Section { + TextField("Enter relay name…", text: $relayToEdit.displayName) + .autocorrectionDisabled(true) + .disabled(relayToEdit.tested == true) + } header: { + HStack { + Text("Your relay name").foregroundColor(theme.colors.secondary) + if !validName { + Spacer() + Image(systemName: "exclamationmark.circle").foregroundColor(.red) + .onTapGesture { showInvalidRelayNameAlert($relayToEdit.displayName) } + } + } + } footer: { + if relayToEdit.tested != true { + Text("**Test relay** to retrieve its name.") + } + } + Section(header: Text("Use relay").foregroundColor(theme.colors.secondary)) { + HStack { + Button("Test relay") { + testing = true + relayToEdit.tested = nil + Task { + if let f = await testRelayConnection(relay: $relayToEdit) { + showTestFailure = true + testFailure = f + } + await MainActor.run { testing = false } + } + } + .disabled(!validAddress || testing) + Spacer() + showRelayTestStatus(relay: relayToEdit) + } + Toggle("Use for new channels", isOn: $relayToEdit.enabled) + } + } + if testing { + ProgressView().scaleEffect(2) + } + } + .modifier(BackButton(disabled: Binding.constant(false)) { + addChatRelay(relayToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) + }) + .alert(isPresented: $showTestFailure) { + Alert( + title: Text("Relay test failed!"), + message: Text(testFailure?.localizedDescription ?? "") + ) + } + .onChange(of: relayToEdit.address) { _ in + relayToEdit.tested = nil + } + } +} + +func testRelayConnection(relay: Binding) async -> RelayTestFailure? { + do { + let (relayProfile, testFailure) = try await testChatRelay(address: relay.wrappedValue.address) + if let f = testFailure { + await MainActor.run { relay.wrappedValue.tested = false } + return f + } + await MainActor.run { + relay.wrappedValue.tested = true + if let relayProfile { + relay.wrappedValue.displayName = relayProfile.displayName + } + } + return nil + } catch { + logger.error("testRelayConnection \(responseError(error))") + await MainActor.run { relay.wrappedValue.tested = false } + return nil + } +} diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift index 1e38b7d5ec..6f76e69182 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift @@ -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 diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift index 6f4710396a..74b7374654 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 02/08/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -77,6 +78,7 @@ struct NetworkAndServers: View { YourServersView( userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: idx ) .navigationTitle("Your servers") @@ -114,6 +116,9 @@ struct NetworkAndServers: View { } else if !ss.servers.serverErrors.isEmpty { ServersErrorView(errStr: NSLocalizedString("Errors in servers configuration.", comment: "servers error")) } + if let warnStr = globalServersWarning(ss.servers.serverWarnings) { + ServersWarningView(warnStr: warnStr) + } } Section(header: Text("Calls").foregroundColor(theme.colors.secondary)) { @@ -142,6 +147,8 @@ struct NetworkAndServers: View { ss.servers.currUserServers = try await getUserServers() ss.servers.userServers = ss.servers.currUserServers ss.servers.serverErrors = [] + ss.servers.serverWarnings = [] + validateServers_($ss.servers.userServers, $ss.servers.serverErrors, $ss.servers.serverWarnings) } catch let error { await MainActor.run { showAlert( @@ -185,6 +192,7 @@ struct NetworkAndServers: View { currUserServers: $ss.servers.currUserServers, userServers: $ss.servers.userServers, serverErrors: $ss.servers.serverErrors, + serverWarnings: $ss.servers.serverWarnings, operatorIndex: operatorIndex, useOperator: serverOperator.enabled ) @@ -359,13 +367,18 @@ struct SimpleConditionsView: View { } } -func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { +func validateServers_( + _ userServers: Binding<[UserOperatorServers]>, + _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil +) { let userServersToValidate = userServers.wrappedValue Task { do { - let errs = try await validateServers(userServers: userServersToValidate) + let (errs, warns) = try await validateServers(userServers: userServersToValidate) await MainActor.run { serverErrors.wrappedValue = errs + serverWarnings?.wrappedValue = warns } } catch let error { logger.error("validateServers error: \(responseError(error))") @@ -395,6 +408,20 @@ struct ServersErrorView: View { } } +struct ServersWarningView: View { + @EnvironmentObject var theme: AppTheme + var warnStr: String + + var body: some View { + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.orange) + Text(warnStr) + .foregroundColor(theme.colors.secondary) + } + } +} + func globalServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalError { @@ -404,6 +431,29 @@ func globalServersError(_ serverErrors: [UserServersError]) -> String? { return nil } +func globalServersWarning(_ serverWarnings: [UserServersWarning]) -> String? { + for warn in serverWarnings { + switch warn { + case let .noChatRelays(user): + let text = NSLocalizedString("No chat relays enabled.", comment: "servers warning") + if let user = user { + return String.localizedStringWithFormat( + NSLocalizedString("For chat profile %@:", comment: "servers warning"), + user.localDisplayName + ) + " " + text + } else { return text } + } + } + return nil +} + +func bindingForChatRelays(_ userServers: Binding<[UserOperatorServers]>, _ opIndex: Int) -> Binding<[UserChatRelay]> { + Binding( + get: { userServers[opIndex].wrappedValue.chatRelays }, + set: { userServers[opIndex].wrappedValue.chatRelays = $0 } + ) +} + func globalSMPServersError(_ serverErrors: [UserServersError]) -> String? { for err in serverErrors { if let errStr = err.globalSMPError { @@ -433,6 +483,14 @@ func findDuplicateHosts(_ serverErrors: [UserServersError]) -> Set { return Set(duplicateHostsList) } +func findDuplicateRelayAddresses(_ serverErrors: [UserServersError]) -> Set { + Set(serverErrors.compactMap { err in + if case let .duplicateChatRelayAddress(_, duplicateAddress) = err { return duplicateAddress } + else { return nil } + }) +} + + func saveServers(_ currUserServers: Binding<[UserOperatorServers]>, _ userServers: Binding<[UserOperatorServers]>) { let userServersToSave = userServers.wrappedValue Task { diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift index c8cb2349e7..0a3c82b4dd 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 13.11.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -14,6 +15,7 @@ struct NewServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @State private var serverToEdit: UserServer = .empty @State private var showTestFailure = false @State private var testing = false @@ -27,7 +29,7 @@ struct NewServerView: View { } } .modifier(BackButton(disabled: Binding.constant(false)) { - addServer(serverToEdit, $userServers, $serverErrors, dismiss) + addServer(serverToEdit, $userServers, $serverErrors, $serverWarnings, dismiss) }) .alert(isPresented: $showTestFailure) { Alert( @@ -117,6 +119,7 @@ func addServer( _ server: UserServer, _ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>, + _ serverWarnings: Binding<[UserServersWarning]>? = nil, _ dismiss: DismissAction ) { if let (serverProtocol, matchingOperator) = serverProtocolAndOperator(server, userServers.wrappedValue) { @@ -125,7 +128,7 @@ func addServer( case .smp: userServers[i].wrappedValue.smpServers.append(server) case .xftp: userServers[i].wrappedValue.xftpServers.append(server) } - validateServers_(userServers, serverErrors) + validateServers_(userServers, serverErrors, serverWarnings) dismiss() if let op = matchingOperator { showAlert( @@ -151,6 +154,7 @@ func addServer( #Preview { NewServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), - serverErrors: Binding.constant([]) + serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]) ) } diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift index afbccc109c..9d068d3b26 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift @@ -5,6 +5,7 @@ // Created by spaced4ndy on 28.10.2024. // Copyright © 2024 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -18,6 +19,7 @@ struct OperatorView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State var useOperator: Bool @State private var useOperatorToggleReset: Bool = false @@ -40,6 +42,7 @@ struct OperatorView: View { private func operatorView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return VStack { List { Section { @@ -51,6 +54,8 @@ struct OperatorView: View { } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } else { switch (userServers[operatorIndex].operator_.conditionsAcceptance) { case let .accepted(acceptedAt, _): @@ -68,15 +73,37 @@ struct OperatorView: View { } if userServers[operatorIndex].operator_.enabled { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + duplicateRelayAddresses: duplicateRelayAddresses, + backLabel: "\(userServers[operatorIndex].operator_.tradeName) servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { Toggle("To receive", isOn: $userServers[operatorIndex].operator_.smpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.smpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } Toggle("For private routing", isOn: $userServers[operatorIndex].operator_.smpRoles.proxy) .onChange(of: userServers[operatorIndex].operator_.smpRoles.proxy) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for messages") @@ -96,6 +123,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -127,6 +155,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -139,7 +168,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added message servers") @@ -151,7 +180,7 @@ struct OperatorView: View { Section { Toggle("To send", isOn: $userServers[operatorIndex].operator_.xftpRoles.storage) .onChange(of: userServers[operatorIndex].operator_.xftpRoles.storage) { _ in - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Use for files") @@ -171,6 +200,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -202,6 +232,7 @@ struct OperatorView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -214,7 +245,7 @@ struct OperatorView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Added media & file servers") @@ -226,6 +257,7 @@ struct OperatorView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) } @@ -245,6 +277,7 @@ struct OperatorView: View { currUserServers: $currUserServers, userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, operatorIndex: operatorIndex ) .modifier(ThemedBackground(grouped: true)) @@ -275,18 +308,18 @@ struct OperatorView: View { switch userServers[operatorIndex].operator_.conditionsAcceptance { case .accepted: userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) case let .required(deadline): if deadline == nil { showConditionsSheet = true } else { userServers[operatorIndex].operator_.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } else { userServers[operatorIndex].operator_.enabled = false - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } } @@ -423,6 +456,7 @@ struct SingleOperatorUsageConditionsView: View { @Binding var currUserServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int var body: some View { @@ -525,7 +559,7 @@ struct SingleOperatorUsageConditionsView: View { updateOperatorsConditionsAcceptance($currUserServers, r.serverOperators) updateOperatorsConditionsAcceptance($userServers, r.serverOperators) userServers[operatorIndexToEnable].operator?.enabled = true - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } catch let error { @@ -580,6 +614,7 @@ func conditionsLinkButton() -> some View { currUserServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), userServers: Binding.constant([UserOperatorServers.sampleData1, UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), operatorIndex: 1, useOperator: ServerOperator.sampleData1.enabled ) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift index 97bfd360cb..5299b7d415 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -14,6 +15,7 @@ struct ProtocolServerView: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] @Binding var server: UserServer @State var serverToEdit: UserServer var backLabel: LocalizedStringKey @@ -49,7 +51,7 @@ struct ProtocolServerView: View { ) } else { server = serverToEdit - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) dismiss() } } else { @@ -201,6 +203,7 @@ struct ProtocolServerView_Previews: PreviewProvider { ProtocolServerView( userServers: Binding.constant([UserOperatorServers.sampleDataNilOperator]), serverErrors: Binding.constant([]), + serverWarnings: Binding.constant([]), server: Binding.constant(UserServer.sampleData.custom), serverToEdit: UserServer.sampleData.custom, backLabel: "Your SMP servers" diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift index b9737914ec..e57df4c5dc 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift @@ -5,6 +5,7 @@ // Created by Evgeny on 15/11/2022. // Copyright © 2022 SimpleX Chat. All rights reserved. // +// Spec: spec/architecture.md import SwiftUI import SimpleXChat @@ -18,10 +19,12 @@ struct YourServersView: View { @Environment(\.editMode) private var editMode @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var operatorIndex: Int @State private var selectedServer: String? = nil @State private var showAddServer = false @State private var newServerNavLinkActive = false + @State private var newChatRelayNavLinkActive = false @State private var showScanProtoServer = false @State private var testing = false @@ -40,7 +43,34 @@ struct YourServersView: View { private func yourServersView() -> some View { let duplicateHosts = findDuplicateHosts(serverErrors) + let duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors) return List { + if !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty { + Section { + ForEach(bindingForChatRelays($userServers, operatorIndex)) { relay in + if !relay.wrappedValue.deleted { + ChatRelayViewLink( + userServers: $userServers, + serverErrors: $serverErrors, + serverWarnings: $serverWarnings, + relay: relay, + duplicateRelayAddresses: duplicateRelayAddresses, + backLabel: "Your servers", + selectedServer: $selectedServer + ) + } else { EmptyView() } + } + .onDelete { indexSet in + deleteChatRelay($userServers, operatorIndex, indexSet) + validateServers_($userServers, $serverErrors, $serverWarnings) + } + } header: { + Text("Chat relays").foregroundColor(theme.colors.secondary) + } footer: { + Text("Chat relays forward messages in channels you create.").foregroundColor(theme.colors.secondary) + } + } + if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { Section { ForEach($userServers[operatorIndex].smpServers) { srv in @@ -48,6 +78,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .smp, @@ -60,7 +91,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteSMPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Message servers") @@ -83,6 +114,7 @@ struct YourServersView: View { ProtocolServerViewLink( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, duplicateHosts: duplicateHosts, server: srv, serverProtocol: .xftp, @@ -95,7 +127,7 @@ struct YourServersView: View { } .onDelete { indexSet in deleteXFTPServer($userServers, operatorIndex, indexSet) - validateServers_($userServers, $serverErrors) + validateServers_($userServers, $serverErrors, $serverWarnings) } } header: { Text("Media & file servers") @@ -124,10 +156,23 @@ struct YourServersView: View { } .frame(width: 1, height: 1) .hidden() + + NavigationLink(isActive: $newChatRelayNavLinkActive) { + NewChatRelayView(userServers: $userServers, serverErrors: $serverErrors, serverWarnings: $serverWarnings) + .navigationTitle("New chat relay") + .navigationBarTitleDisplayMode(.large) + .modifier(ThemedBackground(grouped: true)) + } label: { + EmptyView() + } + .frame(width: 1, height: 1) + .hidden() } } footer: { if let errStr = globalServersError(serverErrors) { ServersErrorView(errStr: errStr) + } else if let warnStr = globalServersWarning(serverWarnings) { + ServersWarningView(warnStr: warnStr) } } @@ -135,6 +180,7 @@ struct YourServersView: View { TestServersButton( smpServers: $userServers[operatorIndex].smpServers, xftpServers: $userServers[operatorIndex].xftpServers, + chatRelays: $userServers[operatorIndex].chatRelays, testing: $testing ) howToButton() @@ -143,7 +189,8 @@ struct YourServersView: View { .toolbar { if ( !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty || - !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty + !userServers[operatorIndex].xftpServers.filter({ !$0.deleted }).isEmpty || + !userServers[operatorIndex].chatRelays.filter({ !$0.deleted }).isEmpty ) { EditButton() } @@ -151,11 +198,13 @@ struct YourServersView: View { .confirmationDialog("Add server", isPresented: $showAddServer, titleVisibility: .hidden) { Button("Enter server manually") { newServerNavLinkActive = true } Button("Scan server QR code") { showScanProtoServer = true } + Button("Chat relay") { newChatRelayNavLinkActive = true } } .sheet(isPresented: $showScanProtoServer) { ScanProtocolServer( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .modifier(ThemedBackground(grouped: true)) } @@ -164,7 +213,8 @@ struct YourServersView: View { private func newServerDestinationView() -> some View { NewServerView( userServers: $userServers, - serverErrors: $serverErrors + serverErrors: $serverErrors, + serverWarnings: $serverWarnings ) .navigationTitle("New server") .navigationBarTitleDisplayMode(.large) @@ -189,6 +239,7 @@ struct ProtocolServerViewLink: View { @EnvironmentObject var theme: AppTheme @Binding var userServers: [UserOperatorServers] @Binding var serverErrors: [UserServersError] + @Binding var serverWarnings: [UserServersWarning] var duplicateHosts: Set @Binding var server: UserServer var serverProtocol: ServerProtocol @@ -202,6 +253,7 @@ struct ProtocolServerViewLink: View { ProtocolServerView( userServers: $userServers, serverErrors: $serverErrors, + serverWarnings: $serverWarnings, server: $server, serverToEdit: server, backLabel: backLabel @@ -279,9 +331,27 @@ func deleteXFTPServer( } } +func deleteChatRelay( + _ userServers: Binding<[UserOperatorServers]>, + _ operatorServersIndex: Int, + _ serverIndexSet: IndexSet +) { + if let idx = serverIndexSet.first { + let relay = userServers[operatorServersIndex].wrappedValue.chatRelays[idx] + if relay.chatRelayId == nil { + userServers[operatorServersIndex].wrappedValue.chatRelays.remove(at: idx) + } else { + var updatedRelay = relay + updatedRelay.deleted = true + userServers[operatorServersIndex].wrappedValue.chatRelays[idx] = updatedRelay + } + } +} + struct TestServersButton: View { @Binding var smpServers: [UserServer] @Binding var xftpServers: [UserServer] + @Binding var chatRelays: [UserChatRelay] @Binding var testing: Bool var body: some View { @@ -290,20 +360,24 @@ struct TestServersButton: View { } private var allServersDisabled: Bool { - smpServers.allSatisfy { !$0.enabled } && xftpServers.allSatisfy { !$0.enabled } + smpServers.allSatisfy { !$0.enabled } && + xftpServers.allSatisfy { !$0.enabled } && + chatRelays.filter({ !$0.deleted }).allSatisfy { !$0.enabled } } private func testServers() { resetTestStatus() testing = true Task { - let fs = await runServersTest() + let rfs = await runRelaysTest() + let sfs = await runServersTest() await MainActor.run { testing = false - if !fs.isEmpty { - let msg = fs.map { (srv, f) in - "\(srv): \(f.localizedDescription)" - }.joined(separator: "\n") + var failures: [String] = [] + failures += rfs.map { (name, f) in "\(name): \(f.localizedDescription)" } + failures += sfs.map { (srv, f) in "\(srv): \(f.localizedDescription)" } + if !failures.isEmpty { + let msg = failures.joined(separator: "\n") showAlert( NSLocalizedString("Tests failed!", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Some servers failed the test:\n%@", comment: "alert message"), msg) @@ -314,6 +388,12 @@ struct TestServersButton: View { } private func resetTestStatus() { + for i in 0.. [String: RelayTestFailure] { + var fs: [String: RelayTestFailure] = [:] + for i in 0.. Всички членове на групата ще останат свързани. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всички съобщения и файлове се изпращат с **криптиране от край до край**, с постквантова сигурност в директните съобщения. @@ -1142,6 +1146,10 @@ swipe action Аудио и видео разговори No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудио/видео разговори @@ -2013,6 +2021,10 @@ This is your own one-time link! Грешка при свързване (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2552,6 +2564,14 @@ swipe action Изтрий съобщението на члена? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Изтрий съобщението? @@ -2560,7 +2580,8 @@ swipe action Delete messages Изтрий съобщенията - alert button + alert action +alert button Delete messages after @@ -3741,6 +3762,10 @@ snd error text Файловете и медията са забранени! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Филтрирайте непрочетените и любимите чатове. @@ -4174,6 +4199,10 @@ Error: %2$@ Ако въведете kодa за достъп за самоунищожение, докато отваряте приложението: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Ако трябва да използвате чата сега, докоснете **Отложи** отдолу (ще ви бъде предложено да мигрирате базата данни, когато рестартирате приложението). @@ -4194,6 +4223,10 @@ Error: %2$@ Изображението ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Веднага @@ -4442,6 +4475,10 @@ More improvements are coming soon! Покани приятели No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Покани членове @@ -4658,6 +4695,10 @@ This is your link for group %@! Запомнени настолни устройства No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4773,6 +4814,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4793,12 +4838,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Членът ще бъде премахнат от групата - това не може да бъде отменено! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6296,7 +6341,11 @@ swipe action Remove Премахване - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6318,7 +6367,7 @@ swipe action Remove member? Острани член? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6703,11 +6752,31 @@ chat item action Лентата за търсене приема линк за връзка. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Търсене или поставяне на SimpleX линк No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8432,6 +8501,10 @@ To connect, please ask your contact to create another connection link and check Видеото ще бъде получено, когато вашият контакт е онлайн, моля, изчакайте или проверете по-късно! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Видео и файлове до 1gb @@ -9522,6 +9595,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded препратено diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index ace3079550..b212f42592 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -782,6 +782,10 @@ swipe action Všichni členové skupiny zůstanou připojeni. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1117,6 +1121,10 @@ swipe action Hlasové a video hovory No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video hovory @@ -1916,6 +1924,10 @@ Toto je váš vlastní jednorázový odkaz! Chyba spojení (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2438,6 +2450,14 @@ swipe action Smazat zprávu člena? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Smazat zprávu? @@ -2446,7 +2466,8 @@ swipe action Delete messages Smazat zprávy - alert button + alert action +alert button Delete messages after @@ -3596,6 +3617,10 @@ snd error text Soubory a média jsou zakázány! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrovat nepřečtené a oblíbené chaty. @@ -3629,7 +3654,7 @@ snd error text Fingerprint in server address does not match certificate. - Je možné, že otisk certifikátu v adrese serveru je nesprávný + Otisk certifikátu v adrese serveru neodpovídá. server test error @@ -4017,6 +4042,10 @@ Error: %2$@ Pokud při otevření aplikace zadáte sebedestrukční heslo: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Pokud potřebujete chat používat nyní, klepněte na **Udělat později** níže (migrace databáze vám bude nabídnuta po restartování aplikace). @@ -4037,6 +4066,10 @@ Error: %2$@ Obrázek bude přijat, až bude váš kontakt online, vyčkejte prosím nebo se podívejte později! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Ihned @@ -4272,6 +4305,10 @@ More improvements are coming soon! Pozvat přátele No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Pozvat členy @@ -4479,6 +4516,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4594,6 +4635,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4614,12 +4659,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Člen bude odstraněn ze skupiny - toto nelze vzít zpět! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5168,7 +5213,7 @@ This is your link for group %@! No user identifiers. - Bez uživatelských identifikátorů + Bez uživatelských identifikátorů. No comment provided by engineer. @@ -6074,7 +6119,11 @@ swipe action Remove Odstranit - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6096,7 +6145,7 @@ swipe action Remove member? Odebrat člena? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6471,10 +6520,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -6754,12 +6823,12 @@ chat item action Server requires authorization to create queues, check password. - Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo + Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo. server test error Server requires authorization to upload, check password. - Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo + Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo. server test error @@ -8156,6 +8225,10 @@ Chcete-li se připojit, požádejte svůj kontakt o vytvoření dalšího odkazu Video obdržíte, až bude váš kontakt online, vyčkejte prosím nebo zkontrolujte později! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videa a soubory až do velikosti 1 gb @@ -9211,6 +9284,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 34bbab2a6a..7ab0535158 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -792,6 +792,11 @@ swipe action Alle Gruppenmitglieder bleiben verbunden. No comment provided by engineer. + + All messages + Alle Nachrichten + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security. @@ -1142,6 +1147,11 @@ swipe action Audio- und Videoanrufe No comment provided by engineer. + + Audio call + Audioanruf + No comment provided by engineer. + Audio/video calls Audio-/Video-Anrufe @@ -2013,6 +2023,10 @@ Das ist Ihr eigener Einmal-Link! Verbindungsfehler (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2593,16 @@ swipe action Nachricht des Mitglieds löschen? No comment provided by engineer. + + Delete member messages + Mitgliedsnachrichten löschen + No comment provided by engineer. + + + Delete member messages? + Mitgliedsnachrichten löschen? + alert title + Delete message? Die Nachricht löschen? @@ -2587,7 +2611,8 @@ swipe action Delete messages Nachrichten löschen - alert button + alert action +alert button Delete messages after @@ -3851,6 +3876,11 @@ snd error text Dateien und Medien sind nicht erlaubt! No comment provided by engineer. + + Filter + Filter + No comment provided by engineer. + Filter unread and favorite chats. Nach ungelesenen und favorisierten Chats filtern. @@ -4315,6 +4345,10 @@ Fehler: %2$@ Wenn Sie Ihren Selbstzerstörungs-Zugangscode während des Öffnens der App eingeben: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Tippen Sie unten auf **Später wiederholen**, wenn Sie den Chat jetzt benötigen (es wird Ihnen angeboten, die Datenbank bei einem Neustart der App zu migrieren). @@ -4335,6 +4369,11 @@ Fehler: %2$@ Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach! No comment provided by engineer. + + Images + Bilder + No comment provided by engineer. + Immediately Sofort @@ -4594,6 +4633,11 @@ Weitere Verbesserungen sind bald verfügbar! Freunde einladen No comment provided by engineer. + + Invite member + Mitglied einladen + No comment provided by engineer. + Invite members Mitglieder einladen @@ -4817,6 +4861,11 @@ Das ist Ihr Link für die Gruppe %@! Verknüpfte Desktops No comment provided by engineer. + + Links + Links + No comment provided by engineer. + List Liste @@ -4942,6 +4991,11 @@ Das ist Ihr Link für die Gruppe %@! Mitglied ist gelöscht - Anfrage kann nicht angenommen werden No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! + alert message + Member reports Mitglieder-Meldungen @@ -4965,12 +5019,12 @@ Das ist Ihr Link für die Gruppe %@! Member will be removed from chat - this cannot be undone! Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6595,7 +6649,12 @@ swipe action Remove Entfernen - No comment provided by engineer. + alert action + + + Remove and delete messages + Mitglied entfernen und Nachrichten löschen + alert action Remove archive? @@ -6620,7 +6679,7 @@ swipe action Remove member? Das Mitglied entfernen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7097,36 @@ chat item action In der Suchleiste werden nun auch Einladungslinks angenommen. No comment provided by engineer. + + Search files + Dateien suchen + No comment provided by engineer. + + + Search images + Bilder suchen + No comment provided by engineer. + + + Search links + Links suchen + No comment provided by engineer. + Search or paste SimpleX link Suchen oder SimpleX-Link einfügen No comment provided by engineer. + + Search videos + Videos suchen + No comment provided by engineer. + + + Search voice messages + Sprachnachrichten suchen + No comment provided by engineer. + Secondary Zweite Farbe @@ -8918,6 +9002,11 @@ Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um s Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später! No comment provided by engineer. + + Videos + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos und Dateien bis zu 1GB @@ -10059,6 +10148,10 @@ pref value Abgelaufen No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded weitergeleitet @@ -10923,7 +11016,7 @@ Zuletzt empfangene Nachricht: %2$@ Ok - OK + Ok No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 2f5a0acbb1..fbc3b2dfa8 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -792,6 +792,11 @@ swipe action All group members will remain connected. No comment provided by engineer. + + All messages + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. @@ -1142,6 +1147,11 @@ swipe action Audio and video calls No comment provided by engineer. + + Audio call + Audio call + No comment provided by engineer. + Audio/video calls Audio/video calls @@ -2013,6 +2023,11 @@ This is your own one-time link! Connection error (AUTH) No comment provided by engineer. + + Connection failed + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2594,16 @@ swipe action Delete member message? No comment provided by engineer. + + Delete member messages + Delete member messages + No comment provided by engineer. + + + Delete member messages? + Delete member messages? + alert title + Delete message? Delete message? @@ -2587,7 +2612,8 @@ swipe action Delete messages Delete messages - alert button + alert action +alert button Delete messages after @@ -3851,6 +3877,11 @@ snd error text Files and media prohibited! No comment provided by engineer. + + Filter + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter unread and favorite chats. @@ -4315,6 +4346,11 @@ Error: %2$@ If you enter your self-destruct passcode while opening the app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). @@ -4335,6 +4371,11 @@ Error: %2$@ Image will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Images + Images + No comment provided by engineer. + Immediately Immediately @@ -4594,6 +4635,11 @@ More improvements are coming soon! Invite friends No comment provided by engineer. + + Invite member + Invite member + No comment provided by engineer. + Invite members Invite members @@ -4817,6 +4863,11 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + Links + No comment provided by engineer. + List List @@ -4942,6 +4993,11 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Member messages will be deleted - this cannot be undone! + alert message + Member reports Member reports @@ -4965,12 +5021,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Member will be removed from group - this cannot be undone! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6595,7 +6651,12 @@ swipe action Remove Remove - No comment provided by engineer. + alert action + + + Remove and delete messages + Remove and delete messages + alert action Remove archive? @@ -6620,7 +6681,7 @@ swipe action Remove member? Remove member? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7099,36 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + Search files + No comment provided by engineer. + + + Search images + Search images + No comment provided by engineer. + + + Search links + Search links + No comment provided by engineer. + Search or paste SimpleX link Search or paste SimpleX link No comment provided by engineer. + + Search videos + Search videos + No comment provided by engineer. + + + Search voice messages + Search voice messages + No comment provided by engineer. + Secondary Secondary @@ -8918,6 +9004,11 @@ To connect, please ask your contact to create another connection link and check Video will be received when your contact is online, please wait or check later! No comment provided by engineer. + + Videos + Videos + No comment provided by engineer. + Videos and files up to 1gb Videos and files up to 1gb @@ -10059,6 +10150,11 @@ pref value expired No comment provided by engineer. + + failed + failed + No comment provided by engineer. + forwarded forwarded diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 30c69af755..61734f2480 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -498,7 +498,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>¡Hola!</p> -<p><a href="%@"> Conecta conmigo a través de SimpleX Chat</a></p> +<p><a href="%@">Conecta conmigo a través de SimpleX Chat</a></p> email text @@ -792,6 +792,11 @@ swipe action Todos los miembros del grupo permanecerán conectados. No comment provided by engineer. + + All messages + Todos los mensajes + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos. @@ -1142,6 +1147,11 @@ swipe action Llamadas y videollamadas No comment provided by engineer. + + Audio call + Llamada + No comment provided by engineer. + Audio/video calls Llamadas y videollamadas @@ -2013,6 +2023,10 @@ This is your own one-time link! Error de conexión (Autenticación) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2593,16 @@ swipe action ¿Eliminar el mensaje de miembro? No comment provided by engineer. + + Delete member messages + Eliminar mensajes del miembro + No comment provided by engineer. + + + Delete member messages? + ¿Eliminar mensajes del miembro? + alert title + Delete message? ¿Eliminar mensaje? @@ -2587,7 +2611,8 @@ swipe action Delete messages Activar - alert button + alert action +alert button Delete messages after @@ -3851,6 +3876,11 @@ snd error text ¡Archivos y multimedia no permitidos! No comment provided by engineer. + + Filter + Filtro + No comment provided by engineer. + Filter unread and favorite chats. Filtra chats no leídos y favoritos. @@ -4315,6 +4345,10 @@ Error: %2$@ Si al abrir la aplicación introduces el código de autodestrucción: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si necesitas usar el chat ahora pulsa **Hacerlo más tarde** más abajo (se ofrecerá migrar la base de datos cuando se reinicie la aplicación). @@ -4335,6 +4369,11 @@ Error: %2$@ La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde! No comment provided by engineer. + + Images + Imágenes + No comment provided by engineer. + Immediately Inmediatamente @@ -4594,6 +4633,11 @@ More improvements are coming soon! Invitar amigos No comment provided by engineer. + + Invite member + Invitar miembro + No comment provided by engineer. + Invite members Invitar miembros @@ -4817,6 +4861,11 @@ This is your link for group %@! Ordenadores enlazados No comment provided by engineer. + + Links + Enlaces + No comment provided by engineer. + List Lista @@ -4942,6 +4991,11 @@ This is your link for group %@! Miembro eliminado, no puede aceptar solicitudes No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! + alert message + Member reports Informes de miembros @@ -4965,12 +5019,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! El miembro será eliminado del chat. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! El miembro será expulsado del grupo. ¡No puede deshacerse! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5484,7 +5538,7 @@ This is your link for group %@! No direct connection yet, message is forwarded by admin. - Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. item status description @@ -5914,7 +5968,7 @@ Requiere activación de la VPN. Or show this code - O muestra el código QR + O muestra este código No comment provided by engineer. @@ -6595,7 +6649,12 @@ swipe action Remove Eliminar - No comment provided by engineer. + alert action + + + Remove and delete messages + Eliminar miembro y sus mensajes + alert action Remove archive? @@ -6620,7 +6679,7 @@ swipe action Remove member? ¿Expulsar miembro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7097,36 @@ chat item action La barra de búsqueda acepta enlaces de invitación. No comment provided by engineer. + + Search files + Buscar archivos + No comment provided by engineer. + + + Search images + Buscar imágenes + No comment provided by engineer. + + + Search links + Buscar enlaces + No comment provided by engineer. + Search or paste SimpleX link Buscar o pegar enlace SimpleX No comment provided by engineer. + + Search videos + Buscar vídeos + No comment provided by engineer. + + + Search voice messages + Buscar mensajes de voz + No comment provided by engineer. + Secondary Secundario @@ -8918,6 +9002,11 @@ Para conectarte pide a tu contacto que cree otro enlace y comprueba la conexión El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde. No comment provided by engineer. + + Videos + Vídeos + No comment provided by engineer. + Videos and files up to 1gb Vídeos y archivos de hasta 1Gb @@ -9649,7 +9738,7 @@ Repeat connection request? accepted you - te ha aceptado + te ha admitido rcv group event chat item @@ -10059,6 +10148,10 @@ pref value expirados No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded reenviado @@ -10533,7 +10626,7 @@ last received msg: %2$@ unprotected - con IP desprotegida + desprotegida No comment provided by engineer. @@ -10623,7 +10716,7 @@ last received msg: %2$@ you accepted this member - has aceptado al miembro + has admitido al miembro snd group event chat item diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index 56fa4a1485..5a7813dfe5 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -726,6 +726,10 @@ swipe action Kaikki ryhmän jäsenet pysyvät yhteydessä. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1042,6 +1046,10 @@ swipe action Ääni- ja videopuhelut No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Ääni/videopuhelut @@ -1806,6 +1814,10 @@ This is your own one-time link! Yhteysvirhe (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2328,6 +2340,14 @@ swipe action Poista jäsenviesti? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Poista viesti? @@ -2336,7 +2356,8 @@ swipe action Delete messages Poista viestit - alert button + alert action +alert button Delete messages after @@ -3483,6 +3504,10 @@ snd error text Tiedostot ja media kielletty! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Suodata lukemattomia- ja suosikkikeskusteluja. @@ -3904,6 +3929,10 @@ Error: %2$@ Jos syötät itsetuhoutuvan pääsykoodin sovellusta avattaessa: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jos haluat käyttää keskustelua nyt, napauta **Tee se myöhemmin** alla (sinulle tarjotaan tietokannan siirtämistä, kun käynnistät sovelluksen uudelleen). @@ -3924,6 +3953,10 @@ Error: %2$@ Kuva vastaanotetaan, kun kontaktisi on verkossa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Heti @@ -4159,6 +4192,10 @@ More improvements are coming soon! Kutsu ystäviä No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Kutsu jäseniä @@ -4366,6 +4403,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4481,6 +4522,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4501,12 +4546,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Jäsen poistetaan ryhmästä - tätä ei voi perua! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5959,7 +6004,11 @@ swipe action Remove Poista - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5981,7 +6030,7 @@ swipe action Remove member? Poista jäsen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6356,10 +6405,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8038,6 +8107,10 @@ Jos haluat muodostaa yhteyden, pyydä kontaktiasi luomaan toinen yhteyslinkki ja Video vastaanotetaan, kun kontaktisi on online-tilassa, odota tai tarkista myöhemmin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Videot ja tiedostot 1 Gt asti @@ -9093,6 +9166,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index 67485353d2..7e386fe50c 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -792,6 +792,10 @@ swipe action Tous les membres du groupe resteront connectés. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tous les messages et fichiers sont envoyés **chiffrés de bout en bout**, avec une sécurité post-quantique dans les messages directs. @@ -1141,6 +1145,10 @@ swipe action Appels audio et vidéo No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Appels audio/vidéo @@ -2001,6 +2009,10 @@ Il s'agit de votre propre lien unique ! Erreur de connexion (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2564,6 +2576,14 @@ swipe action Supprimer le message de ce membre ? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Supprimer le message ? @@ -2572,7 +2592,8 @@ swipe action Delete messages Supprimer les messages - alert button + alert action +alert button Delete messages after @@ -3822,6 +3843,10 @@ snd error text Fichiers et médias interdits ! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filtrer les messages non lus et favoris. @@ -4276,6 +4301,10 @@ Erreur : %2$@ Si vous entrez votre code d'autodestruction à l'ouverture de l'application : No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Si vous avez besoin d'utiliser le chat maintenant appuyez sur **le faire plus tard** (vous pourrez migrer la base de données quand vous relancerez l'app). @@ -4296,6 +4325,10 @@ Erreur : %2$@ L'image sera reçue quand votre contact sera en ligne, merci d'attendre ou de revenir plus tard ! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Immédiatement @@ -4548,6 +4581,10 @@ D'autres améliorations sont à venir ! Inviter des amis No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Inviter des membres @@ -4769,6 +4806,10 @@ Voici votre lien pour le groupe %@ ! Bureaux liés No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4887,6 +4928,10 @@ Voici votre lien pour le groupe %@ ! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4909,12 +4954,12 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! Le membre sera retiré de la discussion - cela ne peut pas être annulé ! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Ce membre sera retiré du groupe - impossible de revenir en arrière ! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6489,7 +6534,11 @@ swipe action Remove Supprimer - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6513,7 +6562,7 @@ swipe action Remove member? Retirer ce membre ? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6912,11 +6961,31 @@ chat item action La barre de recherche accepte les liens d'invitation. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Rechercher ou coller un lien SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secondaire @@ -8743,6 +8812,10 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien La vidéo ne sera reçue que lorsque votre contact sera en ligne. Veuillez patienter ou vérifier plus tard ! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Vidéos et fichiers jusqu'à 1Go @@ -9865,6 +9938,10 @@ pref value expiré No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded transféré diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index 22d223ffd9..0ed5dc19ea 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -92,12 +92,12 @@ %@ is not verified - %@ nincs hitelesítve + %@ nincs ellenőrizve No comment provided by engineer. %@ is verified - %@ hitelesítve + %@ ellenőrizve No comment provided by engineer. @@ -217,7 +217,7 @@ %lld contact(s) selected - %lld partner kijelölve + %lld partner kiválasztva No comment provided by engineer. @@ -367,7 +367,7 @@ **Warning**: Instant push notifications require passphrase saved in Keychain. - **Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. + **Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges. No comment provided by engineer. @@ -377,12 +377,12 @@ **e2e encrypted** audio call - **e2e titkosított** hanghívás + **végpontok között titkosított** hanghívás No comment provided by engineer. **e2e encrypted** video call - **e2e titkosított** videóhívás + **végpontok között titkosított** videóhívás No comment provided by engineer. @@ -394,8 +394,8 @@ - connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! - delivery receipts (up to 20 members). - faster and more stable. - - kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! -- kézbesítési jelentések (legfeljebb 20 tag). + - kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! +- kézbesítési jelentések (legfeljebb 20 tagig). - gyorsabb és stabilabb. No comment provided by engineer. @@ -789,7 +789,12 @@ swipe action All group members will remain connected. - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. + No comment provided by engineer. + + + All messages + Összes üzenet No comment provided by engineer. @@ -829,12 +834,12 @@ swipe action All your contacts will remain connected. - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. No comment provided by engineer. All your contacts will remain connected. Profile update will be sent to your contacts. - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. No comment provided by engineer. @@ -954,7 +959,7 @@ swipe action Allow your contacts to send disappearing messages. - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. No comment provided by engineer. @@ -984,12 +989,12 @@ swipe action Always use private routing. - Mindig használjon privát útválasztást. + Mindig legyen használva privát útválasztás. No comment provided by engineer. Always use relay - Mindig használjon továbbítókiszolgálót + Mindig legyen használva továbbítókiszolgáló No comment provided by engineer. @@ -1142,6 +1147,11 @@ swipe action Hang- és videóhívások No comment provided by engineer. + + Audio call + Hanghívás + No comment provided by engineer. + Audio/video calls Hang- és videóhívások @@ -1264,12 +1274,12 @@ swipe action Bio - Névjegy + Életrajz No comment provided by engineer. Bio too large - A névjegy túl hosszú + Az életrajz túl hosszú alert title @@ -1398,7 +1408,7 @@ swipe action Call already ended! - A hívás már befejeződött! + A hívás már véget ért! No comment provided by engineer. @@ -1691,7 +1701,7 @@ set passcode view Choose _Migrate from another device_ on the new device and scan QR code. - Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot. + Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot. No comment provided by engineer. @@ -1721,37 +1731,37 @@ set passcode view Clear - Kiürítés + Ürítés swipe action Clear conversation - Üzenetek kiürítése + Üzenetek ürítése No comment provided by engineer. Clear conversation? - Kiüríti az üzeneteket? + Üríti a beszélgetés üzeneteit? No comment provided by engineer. Clear group? - Kiüríti a csoportot? + Üríti a csoport üzeneteit? No comment provided by engineer. Clear or delete group? - Csoport kiürítése vagy törlése? + Csoport ürítése vagy törlése? No comment provided by engineer. Clear private notes? - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? No comment provided by engineer. Clear verification - Hitelesítés törlése + Ellenőrzés törlése No comment provided by engineer. @@ -1935,7 +1945,7 @@ Ez a saját egyszer használható meghívója! Connect via one-time link - Kapcsolódás egyszer használható meghívón keresztül + Kapcsolódás az egyszer használható meghívón keresztül new chat sheet title @@ -1960,7 +1970,7 @@ Ez a saját egyszer használható meghívója! Connected to desktop - Kapcsolódva a számítógéphez + Társítva a számítógéppel No comment provided by engineer. @@ -1985,7 +1995,7 @@ Ez a saját egyszer használható meghívója! Connecting to desktop - Kapcsolódás a számítógéphez + Társítás számítógéppel No comment provided by engineer. @@ -2013,6 +2023,10 @@ Ez a saját egyszer használható meghívója! Kapcsolódási hiba (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2212,7 +2226,7 @@ Ez a saját egyszer használható meghívója! Create new profile in [desktop app](https://simplex.chat/downloads/). 💻 - Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻 + Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻 No comment provided by engineer. @@ -2456,7 +2470,7 @@ swipe action Delete all files - Az összes fájl törlése + Összes fájl törlése No comment provided by engineer. @@ -2579,6 +2593,16 @@ swipe action Törli a tag üzenetét? No comment provided by engineer. + + Delete member messages + Tag üzeneteinek törlése + No comment provided by engineer. + + + Delete member messages? + Törli a tag üzeneteit? + alert title + Delete message? Törli az üzenetet? @@ -2587,7 +2611,8 @@ swipe action Delete messages Üzenetek törlése - alert button + alert action +alert button Delete messages after @@ -2706,7 +2731,7 @@ swipe action Desktop app version %@ is not compatible with this app. - A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással. No comment provided by engineer. @@ -2881,7 +2906,7 @@ swipe action Do NOT use private routing. - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. No comment provided by engineer. @@ -2911,7 +2936,7 @@ swipe action Don't enable - Ne engedélyezze + Nem engedélyezem No comment provided by engineer. @@ -2921,7 +2946,7 @@ swipe action Don't show again - Ne mutasd újra + Ne jelenjen meg újra alert action @@ -2992,7 +3017,7 @@ chat item action E2E encrypted notifications. - Végpontok közötti titkosított értesítések. + Végpontok között titkosított értesítések. No comment provided by engineer. @@ -3102,7 +3127,7 @@ chat item action Encrypt - Titkosít + Titkosítás No comment provided by engineer. @@ -3632,7 +3657,7 @@ chat item action Error verifying passphrase: - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: No comment provided by engineer. @@ -3851,6 +3876,11 @@ snd error text A fájlok és a médiatartalmak küldése le van tiltva! No comment provided by engineer. + + Filter + Szűrő + No comment provided by engineer. + Filter unread and favorite chats. Olvasatlan és kedvenc csevegésekre való szűrés. @@ -4272,7 +4302,7 @@ Hiba: %2$@ How to - Hogyan + Útmutató No comment provided by engineer. @@ -4315,9 +4345,13 @@ Hiba: %2$@ Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). + Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése). No comment provided by engineer. @@ -4335,6 +4369,11 @@ Hiba: %2$@ A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Images + Képek + No comment provided by engineer. + Immediately Azonnal @@ -4500,7 +4539,7 @@ További fejlesztések hamarosan! Instant push notifications will be hidden! - Az azonnali push-értesítések el lesznek rejtve! + Az azonnali leküldéses értesítések el lesznek rejtve! No comment provided by engineer. @@ -4594,6 +4633,11 @@ További fejlesztések hamarosan! Barátok meghívása No comment provided by engineer. + + Invite member + Tag meghívása + No comment provided by engineer. + Invite members Tagok meghívása @@ -4714,7 +4758,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Keep the app open to use it from desktop - A számítógépről való használathoz tartsd nyitva az alkalmazást + Alkalmazás megnyitva tartása a számítógépről való használathoz No comment provided by engineer. @@ -4804,7 +4848,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Link mobile and desktop apps! 🔗 - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 No comment provided by engineer. @@ -4817,6 +4861,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Társított számítógépek No comment provided by engineer. + + Links + Hivatkozások + No comment provided by engineer. + List Lista @@ -4894,7 +4943,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Mark verified - Hitelesítés + Megjelölés ellenőrzöttként No comment provided by engineer. @@ -4904,7 +4953,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Max 30 seconds, received instantly. - Max. 30 másodperc, azonnal érkezett. + Legfeljebb 30 másodperc, azonnal megérkezik. No comment provided by engineer. @@ -4942,6 +4991,11 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! A tag törölve lett – nem lehet elfogadni a kérést No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! + alert message + Member reports Tagok jelentései @@ -4965,12 +5019,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Member will be removed from chat - this cannot be undone! A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5044,7 +5098,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Message draft - Üzenetvázlat + Piszkozatok No comment provided by engineer. @@ -5159,7 +5213,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Messages were deleted after you selected them. - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiváasztotta őket. alert message @@ -5209,7 +5263,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration complete - Átköltöztetés befejezve + Átköltöztetés kész No comment provided by engineer. @@ -5219,12 +5273,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). - Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). + Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. Migration is completed - Az átköltöztetés befejeződött + Az átköltöztetés elkészült No comment provided by engineer. @@ -5374,12 +5428,12 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! New contact: - Új kapcsolat: + Új partner: notification New desktop app! - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! No comment provided by engineer. @@ -5464,7 +5518,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No contacts selected - Nincs partner kijelölve + Nincs partner kiválasztva No comment provided by engineer. @@ -5549,7 +5603,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! No push server - Helyi + Nincs kiszolgáló a leküldéses értesítésekhez No comment provided by engineer. @@ -5604,7 +5658,7 @@ Ez a saját hivatkozása a(z) %@ nevű csoporthoz! Nothing selected - Nincs semmi kijelölve + Nincs semmi kiválasztva No comment provided by engineer. @@ -5739,17 +5793,17 @@ VPN engedélyezése szükséges. Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours) - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) No comment provided by engineer. Only you can make calls. - Csak Ön tud hívásokat indítani. + Csak Ön kezdeményezhet hívásokat. No comment provided by engineer. Only you can send disappearing messages. - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. No comment provided by engineer. @@ -5759,7 +5813,7 @@ VPN engedélyezése szükséges. Only you can send voice messages. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. No comment provided by engineer. @@ -5769,17 +5823,17 @@ VPN engedélyezése szükséges. Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours) - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) No comment provided by engineer. Only your contact can make calls. - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. No comment provided by engineer. Only your contact can send disappearing messages. - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. No comment provided by engineer. @@ -5789,7 +5843,7 @@ VPN engedélyezése szükséges. Only your contact can send voice messages. - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. No comment provided by engineer. @@ -6100,7 +6154,7 @@ Hiba: %@ Please restart the app and migrate the database to enable push notifications. - Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez. + Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez. No comment provided by engineer. @@ -6125,7 +6179,7 @@ Hiba: %@ Please wait for token activation to complete. - Várjon, amíg a token aktiválása befejeződik. + Várjon, amíg a token aktiválása elkészül. token info @@ -6285,7 +6339,7 @@ Hiba: %@ Prohibit reporting messages to moderators. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. No comment provided by engineer. @@ -6367,12 +6421,12 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Push notifications - Push-értesítések + Leküldéses értesítések No comment provided by engineer. Push server - Push-kiszolgáló + Leküldéses értesítéskiszolgáló No comment provided by engineer. @@ -6382,7 +6436,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. Rate the app - Értékelje az alkalmazást + Alkalmazás értékelése No comment provided by engineer. @@ -6392,7 +6446,7 @@ Engedélyezze a *Hálózat és kiszolgálók* menüben. React… - Reagálj… + Reagálás… chat item menu @@ -6569,7 +6623,7 @@ swipe action Reject (sender NOT notified) - Elutasítás (a kérés küldője NEM fog értesítést kapni) + Elutasítás (a kérés küldője NEM lesz értesítve) No comment provided by engineer. @@ -6595,7 +6649,12 @@ swipe action Remove Eltávolítás - No comment provided by engineer. + alert action + + + Remove and delete messages + Eltávolítás és az üzeneteinek törlése + alert action Remove archive? @@ -6620,7 +6679,7 @@ swipe action Remove member? Eltávolítja a tagot? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6739,7 +6798,7 @@ swipe action Reset all statistics - Az összes statisztika visszaállítása + Összes statisztika visszaállítása No comment provided by engineer. @@ -7038,11 +7097,36 @@ chat item action A keresősáv elfogadja a meghívási hivatkozásokat. No comment provided by engineer. + + Search files + Fájlok keresése + No comment provided by engineer. + + + Search images + Képek keresése + No comment provided by engineer. + + + Search links + Hivatkozások keresése + No comment provided by engineer. + Search or paste SimpleX link Keresés vagy SimpleX-hivatkozás beillesztése No comment provided by engineer. + + Search videos + Videók keresése + No comment provided by engineer. + + + Search voice messages + Hangüzenetek keresése + No comment provided by engineer. + Secondary Másodlagos szín @@ -7060,7 +7144,7 @@ chat item action Security assessment - Biztonsági kiértékelés + Biztonsági felmérés No comment provided by engineer. @@ -7070,22 +7154,22 @@ chat item action Select - Kijelölés + Kiválasztás chat item action Select chat profile - Csevegési profil kijelölése + Csevegési profil kiválasztása No comment provided by engineer. Selected %lld - %lld kijelölve + %lld kiválasztva No comment provided by engineer. Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. @@ -7240,7 +7324,7 @@ chat item action Sending receipts is disabled for %lld contacts - A kézbesítési jelentések le vannak tiltva %lld partnernél + A kézbesítési jelentések le vannak tiltva %lld partner számára No comment provided by engineer. @@ -7250,7 +7334,7 @@ chat item action Sending receipts is enabled for %lld contacts - A kézbesítési jelentések engedélyezve vannak %lld partnernél + A kézbesítési jelentések engedélyezve vannak %lld partner számára No comment provided by engineer. @@ -7395,7 +7479,7 @@ chat item action Session code - Munkamenet kód + Munkamenet kódja No comment provided by engineer. @@ -7455,7 +7539,7 @@ chat item action Set profile bio and welcome message. - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. No comment provided by engineer. @@ -7681,7 +7765,7 @@ chat item action SimpleX address settings - Beállítások automatikus elfogadása + SimpleX-címbeállítások alert title @@ -7756,7 +7840,7 @@ chat item action Small groups (max 20) - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) No comment provided by engineer. @@ -7809,7 +7893,7 @@ report reason Start chat - Csevegés indítása + Csevegés elindítása No comment provided by engineer. @@ -8206,12 +8290,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. The second tick we missed! ✅ - A második jelölés, amit kihagytunk! ✅ + A második pipa, ami már nagyon hiányzott! ✅ No comment provided by engineer. The sender will NOT be notified - A kérés küldője NEM fog értesítést kapni + A kérés küldője NEM lesz értesítve alert message @@ -8261,12 +8345,12 @@ Ez valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. No comment provided by engineer. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. alert message @@ -8423,7 +8507,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To support instant push notifications the chat database has to be migrated. - Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. + Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges. No comment provided by engineer. @@ -8438,7 +8522,7 @@ A funkció bekapcsolása előtt a rendszer felszólítja a képernyőzár beáll To verify end-to-end encryption with your contact compare (or scan) the code on your devices. - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. No comment provided by engineer. @@ -8640,7 +8724,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Update database passphrase - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása No comment provided by engineer. @@ -8845,7 +8929,7 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso User selection - Felhasználó kijelölése + Felhasználó kiválasztása No comment provided by engineer. @@ -8860,37 +8944,37 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso Verify code with desktop - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen No comment provided by engineer. Verify connection - Kapcsolat hitelesítése + Kapcsolat ellenőrzése No comment provided by engineer. Verify connection security - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése No comment provided by engineer. Verify connections - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése No comment provided by engineer. Verify database passphrase - Az adatbázis jelmondatának hitelesítése + Adatbázis jelmondatának ellenőrzése No comment provided by engineer. Verify passphrase - Jelmondat hitelesítése + Jelmondat ellenőrzése No comment provided by engineer. Verify security code - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése No comment provided by engineer. @@ -8918,6 +9002,11 @@ A kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcso A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később! No comment provided by engineer. + + Videos + Videók + No comment provided by engineer. + Videos and files up to 1gb Videók és fájlok legfeljebb 1GB méretig @@ -9202,7 +9291,7 @@ Megismétli a csatlakozási kérést? You are not connected to the server used to receive messages from this connection (no subscription). - Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). subscription status explanation @@ -9287,7 +9376,7 @@ Megismétli a csatlakozási kérést? You can start chat via app Settings / Database or by restarting the app - A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával No comment provided by engineer. @@ -9322,7 +9411,7 @@ Megismétli a csatlakozási kérést? You could not be verified; please try again. - Nem sikerült hitelesíteni; próbálja meg újra. + Nem sikerült ellenőrizni; próbálja meg újra. No comment provided by engineer. @@ -9434,12 +9523,12 @@ Megismétli a kapcsolódási kérést? You will stop receiving messages from this chat. Chat history will be preserved. - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. No comment provided by engineer. You will stop receiving messages from this group. Chat history will be preserved. - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. No comment provided by engineer. @@ -9514,7 +9603,7 @@ Megismétli a kapcsolódási kérést? Your contact sent a file that is larger than currently supported maximum size (%@). - A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött. No comment provided by engineer. @@ -9524,7 +9613,7 @@ Megismétli a kapcsolódási kérést? Your contacts will remain connected. - A partnerei továbbra is kapcsolódva maradnak. + A partnereivel továbbra is kapcsolatban marad. No comment provided by engineer. @@ -9704,7 +9793,7 @@ Megismétli a kapcsolódási kérést? audio call (not e2e encrypted) - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -9805,7 +9894,7 @@ marked deleted chat item preview text complete - befejezett + kész No comment provided by engineer. @@ -9845,7 +9934,7 @@ marked deleted chat item preview text connecting call… - kapcsolódási hívás… + hívás kapcsolása… call status @@ -9880,12 +9969,12 @@ marked deleted chat item preview text contact has e2e encryption - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik No comment provided by engineer. contact has no e2e encryption - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással No comment provided by engineer. @@ -9981,7 +10070,7 @@ pref value e2e encrypted - e2e titkosított + végpontok között titkosított No comment provided by engineer. @@ -10041,12 +10130,12 @@ pref value ended - befejeződött + hívás vége No comment provided by engineer. ended call %@ - %@ hívása befejeződött + %@ hívása véget ért call status @@ -10059,6 +10148,10 @@ pref value lejárt No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded továbbított @@ -10091,12 +10184,12 @@ pref value iOS Keychain is used to securely store passphrase - it allows receiving push notifications. - Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications. - Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását. + Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását. No comment provided by engineer. @@ -10261,12 +10354,12 @@ pref value no e2e encryption - nincs e2e titkosítás + nincs végpontok közötti titkosítás No comment provided by engineer. no subscription - nincs előfizetés + nincs feliratkozás No comment provided by engineer. @@ -10354,12 +10447,12 @@ time to disappear received answer… - válasz fogadása… + válasz érkezett… No comment provided by engineer. received confirmation… - visszaigazolás fogadása… + visszaigazolás érkezett… No comment provided by engineer. @@ -10498,7 +10591,7 @@ utoljára fogadott üzenet: %2$@ starting… - indítás… + hívás indítása… No comment provided by engineer. @@ -10583,7 +10676,7 @@ utoljára fogadott üzenet: %2$@ video call (not e2e encrypted) - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) No comment provided by engineer. @@ -10838,12 +10931,12 @@ utoljára fogadott üzenet: %2$@ Comment - Hozzászólás + Megjegyzés No comment provided by engineer. Currently maximum supported file size is %@. - Jelenleg támogatott legnagyobb fájl méret: %@. + Jelenleg támogatott legnagyobb fájlméret: %@. No comment provided by engineer. @@ -10948,7 +11041,7 @@ utoljára fogadott üzenet: %2$@ Selected chat preferences prohibit this message. - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 5f057cd8bb..97061054e8 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -792,6 +792,11 @@ swipe action Tutti i membri del gruppo resteranno connessi. No comment provided by engineer. + + All messages + Tutti i messaggi + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti. @@ -1142,6 +1147,11 @@ swipe action Chiamate audio e video No comment provided by engineer. + + Audio call + Chiamata audio + No comment provided by engineer. + Audio/video calls Chiamate audio/video @@ -2013,6 +2023,10 @@ Questo è il tuo link una tantum! Errore di connessione (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2593,16 @@ swipe action Eliminare il messaggio del membro? No comment provided by engineer. + + Delete member messages + Elimina i messaggi del membro + No comment provided by engineer. + + + Delete member messages? + Eliminare i messaggi del membro? + alert title + Delete message? Eliminare il messaggio? @@ -2587,7 +2611,8 @@ swipe action Delete messages Elimina messaggi - alert button + alert action +alert button Delete messages after @@ -3851,6 +3876,11 @@ snd error text File e contenuti multimediali vietati! No comment provided by engineer. + + Filter + Filtro + No comment provided by engineer. + Filter unread and favorite chats. Filtra le chat non lette e preferite. @@ -4315,6 +4345,10 @@ Errore: %2$@ Se inserisci il tuo codice di autodistruzione mentre apri l'app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Se devi usare la chat adesso, tocca **Fallo più tardi** qui sotto (ti verrà offerto di migrare il database quando riavvii l'app). @@ -4335,6 +4369,11 @@ Errore: %2$@ L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi! No comment provided by engineer. + + Images + Immagini + No comment provided by engineer. + Immediately Immediatamente @@ -4594,6 +4633,11 @@ Altri miglioramenti sono in arrivo! Invita amici No comment provided by engineer. + + Invite member + Invita membro + No comment provided by engineer. + Invite members Invita membri @@ -4817,6 +4861,11 @@ Questo è il tuo link per il gruppo %@! Desktop collegati No comment provided by engineer. + + Links + Link + No comment provided by engineer. + List Elenco @@ -4942,6 +4991,11 @@ Questo è il tuo link per il gruppo %@! Il membro è eliminato - impossibile accettare la richiesta No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + I messaggi del membro verranno eliminati. Non è reversibile! + alert message + Member reports Segnalazioni dei membri @@ -4965,12 +5019,12 @@ Questo è il tuo link per il gruppo %@! Member will be removed from chat - this cannot be undone! Il membro verrà rimosso dalla chat, non è reversibile! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Il membro verrà rimosso dal gruppo, non è reversibile! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5854,7 +5908,7 @@ Richiede l'attivazione della VPN. Open new group - Apri un gruppo nuovo + Apri il nuovo gruppo new chat action @@ -6595,7 +6649,12 @@ swipe action Remove Rimuovi - No comment provided by engineer. + alert action + + + Remove and delete messages + Rimuovi ed elimina i messaggi + alert action Remove archive? @@ -6620,7 +6679,7 @@ swipe action Remove member? Rimuovere il membro? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7038,11 +7097,36 @@ chat item action La barra di ricerca accetta i link di invito. No comment provided by engineer. + + Search files + Cerca file + No comment provided by engineer. + + + Search images + Cerca immagini + No comment provided by engineer. + + + Search links + Cerca link + No comment provided by engineer. + Search or paste SimpleX link Cerca o incolla un link SimpleX No comment provided by engineer. + + Search videos + Cerca video + No comment provided by engineer. + + + Search voice messages + Cerca messaggi vocali + No comment provided by engineer. + Secondary Secondario @@ -8918,6 +9002,11 @@ Per connetterti, chiedi al tuo contatto di creare un altro link di connessione e Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi! No comment provided by engineer. + + Videos + Video + No comment provided by engineer. + Videos and files up to 1gb Video e file fino a 1 GB @@ -10059,6 +10148,10 @@ pref value scaduto No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded inoltrato diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index 9a42ab3f7e..ddec6c47f6 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -625,6 +625,7 @@ swipe action Active connections + アクティブな接続 No comment provided by engineer. @@ -634,10 +635,12 @@ swipe action Add friends + 友達を追加 No comment provided by engineer. Add list + リストを追加 No comment provided by engineer. @@ -661,6 +664,7 @@ swipe action Add team members + チームメンバーを追加 No comment provided by engineer. @@ -670,6 +674,7 @@ swipe action Add to list + リストに追加 No comment provided by engineer. @@ -719,6 +724,7 @@ swipe action Address settings + アドレス設定 No comment provided by engineer. @@ -742,6 +748,7 @@ swipe action All + すべて No comment provided by engineer. @@ -772,12 +779,17 @@ swipe action グループ全員の接続が継続します。 No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. All messages will be deleted - this cannot be undone! + すべてのメッセージが削除されます。この操作は元に戻せません! No comment provided by engineer. @@ -829,6 +841,7 @@ swipe action Allow calls? + 通話を許可しますか? No comment provided by engineer. @@ -838,6 +851,7 @@ swipe action Allow downgrade + ダウングレードを許可する No comment provided by engineer. @@ -969,6 +983,7 @@ swipe action Another reason + 他の理由 report reason @@ -1046,6 +1061,7 @@ swipe action Archive + アーカイブ No comment provided by engineer. @@ -1079,6 +1095,7 @@ swipe action Archived contacts + アーカイブされた連絡先 No comment provided by engineer. @@ -1100,6 +1117,10 @@ swipe action 音声通話とビデオ通話 No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls 音声/ビデオ通話 @@ -1350,6 +1371,7 @@ swipe action Can't change profile + プロフィールを変更できません alert title @@ -1375,6 +1397,7 @@ new chat action Cancel migration + 移行を中止する No comment provided by engineer. @@ -1384,6 +1407,7 @@ new chat action Cannot forward message + メッセージを転送できません No comment provided by engineer. @@ -1460,6 +1484,7 @@ set passcode view Chat + チャット No comment provided by engineer. @@ -1514,6 +1539,7 @@ set passcode view Chat list + チャット一覧 No comment provided by engineer. @@ -1570,6 +1596,7 @@ set passcode view Check messages every 20 min. + 20分おきにメッセージを確認する。 No comment provided by engineer. @@ -1881,6 +1908,10 @@ This is your own one-time link! 接続エラー (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2407,6 +2438,14 @@ swipe action メンバーのメッセージを削除しますか? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? メッセージを削除しますか? @@ -2415,7 +2454,8 @@ swipe action Delete messages メッセージを削除 - alert button + alert action +alert button Delete messages after @@ -3565,6 +3605,10 @@ snd error text ファイルとメディアは禁止されています! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. 未読とお気に入りをフィルターします。 @@ -3986,6 +4030,10 @@ Error: %2$@ アプリを開いているときに自己破壊パスコードを入力した場合: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 今すぐチャットを使用する必要がある場合は、下の **後で実行する**をタップしてください (アプリを再起動すると、データベースを移行するよう求められます)。 @@ -4006,6 +4054,10 @@ Error: %2$@ 連絡先がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately 即座に @@ -4241,6 +4293,10 @@ More improvements are coming soon! 友人を招待する No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members メンバーを招待する @@ -4448,6 +4504,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4563,6 +4623,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4583,12 +4647,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! メンバーをグループから除名する (※元に戻せません※)! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6045,7 +6109,11 @@ swipe action Remove 削除 - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6067,7 +6135,7 @@ swipe action Remove member? メンバーを除名しますか? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6442,10 +6510,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8117,6 +8205,10 @@ To connect, please ask your contact to create another connection link and check 動画は相手がオンラインになったら受信されます。しばらくお待ちください! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1GBまでのビデオとファイル @@ -9173,6 +9265,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 8e0cdee3ca..e12cb0a483 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -790,6 +790,10 @@ swipe action Alle groepsleden blijven verbonden. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Alle berichten en bestanden worden **end-to-end versleuteld** verzonden, met post-quantumbeveiliging in directe berichten. @@ -1138,6 +1142,10 @@ swipe action Audio en video oproepen No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Audio/video oproepen @@ -2001,6 +2009,10 @@ Dit is uw eigen eenmalige link! Verbindingsfout (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2565,6 +2577,14 @@ swipe action Bericht van lid verwijderen? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Verwijder bericht? @@ -2573,7 +2593,8 @@ swipe action Delete messages Verwijder berichten - alert button + alert action +alert button Delete messages after @@ -3825,6 +3846,10 @@ snd error text Bestanden en media niet toegestaan! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Filter ongelezen en favoriete chats. @@ -4285,6 +4310,10 @@ Fout: %2$@ Als u uw zelfvernietigings wachtwoord invoert tijdens het openen van de app: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Als u de chat nu wilt gebruiken, tikt u hieronder op **Doe het later** (u wordt aangeboden om de database te migreren wanneer u de app opnieuw start). @@ -4305,6 +4334,10 @@ Fout: %2$@ De afbeelding wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Onmiddellijk @@ -4564,6 +4597,10 @@ Binnenkort meer verbeteringen! Nodig vrienden uit No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Nodig leden uit @@ -4785,6 +4822,10 @@ Dit is jouw link voor groep %@! Gelinkte desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List Lijst @@ -4907,6 +4948,10 @@ Dit is jouw link voor groep %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Ledenrapporten @@ -4930,12 +4975,12 @@ Dit is jouw link voor groep %@! Member will be removed from chat - this cannot be undone! Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6544,7 +6589,11 @@ swipe action Remove Verwijderen - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6568,7 +6617,7 @@ swipe action Remove member? Lid verwijderen? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6982,11 +7031,31 @@ chat item action Zoekbalk accepteert uitnodigingslinks. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Zoeken of plak een SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Secundair @@ -8832,6 +8901,10 @@ Om verbinding te maken, vraagt u uw contact om een andere verbinding link te mak De video wordt ontvangen wanneer uw contact online is, even geduld a.u.b. of kijk later! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Video's en bestanden tot 1 GB @@ -9964,6 +10037,10 @@ pref value verlopen No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded doorgestuurd diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index fb46bcd1d9..ee17f807ba 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -498,7 +498,7 @@ time interval <p>Hi!</p> <p><a href="%@">Connect to me via SimpleX Chat</a></p> <p>Cześć!</p> -<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat.</a></p> +<p><a href="%@">Połącz się ze mną poprzez SimpleX Chat</a></p> email text @@ -568,10 +568,12 @@ swipe action Accept as member + Zaakceptuj jako członka alert action Accept as observer + Zaakceptuj jako obserwatora alert action @@ -586,6 +588,7 @@ swipe action Accept contact request + Zaakceptuj prośby o kontakt alert title @@ -601,6 +604,7 @@ swipe action Accept member + Zaakceptuj członka alert title @@ -645,6 +649,7 @@ swipe action Add message + Dodaj wiadomość placeholder for sending contact request @@ -787,6 +792,11 @@ swipe action Wszyscy członkowie grupy pozostaną połączeni. No comment provided by engineer. + + All messages + Wszystkie wiadomości + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich. @@ -819,6 +829,7 @@ swipe action All servers + Wszystkie serwery No comment provided by engineer. @@ -863,6 +874,7 @@ swipe action Allow files and media only if your contact allows them. + Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala. No comment provided by engineer. @@ -952,6 +964,7 @@ swipe action Allow your contacts to send files and media. + Pozwól kontaktom wysyłać pliki i media. No comment provided by engineer. @@ -1134,6 +1147,11 @@ swipe action Połączenia audio i wideo No comment provided by engineer. + + Audio call + Połączenie audio + No comment provided by engineer. + Audio/video calls Połączenia audio/wideo @@ -1216,6 +1234,7 @@ swipe action Better groups performance + Lepsze działanie grup No comment provided by engineer. @@ -1240,6 +1259,7 @@ swipe action Better privacy and security + Lepsza prywatność i bezpieczeństwo No comment provided by engineer. @@ -1254,10 +1274,12 @@ swipe action Bio + Bio No comment provided by engineer. Bio too large + Bio jest za długie alert title @@ -1312,6 +1334,7 @@ swipe action Bot + Bot No comment provided by engineer. @@ -1336,6 +1359,7 @@ swipe action Both you and your contact can send files and media. + Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media. No comment provided by engineer. @@ -1360,6 +1384,7 @@ swipe action Business connection + Kontakty biznesowe No comment provided by engineer. @@ -1376,6 +1401,9 @@ swipe action By using SimpleX Chat you agree to: - send only legal content in public groups. - respect other users – no spam. + Korzystając z SimpleX Chat, zgadzasz się: +- wysyłać tylko legalne treści w grupach publicznych. +- szanować innych użytkowników – nie spamować. No comment provided by engineer. @@ -1410,6 +1438,7 @@ swipe action Can't change profile + Nie można zmienić profilu alert title @@ -1471,6 +1500,7 @@ new chat action Change automatic message deletion? + Zmienić automatyczne usuwanie wiadomości? alert title @@ -1626,14 +1656,17 @@ set passcode view Chat with admins + Czatuj z administratorami chat toolbar Chat with member + Czatuj z członkiem No comment provided by engineer. Chat with members before they join. + Porozmawiaj z członkami, zanim dołączą. No comment provided by engineer. @@ -1643,6 +1676,7 @@ set passcode view Chats with members + Czaty z członkami No comment provided by engineer. @@ -1712,10 +1746,12 @@ set passcode view Clear group? + Wyczyścić grupę? No comment provided by engineer. Clear or delete group? + Wyczyścić lub usunąć grupę? No comment provided by engineer. @@ -1740,6 +1776,7 @@ set passcode view Community guidelines violation + Naruszenie zasad społeczności report reason @@ -1779,14 +1816,17 @@ set passcode view Conditions will be accepted for the operator(s): **%@**. + Warunki zostaną zaakceptowane dla operatora(-ów): **%@**. No comment provided by engineer. Conditions will be accepted on: %@. + Warunki zostaną zaakceptowane w dniu: %@. No comment provided by engineer. Conditions will be automatically accepted for enabled operators on: %@. + Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@. No comment provided by engineer. @@ -1796,6 +1836,7 @@ set passcode view Configure server operators + Skonfiguruj operatorów serwerów No comment provided by engineer. @@ -1850,6 +1891,7 @@ set passcode view Confirmed + Potwierdzony token status text @@ -1864,6 +1906,7 @@ set passcode view Connect faster! 🚀 + Połącz się szybciej! 🚀 No comment provided by engineer. @@ -1967,6 +2010,7 @@ To jest twój jednorazowy link! Connection blocked + Połączenie zablokowane No comment provided by engineer. @@ -1979,13 +2023,20 @@ To jest twój jednorazowy link! Błąd połączenia (UWIERZYTELNIANIE) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ + Połączenie zostało zablokowane przez operatora serwera: +%@ No comment provided by engineer. Connection not ready. + Połączenie nie jest gotowe. No comment provided by engineer. @@ -2000,10 +2051,12 @@ To jest twój jednorazowy link! Connection requires encryption renegotiation. + Połączenie wymaga renegocjacji szyfrowania. No comment provided by engineer. Connection security + Bezpieczeństwo połączenia No comment provided by engineer. @@ -2068,6 +2121,7 @@ To jest twój jednorazowy link! Contact requests from groups + Prośby o kontakt od grup No comment provided by engineer. @@ -2087,6 +2141,7 @@ To jest twój jednorazowy link! Content violates conditions of use + Treść narusza warunki użytkowania blocking reason @@ -2131,6 +2186,7 @@ To jest twój jednorazowy link! Create 1-time link + Utwórz jednorazowy link No comment provided by engineer. @@ -2165,6 +2221,7 @@ To jest twój jednorazowy link! Create list + Utwórz listę No comment provided by engineer. @@ -2184,6 +2241,7 @@ To jest twój jednorazowy link! Create your address + Utwórz swój adres No comment provided by engineer. @@ -2223,6 +2281,7 @@ To jest twój jednorazowy link! Current conditions text couldn't be loaded, you can review conditions via this link: + Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link: No comment provided by engineer. @@ -2247,6 +2306,7 @@ To jest twój jednorazowy link! Customizable message shape. + Konfigurowalny kształt wiadomości. No comment provided by engineer. @@ -2420,10 +2480,12 @@ swipe action Delete chat + Usuń czat No comment provided by engineer. Delete chat messages from your device. + Usuń wiadomości czatu ze swojego urządzenia. No comment provided by engineer. @@ -2438,10 +2500,12 @@ swipe action Delete chat with member? + Usunąć czat z członkiem? alert title Delete chat? + Usunąć czat? No comment provided by engineer. @@ -2521,6 +2585,7 @@ swipe action Delete list? + Usunąć listę? alert title @@ -2528,6 +2593,16 @@ swipe action Usunąć wiadomość członka? No comment provided by engineer. + + Delete member messages + Usuń wiadomości członków + No comment provided by engineer. + + + Delete member messages? + Usunąć wiadomości członków? + alert title + Delete message? Usunąć wiadomość? @@ -2536,7 +2611,8 @@ swipe action Delete messages Usuń wiadomości - alert button + alert action +alert button Delete messages after @@ -2555,6 +2631,7 @@ swipe action Delete or moderate up to 200 messages. + Usuń lub moderuj do 200 wiadomości. No comment provided by engineer. @@ -2574,6 +2651,7 @@ swipe action Delete report + Usuń raport No comment provided by engineer. @@ -2613,6 +2691,7 @@ swipe action Delivered even when Apple drops them. + Dostarczane nawet wtedy, gdy Apple je wycofa. No comment provided by engineer. @@ -2632,6 +2711,7 @@ swipe action Deprecated options + Opcje wycofane No comment provided by engineer. @@ -2641,6 +2721,7 @@ swipe action Description too large + Opis jest zbyt długi alert title @@ -2725,6 +2806,7 @@ swipe action Direct messages between members are prohibited in this chat. + W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami. No comment provided by engineer. @@ -2744,10 +2826,12 @@ swipe action Disable automatic message deletion? + Wyłączyć automatyczne usuwanie wiadomości? alert title Disable delete messages + Wyłącz usuwanie wiadomości alert button @@ -2842,6 +2926,7 @@ swipe action Documents: + Dokumenty: No comment provided by engineer. @@ -2856,6 +2941,7 @@ swipe action Don't miss important messages. + Nie przegap ważnych wiadomości. No comment provided by engineer. @@ -2865,6 +2951,7 @@ swipe action Done + Gotowe No comment provided by engineer. @@ -2930,6 +3017,7 @@ chat item action E2E encrypted notifications. + Powiadomienia szyfrowane E2E. No comment provided by engineer. @@ -2944,6 +3032,7 @@ chat item action Empty message! + Pusta wiadomość! No comment provided by engineer. @@ -2958,6 +3047,7 @@ chat item action Enable Flux in Network & servers settings for better metadata privacy. + Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych. No comment provided by engineer. @@ -2982,6 +3072,7 @@ chat item action Enable disappearing messages by default. + Włącz domyślnie znikające wiadomości. No comment provided by engineer. @@ -3106,6 +3197,7 @@ chat item action Encryption renegotiation in progress. + Trwa renegocjacja szyfrowania. No comment provided by engineer. @@ -3175,6 +3267,7 @@ chat item action Error accepting conditions + Błąd podczas akceptacji warunków alert title @@ -3184,6 +3277,7 @@ chat item action Error accepting member + Błąd podczas akceptacji członka alert title @@ -3193,10 +3287,12 @@ chat item action Error adding server + Błąd podczas dodawania serwera alert title Error adding short link + Błąd dodawania krótkiego linku No comment provided by engineer. @@ -3206,6 +3302,7 @@ chat item action Error changing chat profile + Błąd zmiany profilu czatu alert title @@ -3230,6 +3327,7 @@ chat item action Error checking token status + Błąd sprawdzania statusu tokenu No comment provided by engineer. @@ -3239,6 +3337,7 @@ chat item action Error connecting to the server used to receive messages from this connection: %@ + Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@ subscription status explanation @@ -3258,6 +3357,7 @@ chat item action Error creating list + Błąd tworzenia listy alert title @@ -3277,6 +3377,7 @@ chat item action Error creating report + Błąd tworzenia raportu No comment provided by engineer. @@ -3286,6 +3387,7 @@ chat item action Error deleting chat + Błąd usuwania czatu alert title @@ -3365,6 +3467,7 @@ chat item action Error loading servers + Błąd ładowania serwerów alert title @@ -3379,6 +3482,7 @@ chat item action Error opening group + Błąd otwierania grupy No comment provided by engineer. @@ -3398,10 +3502,12 @@ chat item action Error registering for notifications + Błąd rejestracji powiadomień alert title Error rejecting contact request + Błąd odrzucenia prośby o kontakt alert title @@ -3411,6 +3517,7 @@ chat item action Error reordering lists + Błąd ponownego porządkowania list alert title @@ -3425,6 +3532,7 @@ chat item action Error saving chat list + Błąd zapisywania listy czatów alert title @@ -3444,6 +3552,7 @@ chat item action Error saving servers + Błąd zapisywania serwerów alert title @@ -3478,6 +3587,7 @@ chat item action Error setting auto-accept + Błąd ustawiania automatycznego akceptowania No comment provided by engineer. @@ -3512,6 +3622,7 @@ chat item action Error testing server connection + Błąd testowania połączenia z serwerem No comment provided by engineer. @@ -3526,6 +3637,7 @@ chat item action Error updating server + Błąd aktualizacji serwera alert title @@ -3562,6 +3674,7 @@ snd error text Error: %@. + Błąd: %@. server test error @@ -3581,6 +3694,7 @@ snd error text Errors in servers configuration. + Błędy w konfiguracji serwerów. servers error @@ -3600,6 +3714,7 @@ snd error text Expired + Wygasło token status text @@ -3644,6 +3759,7 @@ snd error text Faster deletion of groups. + Szybsze usuwanie grup. No comment provided by engineer. @@ -3653,6 +3769,7 @@ snd error text Faster sending messages. + Szybsze wysyłanie wiadomości. No comment provided by engineer. @@ -3662,6 +3779,7 @@ snd error text Favorites + Ulubione No comment provided by engineer. @@ -3679,6 +3797,8 @@ snd error text File is blocked by server operator: %@. + Plik jest zablokowany przez operatora serwera: +%@. file error text @@ -3738,6 +3858,7 @@ snd error text Files and media are prohibited in this chat. + W tym czacie nie wolno przesyłać plików ani multimediów. No comment provided by engineer. @@ -3755,6 +3876,11 @@ snd error text Pliki i media zabronione! No comment provided by engineer. + + Filter + Filtr + No comment provided by engineer. + Filter unread and favorite chats. Filtruj nieprzeczytane i ulubione czaty. @@ -3782,19 +3908,22 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy. server test error Fingerprint in server address does not match certificate: %@. + Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@. No comment provided by engineer. @@ -3829,10 +3958,12 @@ snd error text For all moderators + Dla wszystkich moderatorów No comment provided by engineer. For chat profile %@: + Dla profilu czatu %@: servers error @@ -3842,18 +3973,22 @@ snd error text For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server. + Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux. No comment provided by engineer. For me + Dla mnie No comment provided by engineer. For private routing + Dla prywatnego routingu No comment provided by engineer. For social media + Dla mediów społecznościowych No comment provided by engineer. @@ -3883,6 +4018,7 @@ snd error text Forward up to 20 messages at once. + Przekaż jednocześnie do 20 wiadomości. No comment provided by engineer. @@ -3971,6 +4107,7 @@ Błąd: %2$@ Get notified when mentioned. + Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie. No comment provided by engineer. @@ -4065,6 +4202,7 @@ Błąd: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy. alert message @@ -4084,6 +4222,7 @@ Błąd: %2$@ Groups + Grupy No comment provided by engineer. @@ -4093,6 +4232,7 @@ Błąd: %2$@ Help admins moderating their groups. + Pomóż administratorom moderować ich grupy. No comment provided by engineer. @@ -4147,14 +4287,17 @@ Błąd: %2$@ How it affects privacy + Jak to wpływa na prywatność No comment provided by engineer. How it helps privacy + Jak to pomaga chronić prywatność No comment provided by engineer. How it works + Jak to działa alert button @@ -4202,6 +4345,10 @@ Błąd: %2$@ Jeśli wpiszesz swój pin samodestrukcji podczas otwierania aplikacji: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Jeśli potrzebujesz użyć czatu teraz, dotknij **Zrób to później** poniżej (zostanie Ci zaproponowana migracja bazy danych po ponownym uruchomieniu aplikacji). @@ -4222,6 +4369,11 @@ Błąd: %2$@ Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Images + Zdjęcia + No comment provided by engineer. + Immediately Natychmiast @@ -4265,6 +4417,8 @@ Błąd: %2$@ Improved delivery, reduced traffic usage. More improvements are coming soon! + Ulepszona dostawa, mniejsze zużycie ruchu. +Wkrótce pojawią się kolejne ulepszenia! No comment provided by engineer. @@ -4299,10 +4453,12 @@ More improvements are coming soon! Inappropriate content + Nieodpowiednia treść report reason Inappropriate profile + Nieodpowiedni profil report reason @@ -4399,22 +4555,27 @@ More improvements are coming soon! Invalid + Nieprawidłowy token status text Invalid (bad token) + Nieprawidłowy (zły token) token status text Invalid (expired) + Nieważny (wygasły) token status text Invalid (unregistered) + Nieprawidłowy (niezarejestrowany) token status text Invalid (wrong topic) + Nieprawidłowy (niewłaściwy temat) token status text @@ -4472,6 +4633,11 @@ More improvements are coming soon! Zaproś znajomych No comment provided by engineer. + + Invite member + Zaproś członka + No comment provided by engineer. + Invite members Zaproś członków @@ -4479,6 +4645,7 @@ More improvements are coming soon! Invite to chat + Zaproś do czatu No comment provided by engineer. @@ -4601,6 +4768,7 @@ To jest twój link do grupy %@! Keep your chats clean + Utrzymuj czystość swoich czatów No comment provided by engineer. @@ -4640,10 +4808,12 @@ To jest twój link do grupy %@! Leave chat + Opuść czat No comment provided by engineer. Leave chat? + Opuścić czat? No comment provided by engineer. @@ -4658,6 +4828,7 @@ To jest twój link do grupy %@! Less traffic on mobile networks. + Mniejszy ruch w sieciach komórkowych. No comment provided by engineer. @@ -4690,16 +4861,24 @@ To jest twój link do grupy %@! Połączone komputery No comment provided by engineer. + + Links + Linki + No comment provided by engineer. + List + Lista swipe action List name and emoji should be different for all lists. + Nazwa listy i emoji powinny być różne dla wszystkich list. No comment provided by engineer. List name... + Nazwa listy... No comment provided by engineer. @@ -4714,6 +4893,7 @@ To jest twój link do grupy %@! Loading profile… + Ładowanie profilu… in progress text @@ -4793,10 +4973,12 @@ To jest twój link do grupy %@! Member %@ + Członek %@ past/unknown group member Member admission + Przyjmowanie członków No comment provided by engineer. @@ -4806,14 +4988,22 @@ To jest twój link do grupy %@! Member is deleted - can't accept request + Członek został usunięty – nie można zaakceptować prośby No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + Wiadomości członków zostaną usunięte – nie można tego cofnąć! + alert message + Member reports + Raporty członków chat feature Member role will be changed to "%@". All chat members will be notified. + Rola członka zostanie zmieniona na "%@". Wszyscy członkowie czatu zostaną o tym poinformowani. No comment provided by engineer. @@ -4828,15 +5018,17 @@ To jest twój link do grupy %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + Członek zostanie usunięty z czatu – nie można tego cofnąć! + alert message Member will be removed from group - this cannot be undone! Członek zostanie usunięty z grupy - nie można tego cofnąć! - No comment provided by engineer. + alert message Member will join the group, accept member? + Członek dołączy do grupy, zaakceptować członka? alert message @@ -4851,6 +5043,7 @@ To jest twój link do grupy %@! Members can report messsages to moderators. + Członkowie mogą zgłaszać wiadomości moderatorom. No comment provided by engineer. @@ -4880,6 +5073,7 @@ To jest twój link do grupy %@! Mention members 👋 + Wspomnij członków 👋 No comment provided by engineer. @@ -4914,6 +5108,7 @@ To jest twój link do grupy %@! Message instantly once you tap Connect. + Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”. No comment provided by engineer. @@ -4993,6 +5188,7 @@ To jest twój link do grupy %@! Messages are protected by **end-to-end encryption**. + Wiadomości są chronione przez **szyfrowanie typu end-to-end**. No comment provided by engineer. @@ -5002,6 +5198,7 @@ To jest twój link do grupy %@! Messages in this chat will never be deleted. + Wiadomości na tym czacie nigdy nie zostaną usunięte. alert message @@ -5106,6 +5303,7 @@ To jest twój link do grupy %@! More + Więcej swipe action @@ -5120,6 +5318,7 @@ To jest twój link do grupy %@! More reliable notifications + Bardziej niezawodne powiadomienia No comment provided by engineer. @@ -5139,6 +5338,7 @@ To jest twój link do grupy %@! Mute all + Wycisz wszystko notification label action @@ -5163,6 +5363,7 @@ To jest twój link do grupy %@! Network decentralization + Decentralizacja sieci No comment provided by engineer. @@ -5177,6 +5378,7 @@ To jest twój link do grupy %@! Network operator + Operator sieci No comment provided by engineer. @@ -5191,6 +5393,7 @@ To jest twój link do grupy %@! New + Nowy token status text @@ -5240,10 +5443,12 @@ To jest twój link do grupy %@! New events + Nowe wydarzenia notification New group role: Moderator + Nowa rola w grupie: Moderator No comment provided by engineer. @@ -5263,6 +5468,7 @@ To jest twój link do grupy %@! New member wants to join the group. + Nowy członek chce dołączyć do grupy. rcv group event chat item @@ -5277,6 +5483,7 @@ To jest twój link do grupy %@! New server + Nowy serwer No comment provided by engineer. @@ -5291,18 +5498,22 @@ To jest twój link do grupy %@! No chats + Żadnych czatów No comment provided by engineer. No chats found + Nie znaleziono żadnych czatów No comment provided by engineer. No chats in list %@ + Brak czatów na liście %@ No comment provided by engineer. No chats with members + Żadnych rozmów z członkami No comment provided by engineer. @@ -5352,14 +5563,17 @@ To jest twój link do grupy %@! No media & file servers. + Brak mediów i serwerów plików multimedialnych. servers error No message + Brak wiadomości No comment provided by engineer. No message servers. + Brak serwerów wiadomości. servers error @@ -5384,6 +5598,7 @@ To jest twój link do grupy %@! No private routing session + Brak prywatnej sesji routingu alert title @@ -5398,26 +5613,32 @@ To jest twój link do grupy %@! No servers for private message routing. + Brak serwerów prywatnej sesji routingu. servers error No servers to receive files. + Brak serwerów do otrzymania plików. servers error No servers to receive messages. + Brak serwerów aby otrzymać wiadomości. servers error No servers to send files. + Brak serwerów do wysyłania plików. servers error No token! + Brak tokenu! alert title No unread chats + Brak nieprzeczytanych czatów No comment provided by engineer. @@ -5432,6 +5653,7 @@ To jest twój link do grupy %@! Notes + Notatki No comment provided by engineer. @@ -5456,14 +5678,17 @@ To jest twój link do grupy %@! Notifications error + Błąd powiadomień alert title Notifications privacy + Prywatność powiadomień No comment provided by engineer. Notifications status + Stan powiadomień alert title @@ -5523,6 +5748,7 @@ Wymaga włączenia VPN. Only chat owners can change preferences. + Tylko właściciele czatu mogą zmieniać preferencje. No comment provided by engineer. @@ -5552,10 +5778,12 @@ Wymaga włączenia VPN. Only sender and moderators see it + Widzą to tylko nadawca i moderatorzy No comment provided by engineer. Only you and moderators see it + Widzisz to tylko Ty i moderatorzy No comment provided by engineer. @@ -5580,6 +5808,7 @@ Wymaga włączenia VPN. Only you can send files and media. + Tylko Ty możesz wysyłać pliki i multimedia. No comment provided by engineer. @@ -5609,6 +5838,7 @@ Wymaga włączenia VPN. Only your contact can send files and media. + Tylko Twój kontakt może wysyłać pliki i multimedia. No comment provided by engineer. @@ -5628,6 +5858,7 @@ Wymaga włączenia VPN. Open changes + Otwórz zmiany No comment provided by engineer. @@ -5642,14 +5873,17 @@ Wymaga włączenia VPN. Open clean link + Otwórz czysty link alert action Open conditions + Otwórz warunki No comment provided by engineer. Open full link + Otwórz pełny link alert action @@ -5659,6 +5893,7 @@ Wymaga włączenia VPN. Open link? + Otworzyć link? alert title @@ -5668,26 +5903,32 @@ Wymaga włączenia VPN. Open new chat + Otwórz nowy czat new chat action Open new group + Otwórz nową grupę new chat action Open to accept + Otwórz by zaakceptować No comment provided by engineer. Open to connect + Otwórz aby się połączyć No comment provided by engineer. Open to join + Otwórz aby dołączyć No comment provided by engineer. Open to use bot + Otwórz aby skorzystać z bota No comment provided by engineer. @@ -5697,14 +5938,17 @@ Wymaga włączenia VPN. Operator + Operator No comment provided by engineer. Operator server + Serwer Operatora alert title Or import archive file + Lub zaimportuj plik archiwalny No comment provided by engineer. @@ -5729,10 +5973,12 @@ Wymaga włączenia VPN. Or to share privately + Lub udostępnij prywatnie No comment provided by engineer. Organize chats into lists + Organizuj czaty jako listy No comment provided by engineer. @@ -5923,18 +6169,22 @@ Błąd: %@ Please try to disable and re-enable notfications. + Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia. token info Please wait for group moderators to review your request to join the group. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. snd group event chat item Please wait for token activation to complete. + Proszę poczekać na zakończenie aktywacji tokenu. token info Please wait for token to be registered. + Proszę poczekać na zarejestrowanie tokenu. token info @@ -5959,6 +6209,7 @@ Błąd: %@ Preset servers + Domyślne serwery No comment provided by engineer. @@ -5978,10 +6229,12 @@ Błąd: %@ Privacy for your customers. + Prywatność dla Twoich klientów. No comment provided by engineer. Privacy policy and conditions of use. + Polityka prywatności i warunki korzystania. No comment provided by engineer. @@ -5991,6 +6244,7 @@ Błąd: %@ Private chats, groups and your contacts are not accessible to server operators. + Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów. No comment provided by engineer. @@ -6000,6 +6254,7 @@ Błąd: %@ Private media file names. + Nazwy prywatnych plików multimedialnych. No comment provided by engineer. @@ -6029,6 +6284,7 @@ Błąd: %@ Private routing timeout + Limit czasu routingu prywatnego alert title @@ -6083,6 +6339,7 @@ Błąd: %@ Prohibit reporting messages to moderators. + Zabroń raportowania wiadomości moderatorom. No comment provided by engineer. @@ -6134,6 +6391,7 @@ Włącz w ustawianiach *Sieć i serwery* . Protocol background timeout + Limit czasu protokołu w tle No comment provided by engineer. @@ -6343,14 +6601,17 @@ Włącz w ustawianiach *Sieć i serwery* . Register + Zarejestruj No comment provided by engineer. Register notification token? + Zarejestrować token powiadomień? token info Registered + Zarejestrowany token status text @@ -6372,6 +6633,7 @@ swipe action Reject member? + Odrzucić członka? alert title @@ -6387,7 +6649,12 @@ swipe action Remove Usuń - No comment provided by engineer. + alert action + + + Remove and delete messages + Usuń i skasuj wiadomości + alert action Remove archive? @@ -6401,6 +6668,7 @@ swipe action Remove link tracking + Usuń śledzenie linków No comment provided by engineer. @@ -6411,7 +6679,7 @@ swipe action Remove member? Usunąć członka? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6420,6 +6688,7 @@ swipe action Removes messages and blocks members. + Usuwa wiadomości i blokuje członków. No comment provided by engineer. @@ -6459,46 +6728,57 @@ swipe action Report + Zgłoś chat item action Report content: only group moderators will see it. + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. report reason Report member profile: only group moderators will see it. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. report reason Report other: only group moderators will see it. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. report reason Report reason? + Jaki jest powód zgłoszenia? No comment provided by engineer. Report sent to moderators + Zgłoszenia wysłane do moderatorów alert title Report spam: only group moderators will see it. + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. report reason Report violation: only group moderators will see it. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. report reason Report: %@ + Zgłoszenie: %@ report in notification Reporting messages to moderators is prohibited. + Zgłaszanie wiadomości moderatorom jest zabronione. No comment provided by engineer. Reports + Zgłoszenia No comment provided by engineer. @@ -6588,18 +6868,22 @@ swipe action Review conditions + Przejrzyj warunki No comment provided by engineer. Review group members + Przejrzyj członków grupy No comment provided by engineer. Review members + Przejrzyj członków admission stage Review members before admitting ("knocking"). + Przejrzyj członków przed dopuszczeniem ("zapukaj"). admission stage description @@ -6660,10 +6944,12 @@ chat item action Save (and notify members) + Zapisz (i powiadom członków) alert button Save admission settings? + Zapisać ustawienia wstępu? alert title @@ -6693,10 +6979,12 @@ chat item action Save group profile? + Zapisać profil grupy? alert title Save list + Zapisz listę No comment provided by engineer. @@ -6809,11 +7097,36 @@ chat item action Pasek wyszukiwania akceptuje linki zaproszenia. No comment provided by engineer. + + Search files + Szukaj plików + No comment provided by engineer. + + + Search images + Szukaj zdjęć + No comment provided by engineer. + + + Search links + Szukaj linków + No comment provided by engineer. + Search or paste SimpleX link Wyszukaj lub wklej link SimpleX No comment provided by engineer. + + Search videos + Szukaj wideo + No comment provided by engineer. + + + Search voice messages + Szukaj wiadomości głosowych + No comment provided by engineer. + Secondary Drugorzędny @@ -6891,6 +7204,7 @@ chat item action Send contact request? + Wysłać prośbę o kontakt? No comment provided by engineer. @@ -6945,6 +7259,7 @@ chat item action Send private reports + Wyślij prywatne zgłoszenia No comment provided by engineer. @@ -6959,10 +7274,12 @@ chat item action Send request + Wyślij prośbę No comment provided by engineer. Send request without message + Wyślij prośbę bez wiadomości No comment provided by engineer. @@ -6977,6 +7294,7 @@ chat item action Send your private feedback to groups. + Wyślij swoją prywatną opinię do grup. No comment provided by engineer. @@ -7081,6 +7399,7 @@ chat item action Server added to operator %@. + Serwer został dodany do operatora %@. alert message @@ -7100,24 +7419,27 @@ chat item action Server operator changed. + Operator serwera został zmieniony. alert title Server operators + Operatorzy serwera No comment provided by engineer. Server protocol changed. + Protokół serwera zmieniony. alert title Server requires authorization to create queues, check password. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. server test error Server requires authorization to upload, check password. - Serwer wymaga autoryzacji do przesłania, sprawdź hasło + Serwer wymaga autoryzacji do przesłania, sprawdź hasło. server test error @@ -7167,6 +7489,7 @@ chat item action Set chat name… + Ustaw nazwę czatu… No comment provided by engineer. @@ -7191,10 +7514,12 @@ chat item action Set member admission + Ustaw przyjmowanie członków No comment provided by engineer. Set message expiration in chats. + Ustaw datę wygaśnięcia wiadomości na czatach. No comment provided by engineer. @@ -7214,6 +7539,7 @@ chat item action Set profile bio and welcome message. + Ustaw biografię profilu i wiadomość powitalną. No comment provided by engineer. @@ -7254,10 +7580,12 @@ chat item action Share 1-time link with a friend + Udostępnij jednorazowy link znajomemu No comment provided by engineer. Share SimpleX address on social media. + Udostępnij adres SimpleX w mediach społecznościowych. No comment provided by engineer. @@ -7267,6 +7595,7 @@ chat item action Share address publicly + Udostępnij adres publicznie No comment provided by engineer. @@ -7286,10 +7615,12 @@ chat item action Share old address + Udostępnij stary adres alert button Share old link + Udostępnij stary link alert button @@ -7314,18 +7645,22 @@ chat item action Share your address + Udostępnij swój adres No comment provided by engineer. Short SimpleX address + Krótki adres SimpleX No comment provided by engineer. Short description + Krótki opis No comment provided by engineer. Short link + Krótki link No comment provided by engineer. @@ -7385,6 +7720,7 @@ chat item action SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. No comment provided by engineer. @@ -7419,10 +7755,12 @@ chat item action SimpleX address and 1-time links are safe to share via any messenger. + Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator. No comment provided by engineer. SimpleX address or 1-time link? + Adres SimpleX czy link jednorazowy? No comment provided by engineer. @@ -7432,6 +7770,7 @@ chat item action SimpleX channel link + Link do kanału na SimpleX simplex link type @@ -7471,10 +7810,12 @@ chat item action SimpleX protocols reviewed by Trail of Bits. + Protokoły SimpleX sprawdzone przez Trail of Bits. No comment provided by engineer. SimpleX relay link + łącze przekaźnikowe SimpleX simplex link type @@ -7530,6 +7871,8 @@ chat item action Some servers failed the test: %@ + Niektóre serwery nie przeszły testu: +%@ alert message @@ -7539,6 +7882,7 @@ chat item action Spam + Spam blocking reason report reason @@ -7629,6 +7973,7 @@ report reason Storage + Magazyn No comment provided by engineer. @@ -7663,10 +8008,12 @@ report reason Switch audio and video during the call. + Przełączanie audio i wideo podczas połączenia. No comment provided by engineer. Switch chat profile for 1-time invitations. + Przełącz profil czatu dla zaproszeń jednorazowych. No comment provided by engineer. @@ -7686,6 +8033,7 @@ report reason TCP connection bg timeout + Przekroczono limit czasu połączenia TCP No comment provided by engineer. @@ -7695,6 +8043,7 @@ report reason TCP port for messaging + Port TCP dla wiadomości No comment provided by engineer. @@ -7724,22 +8073,27 @@ report reason Tap Connect to chat + Dotknij Połącz aby rozpocząć czat No comment provided by engineer. Tap Connect to send request + Dotknij Połącz, aby wysłać prośbę No comment provided by engineer. Tap Connect to use bot + Dotknij Połącz aby użyć bota No comment provided by engineer. Tap Create SimpleX address in the menu to create it later. + Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. No comment provided by engineer. Tap Join group + Dotknij Dołącz do grupy No comment provided by engineer. @@ -7789,6 +8143,7 @@ report reason Test notifications + Powiadomienia testowe No comment provided by engineer. @@ -7830,6 +8185,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The address will be short, and your profile will be shared via the address. + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. alert message @@ -7839,6 +8195,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The app protects your privacy by using different operators in each conversation. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. No comment provided by engineer. @@ -7858,6 +8215,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The connection reached the limit of undelivered messages, your contact may be offline. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. No comment provided by engineer. @@ -7892,6 +8250,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The link will be short, and group profile will be shared via the link. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. alert message @@ -7921,10 +8280,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The same conditions will apply to operator **%@**. + Te same warunki będą miały zastosowanie do operatora **%@**. No comment provided by engineer. The second preset operator in the app! + Drugi predefiniowany operator w aplikacji! No comment provided by engineer. @@ -7944,6 +8305,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom The servers for new files of your current chat profile **%@**. + Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**. No comment provided by engineer. @@ -7963,6 +8325,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom These conditions will also apply for: **%@**. + Warunki te będą miały również zastosowanie w przypadku: **%@**. No comment provided by engineer. @@ -7987,6 +8350,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. alert message @@ -8026,6 +8390,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. No comment provided by engineer. @@ -8035,6 +8400,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This message was deleted or not received yet. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. No comment provided by engineer. @@ -8044,10 +8410,12 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom This setting is for your current profile **%@**. + To ustawienie jest dla Twojego obecnego profilu **%@**. No comment provided by engineer. Time to disappear is set only for new contacts. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. No comment provided by engineer. @@ -8077,6 +8445,7 @@ Może się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skom To protect against your link being replaced, you can compare contact security codes. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. No comment provided by engineer. @@ -8103,6 +8472,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To receive + Żeby odebrać No comment provided by engineer. @@ -8127,10 +8497,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To send + Żeby wysłać No comment provided by engineer. To send commands you must be connected. + Aby wysyłać polecenia, musisz być podłączony. alert message @@ -8140,10 +8512,12 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. To use another profile after connection attempt, delete the chat and use the link again. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. alert message To use the servers of **%@**, accept conditions of use. + Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania. No comment provided by engineer. @@ -8163,6 +8537,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Token status: %@. + Stan tokena: %@. token status @@ -8187,6 +8562,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Trying to connect to the server used to receive messages from this connection. + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. subscription status explanation @@ -8236,6 +8612,7 @@ Przed włączeniem tej funkcji zostanie wyświetlony monit uwierzytelniania. Undelivered messages + Niedostarczone wiadomości No comment provided by engineer. @@ -8332,6 +8709,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Unsupported connection link + Nieobsługiwane łącze połączenia No comment provided by engineer. @@ -8361,6 +8739,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Updated conditions + Zaktualizowane warunki No comment provided by engineer. @@ -8370,14 +8749,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade + Zaktualizuj alert button Upgrade address + Uaktualnij adres No comment provided by engineer. Upgrade address? + Uaktualnić adres? alert message @@ -8387,14 +8769,17 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Upgrade group link? + Uaktualnić link do grupy? alert message Upgrade link + Uaktualnij link No comment provided by engineer. Upgrade your address + Zaktualizuj swój adres No comment provided by engineer. @@ -8429,6 +8814,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use %@ + Użyj %@ No comment provided by engineer. @@ -8448,10 +8834,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use TCP port %@ when no port is specified. + Jeśli nie podano portu, należy użyć portu TCP %@. No comment provided by engineer. Use TCP port 443 for preset servers only. + Używaj portu TCP 443 tylko dla domyślnych serwerów. No comment provided by engineer. @@ -8466,10 +8854,12 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use for files + Użyj dla plików No comment provided by engineer. Use for messages + Użyj dla wiadomości No comment provided by engineer. @@ -8489,6 +8879,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use incognito profile + Użyj profilu incognito No comment provided by engineer. @@ -8518,6 +8909,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use servers + Użyj serwerów No comment provided by engineer. @@ -8532,6 +8924,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Use web port + Użyj portu internetowego No comment provided by engineer. @@ -8609,6 +9002,11 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później! No comment provided by engineer. + + Videos + Wideo + No comment provided by engineer. + Videos and files up to 1gb Filmy i pliki do 1gb @@ -8616,6 +9014,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View conditions + Zobacz warunki No comment provided by engineer. @@ -8625,6 +9024,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc View updated conditions + Zobacz zaktualizowane warunki No comment provided by engineer. @@ -8724,6 +9124,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc Welcome your contacts 👋 + Powitaj swoje kontakty 👋 No comment provided by engineer. @@ -8743,6 +9144,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. No comment provided by engineer. @@ -8842,6 +9244,7 @@ Aby się połączyć, poproś Twój kontakt o utworzenie kolejnego linku połąc You are already connected with %@. + Zostałeś już połączony z %@. No comment provided by engineer. @@ -8878,6 +9281,7 @@ Powtórzyć prośbę dołączenia? You are connected to the server used to receive messages from this connection. + Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia. subscription status explanation @@ -8887,6 +9291,7 @@ Powtórzyć prośbę dołączenia? You are not connected to the server used to receive messages from this connection (no subscription). + Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji). subscription status explanation @@ -8906,6 +9311,7 @@ Powtórzyć prośbę dołączenia? You can configure servers via settings. + Serwery można skonfigurować w ustawieniach. No comment provided by engineer. @@ -8950,6 +9356,7 @@ Powtórzyć prośbę dołączenia? You can set connection name, to remember who the link was shared with. + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. No comment provided by engineer. @@ -8994,6 +9401,7 @@ Powtórzyć prośbę dołączenia? You can view your reports in Chat with admins. + Możesz przeglądać swoje raporty w czacie z administratorami. alert message @@ -9075,10 +9483,12 @@ Powtórzyć prośbę połączenia? You should receive notifications. + Powinieneś otrzymywać powiadomienia. token info You will be able to send messages **only after your request is accepted**. + Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**. No comment provided by engineer. @@ -9113,6 +9523,7 @@ Powtórzyć prośbę połączenia? You will stop receiving messages from this chat. Chat history will be preserved. + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. No comment provided by engineer. @@ -9147,6 +9558,7 @@ Powtórzyć prośbę połączenia? Your business contact + Twój kontakt biznesowy No comment provided by engineer. @@ -9176,6 +9588,7 @@ Powtórzyć prośbę połączenia? Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile. + Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd. alert message @@ -9185,6 +9598,7 @@ Powtórzyć prośbę połączenia? Your contact + Twój kontakt No comment provided by engineer. @@ -9219,6 +9633,7 @@ Powtórzyć prośbę połączenia? Your group + Twoja grupa No comment provided by engineer. @@ -9308,6 +9723,7 @@ Powtórzyć prośbę połączenia? accepted %@ + zaakceptowano %@ rcv group event chat item @@ -9317,10 +9733,12 @@ Powtórzyć prośbę połączenia? accepted invitation + zaproszenie zaakceptowane chat list item title accepted you + przyjął cię rcv group event chat item @@ -9345,6 +9763,7 @@ Powtórzyć prośbę połączenia? all + wszystkie member criteria value @@ -9364,6 +9783,7 @@ Powtórzyć prośbę połączenia? archived report + zarchiwizowany raport No comment provided by engineer. @@ -9434,6 +9854,7 @@ marked deleted chat item preview text can't send messages + nie można wysłać wiadomości No comment provided by engineer. @@ -9538,10 +9959,12 @@ marked deleted chat item preview text contact deleted + kontakt usunięty No comment provided by engineer. contact disabled + kontakt wyłączony No comment provided by engineer. @@ -9556,10 +9979,12 @@ marked deleted chat item preview text contact not ready + kontakt nie gotowy No comment provided by engineer. contact should accept… + kontakt powinien zaakceptować… No comment provided by engineer. @@ -9723,6 +10148,10 @@ pref value wygasły No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded przekazane dalej @@ -9730,6 +10159,7 @@ pref value group + grupa shown on group welcome message @@ -9739,6 +10169,7 @@ pref value group is deleted + grupa została usunięta No comment provided by engineer. @@ -9863,6 +10294,7 @@ pref value member has old version + członek posiada starą wersję No comment provided by engineer. @@ -9897,6 +10329,7 @@ pref value moderator + moderator member role @@ -9926,6 +10359,7 @@ pref value no subscription + brak subskrypcji No comment provided by engineer. @@ -9935,6 +10369,7 @@ pref value not synchronized + nie zsynchronizowano No comment provided by engineer. @@ -9992,14 +10427,17 @@ time to disappear pending + oczekuje No comment provided by engineer. pending approval + oczekuje na zatwierdzenie No comment provided by engineer. pending review + oczekuje na ocenę No comment provided by engineer. @@ -10019,6 +10457,7 @@ time to disappear rejected + odrzucono No comment provided by engineer. @@ -10043,6 +10482,7 @@ time to disappear removed from group + usunięty z grupy No comment provided by engineer. @@ -10057,30 +10497,37 @@ time to disappear request is sent + prośba została wysłana No comment provided by engineer. request to join rejected + prośba o dołączenie została odrzucona No comment provided by engineer. requested connection + prośba o połączenie rcv group event chat item requested connection from group %@ + prośba o połączenie od grupy %@ rcv direct event chat item requested to connect + poproszono o połączenie chat list item title review + ocena No comment provided by engineer. reviewed by admins + sprawdzone przez administratorów No comment provided by engineer. @@ -10269,6 +10716,7 @@ ostatnia otrzymana wiadomość: %2$@ you accepted this member + zaakceptowałeś tego członka snd group event chat item @@ -10404,22 +10852,27 @@ ostatnia otrzymana wiadomość: %2$@ %d new events + %d nowych wydarzeń notification body From %d chat(s) + Z %d czatu(ów) notification body From: %@ + Od: %@ notification body New events + Nowe wydarzenia notification New messages + Nowe wiadomości notification diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index d885db1350..d9a5c48dda 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -167,7 +167,7 @@ %d hours - %d час. + %d ч. time interval @@ -792,6 +792,10 @@ swipe action Все члены группы останутся соединены. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Все сообщения и файлы отправляются с **end-to-end шифрованием**, с постквантовой безопасностью в прямых разговорах. @@ -1134,7 +1138,7 @@ swipe action Audio & video calls - Аудио- и видеозвонки + Аудио и видеозвонки No comment provided by engineer. @@ -1142,6 +1146,10 @@ swipe action Аудио и видео звонки No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудио/видео звонки @@ -2013,6 +2021,10 @@ This is your own one-time link! Ошибка соединения (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2591,14 @@ swipe action Удалить сообщение участника? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Удалить сообщение? @@ -2587,7 +2607,8 @@ swipe action Delete messages Удалить сообщения - alert button + alert action +alert button Delete messages after @@ -3312,6 +3333,7 @@ chat item action Error connecting to the server used to receive messages from this connection: %@ + Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@ subscription status explanation @@ -3850,6 +3872,10 @@ snd error text Файлы и медиа запрещены! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Фильтровать непрочитанные и избранные чаты. @@ -4314,6 +4340,10 @@ Error: %2$@ Если Вы введёте код самоуничтожения при открытии приложения: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Если сейчас Вам нужно использовать чат, нажмите **Отложить** внизу (Вы сможете мигрировать данные чата при следующем запуске приложения). @@ -4334,6 +4364,10 @@ Error: %2$@ Изображение будет принято, когда Ваш контакт будет в сети, подождите или проверьте позже! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Сразу @@ -4592,6 +4626,10 @@ More improvements are coming soon! Пригласить друзей No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Пригласить членов группы @@ -4815,6 +4853,10 @@ This is your link for group %@! Связанные компьютеры No comment provided by engineer. + + Links + No comment provided by engineer. + List Список @@ -4940,6 +4982,10 @@ This is your link for group %@! Член группы удалён - невозможно принять запрос No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Сообщения о нарушениях @@ -4963,12 +5009,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Член будет удален из разговора - это действие нельзя отменить! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Член группы будет удален - это действие нельзя отменить! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6593,7 +6639,11 @@ swipe action Remove Удалить - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6618,7 +6668,7 @@ swipe action Remove member? Удалить члена группы? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7036,11 +7086,31 @@ chat item action Поле поиска поддерживает ссылки-приглашения. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Искать или вставьте ссылку SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Вторичный @@ -8411,7 +8481,7 @@ You will be prompted to complete authentication before this feature is enabled.< To send - Для оправки + Для отправки No comment provided by engineer. @@ -8476,6 +8546,7 @@ You will be prompted to complete authentication before this feature is enabled.< Trying to connect to the server used to receive messages from this connection. + Попытка подключиться к серверу, используемому для получения сообщений от этого соединения. subscription status explanation @@ -8915,6 +8986,10 @@ To connect, please ask your contact to create another connection link and check Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Видео и файлы до 1гб @@ -9189,6 +9264,7 @@ Repeat join request? You are connected to the server used to receive messages from this connection. + Вы подключены к серверу, используемому для приема сообщений от этого соединения. subscription status explanation @@ -9198,6 +9274,7 @@ Repeat join request? You are not connected to the server used to receive messages from this connection (no subscription). + Вы не подключены к серверу, используемому для получения сообщений по этому соединению (нет подписки). subscription status explanation @@ -10054,6 +10131,10 @@ pref value истекло No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded переслано @@ -10261,6 +10342,7 @@ pref value no subscription + нет подписки No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index ecb4d20fbb..13d3240daf 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -718,6 +718,10 @@ swipe action สมาชิกในกลุ่มทุกคนจะยังคงเชื่อมต่ออยู่. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. No comment provided by engineer. @@ -1034,6 +1038,10 @@ swipe action การโทรด้วยเสียงและวิดีโอ No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls การโทรด้วยเสียง/วิดีโอ @@ -1797,6 +1805,10 @@ This is your own one-time link! การเชื่อมต่อผิดพลาด (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2317,6 +2329,14 @@ swipe action ลบข้อความสมาชิก? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? ลบข้อความ? @@ -2325,7 +2345,8 @@ swipe action Delete messages ลบข้อความ - alert button + alert action +alert button Delete messages after @@ -3468,6 +3489,10 @@ snd error text ไฟล์และสื่อต้องห้าม! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. กรองแชทที่ยังไม่อ่านและแชทโปรด @@ -3889,6 +3914,10 @@ Error: %2$@ หากคุณใส่รหัสผ่านทำลายตัวเองขณะเปิดแอป: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). หากคุณจำเป็นต้องใช้แชทตอนนี้ ให้แตะ **ทำในภายหลัง** ด้านล่าง (ระบบจะเสนอให้คุณย้ายฐานข้อมูลเมื่อคุณรีสตาร์ทแอป) @@ -3909,6 +3938,10 @@ Error: %2$@ จะได้รับรูปภาพเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately โดยทันที @@ -4142,6 +4175,10 @@ More improvements are coming soon! เชิญเพื่อนๆ No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members เชิญสมาชิก @@ -4349,6 +4386,10 @@ This is your link for group %@! Linked desktops No comment provided by engineer. + + Links + No comment provided by engineer. + List swipe action @@ -4464,6 +4505,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports chat feature @@ -4484,12 +4529,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -5936,7 +5981,11 @@ swipe action Remove ลบ - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -5958,7 +6007,7 @@ swipe action Remove member? ลบสมาชิกออก? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6333,10 +6382,30 @@ chat item action Search bar accepts invitation links. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary No comment provided by engineer. @@ -8008,6 +8077,10 @@ To connect, please ask your contact to create another connection link and check จะได้รับวิดีโอเมื่อผู้ติดต่อของคุณออนไลน์ โปรดรอหรือตรวจสอบในภายหลัง! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb วิดีโอและไฟล์สูงสุด 1gb @@ -9060,6 +9133,10 @@ pref value expired No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 57151a95b5..c97da9e0b5 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -792,6 +792,10 @@ swipe action Tüm grup üyeleri bağlı kalacaktır. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Bütün mesajlar ve dosyalar **uçtan-uca şifrelemeli** gönderilir, doğrudan mesajlarda kuantum güvenlik ile birlikte. @@ -1142,6 +1146,10 @@ swipe action Sesli ve görüntülü aramalar No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Sesli/görüntülü aramalar @@ -2013,6 +2021,10 @@ Bu senin kendi tek kullanımlık bağlantın! Bağlantı hatası (DOĞRULAMA) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2579,6 +2591,14 @@ swipe action Kişinin mesajı silinsin mi? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Mesaj silinsin mi? @@ -2587,7 +2607,8 @@ swipe action Delete messages Mesajları sil - alert button + alert action +alert button Delete messages after @@ -3849,6 +3870,10 @@ snd error text Dosyalar ve medya yasaklandı! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Favori ve okunmamış sohbetleri filtrele. @@ -4310,6 +4335,10 @@ Hata: %2$@ Uygulamayı açarken kendi kendini imha eden şifrenizi girerseniz: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Sohbeti şimdi kullanmanız gerekiyorsa aşağıdaki **Daha sonra yap** seçeneğine dokunun (uygulamayı yeniden başlattığınızda veritabanını taşımanız önerilecektir). @@ -4330,6 +4359,10 @@ Hata: %2$@ Kişi çevrimiçi olduğunda fotoğraf alınacaktır, lütfen bekleyin veya daha sonra kontrol et! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Hemen @@ -4589,6 +4622,10 @@ Daha fazla iyileştirme yakında geliyor! Arkadaşları davet et No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Üyeleri davet et @@ -4812,6 +4849,10 @@ Bu senin grup için bağlantın %@! Bağlanmış bilgisayarlar No comment provided by engineer. + + Links + No comment provided by engineer. + List Liste @@ -4937,6 +4978,10 @@ Bu senin grup için bağlantın %@! Üye silinmiş - istek kabul edilemez No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Üye raporları @@ -4960,12 +5005,12 @@ Bu senin grup için bağlantın %@! Member will be removed from chat - this cannot be undone! Üye sohbetten kaldırılacak - bu geri alınamaz! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Üye gruptan çıkarılacaktır - bu geri alınamaz! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6590,7 +6635,11 @@ swipe action Remove Sil - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6615,7 +6664,7 @@ swipe action Remove member? Kişi silinsin mi? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7033,11 +7082,31 @@ chat item action Arama çubuğu davet bağlantılarını kabul eder. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Ara veya SimpleX bağlantısını yapıştır No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary İkincil renk @@ -8912,6 +8981,10 @@ Bağlanmak için lütfen kişinizden başka bir bağlantı oluşturmasını iste Kişiniz çevrimiçi olduğunda video alınacaktır, lütfen bekleyin veya daha sonra kontrol edin! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb 1gb'a kadar videolar ve dosyalar @@ -10051,6 +10124,10 @@ pref value süresi dolmuş No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded iletildi diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index 7980685349..9cc95a6085 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -792,6 +792,10 @@ swipe action Всі учасники групи залишаться на зв'язку. No comment provided by engineer. + + All messages + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. Всі повідомлення та файли надсилаються **наскрізним шифруванням**, з пост-квантовим захистом у прямих повідомленнях. @@ -1140,6 +1144,10 @@ swipe action Аудіо та відеодзвінки No comment provided by engineer. + + Audio call + No comment provided by engineer. + Audio/video calls Аудіо/відео дзвінки @@ -2009,6 +2017,10 @@ This is your own one-time link! Помилка підключення (AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2574,6 +2586,14 @@ swipe action Видалити повідомлення учасника? No comment provided by engineer. + + Delete member messages + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? Видалити повідомлення? @@ -2582,7 +2602,8 @@ swipe action Delete messages Видалити повідомлення - alert button + alert action +alert button Delete messages after @@ -3841,6 +3862,10 @@ snd error text Файли та медіа заборонені! No comment provided by engineer. + + Filter + No comment provided by engineer. + Filter unread and favorite chats. Фільтруйте непрочитані та улюблені чати. @@ -4302,6 +4327,10 @@ Error: %2$@ Якщо ви введете пароль самознищення під час відкриття програми: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). Якщо вам потрібно скористатися чатом зараз, натисніть **Зробити це пізніше** нижче (вам буде запропоновано перенести базу даних при перезапуску програми). @@ -4322,6 +4351,10 @@ Error: %2$@ Зображення буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Images + No comment provided by engineer. + Immediately Негайно @@ -4581,6 +4614,10 @@ More improvements are coming soon! Запросити друзів No comment provided by engineer. + + Invite member + No comment provided by engineer. + Invite members Запросити учасників @@ -4804,6 +4841,10 @@ This is your link for group %@! Пов'язані робочі столи No comment provided by engineer. + + Links + No comment provided by engineer. + List Список @@ -4927,6 +4968,10 @@ This is your link for group %@! Member is deleted - can't accept request No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports Повідомлення учасників @@ -4950,12 +4995,12 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! Учасника буде видалено з чату – це неможливо скасувати! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! Учасник буде видалений з групи - це неможливо скасувати! - No comment provided by engineer. + alert message Member will join the group, accept member? @@ -6575,7 +6620,11 @@ swipe action Remove Видалити - No comment provided by engineer. + alert action + + + Remove and delete messages + alert action Remove archive? @@ -6599,7 +6648,7 @@ swipe action Remove member? Видалити учасника? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -7017,11 +7066,31 @@ chat item action Рядок пошуку приймає посилання-запрошення. No comment provided by engineer. + + Search files + No comment provided by engineer. + + + Search images + No comment provided by engineer. + + + Search links + No comment provided by engineer. + Search or paste SimpleX link Знайдіть або вставте посилання SimpleX No comment provided by engineer. + + Search videos + No comment provided by engineer. + + + Search voice messages + No comment provided by engineer. + Secondary Вторинний @@ -8892,6 +8961,10 @@ To connect, please ask your contact to create another connection link and check Відео буде отримано, коли ваш контакт буде онлайн, будь ласка, зачекайте або перевірте пізніше! No comment provided by engineer. + + Videos + No comment provided by engineer. + Videos and files up to 1gb Відео та файли до 1 Гб @@ -10031,6 +10104,10 @@ pref value закінчився No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded переслано diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index e1ce65b5ce..fbb118774a 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -568,10 +568,12 @@ swipe action Accept as member + 接受为成员 alert action Accept as observer + 接受为观察员 alert action @@ -586,6 +588,7 @@ swipe action Accept contact request + 接受联络请求 alert title @@ -601,6 +604,7 @@ swipe action Accept member + 接受成员 alert title @@ -645,6 +649,7 @@ swipe action Add message + 添加信息 placeholder for sending contact request @@ -787,6 +792,11 @@ swipe action 所有群组成员将保持连接。 No comment provided by engineer. + + All messages + 所有消息 + No comment provided by engineer. + All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages. 所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。 @@ -864,6 +874,7 @@ swipe action Allow files and media only if your contact allows them. + 只有你的联系人允许的情况下才允许文件和媒体。 No comment provided by engineer. @@ -953,6 +964,7 @@ swipe action Allow your contacts to send files and media. + 允许你的联系人发送文件和媒体。 No comment provided by engineer. @@ -1135,6 +1147,11 @@ swipe action 语音和视频通话 No comment provided by engineer. + + Audio call + 语音通话 + No comment provided by engineer. + Audio/video calls 音频/视频通话 @@ -1257,10 +1274,12 @@ swipe action Bio + 自我介绍 No comment provided by engineer. Bio too large + 自我介绍过大 alert title @@ -1315,6 +1334,7 @@ swipe action Bot + 机器人 No comment provided by engineer. @@ -1339,6 +1359,7 @@ swipe action Both you and your contact can send files and media. + 你和你的联系人都可发送文件和媒体。 No comment provided by engineer. @@ -1363,6 +1384,7 @@ swipe action Business connection + 企业连接 No comment provided by engineer. @@ -1416,6 +1438,7 @@ swipe action Can't change profile + 无法更改个人资料 alert title @@ -1633,14 +1656,17 @@ set passcode view Chat with admins + 和管理员聊天 chat toolbar Chat with member + 和成员聊天 No comment provided by engineer. Chat with members before they join. + 在成员加入前和这些人聊天 No comment provided by engineer. @@ -1650,6 +1676,7 @@ set passcode view Chats with members + 和成员聊天 No comment provided by engineer. @@ -1879,6 +1906,7 @@ set passcode view Connect faster! 🚀 + 更快地连接!🚀 No comment provided by engineer. @@ -1995,6 +2023,10 @@ This is your own one-time link! 连接错误(AUTH) No comment provided by engineer. + + Connection failed + No comment provided by engineer. + Connection is blocked by server operator: %@ @@ -2088,6 +2120,7 @@ This is your own one-time link! Contact requests from groups + 来自群的联络请求 No comment provided by engineer. @@ -2207,6 +2240,7 @@ This is your own one-time link! Create your address + 创建地址 No comment provided by engineer. @@ -2465,6 +2499,7 @@ swipe action Delete chat with member? + 删除和成员的聊天吗? alert title @@ -2557,6 +2592,15 @@ swipe action 删除成员消息? No comment provided by engineer. + + Delete member messages + 删除成员消息 + No comment provided by engineer. + + + Delete member messages? + alert title + Delete message? 删除消息吗? @@ -2565,7 +2609,8 @@ swipe action Delete messages 删除消息 - alert button + alert action +alert button Delete messages after @@ -2664,6 +2709,7 @@ swipe action Deprecated options + 已废弃的选项 No comment provided by engineer. @@ -2673,6 +2719,7 @@ swipe action Description too large + 描述过大 alert title @@ -2983,6 +3030,7 @@ chat item action Empty message! + 空消息! No comment provided by engineer. @@ -3022,6 +3070,7 @@ chat item action Enable disappearing messages by default. + 默认启用定时消失消息。 No comment provided by engineer. @@ -3226,6 +3275,7 @@ chat item action Error accepting member + 接受成员出错 alert title @@ -3240,6 +3290,7 @@ chat item action Error adding short link + 添加短链接出错 No comment provided by engineer. @@ -3249,6 +3300,7 @@ chat item action Error changing chat profile + 更改聊天资料出错 alert title @@ -3273,6 +3325,7 @@ chat item action Error checking token status + 查询token状态出错 No comment provided by engineer. @@ -3331,6 +3384,7 @@ chat item action Error deleting chat + 删除聊天出错 alert title @@ -3425,6 +3479,7 @@ chat item action Error opening group + 打开群时出错 No comment provided by engineer. @@ -3449,6 +3504,7 @@ chat item action Error rejecting contact request + 拒绝联络请求出错 alert title @@ -3528,6 +3584,7 @@ chat item action Error setting auto-accept + 设置自动接受出错 No comment provided by engineer. @@ -3614,6 +3671,7 @@ snd error text Error: %@. + 错误:%@。 server test error @@ -3797,6 +3855,7 @@ snd error text Files and media are prohibited in this chat. + 此聊天禁止文件和媒体。 No comment provided by engineer. @@ -3814,6 +3873,11 @@ snd error text 禁止文件和媒体! No comment provided by engineer. + + Filter + 过滤器 + No comment provided by engineer. + Filter unread and favorite chats. 过滤未读和收藏的聊天记录。 @@ -3841,10 +3905,12 @@ snd error text Fingerprint in destination server address does not match certificate: %@. + 目的地服务器的指纹与证书不符:%@。 No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + 转发服务器的指纹与证书不符:%@。 No comment provided by engineer. @@ -3854,6 +3920,7 @@ snd error text Fingerprint in server address does not match certificate: %@. + 服务器的指纹与证书不符:%@。 No comment provided by engineer. @@ -4132,6 +4199,7 @@ Error: %2$@ Group profile was changed. If you save it, the updated profile will be sent to group members. + 群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。 alert message @@ -4274,6 +4342,10 @@ Error: %2$@ 如果您在打开应用程序时输入自毁密码: No comment provided by engineer. + + If you joined or created channels, they will stop working permanently. + down migration warning + If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). 如果您现在需要使用聊天,请点击下面的**稍后再做**(当您重新启动应用程序时,系统会提示您迁移数据库)。 @@ -4294,6 +4366,11 @@ Error: %2$@ 图片将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Images + 图片 + No comment provided by engineer. + Immediately 立即 @@ -4553,6 +4630,11 @@ More improvements are coming soon! 邀请朋友 No comment provided by engineer. + + Invite member + 邀请成员 + No comment provided by engineer. + Invite members 邀请成员 @@ -4683,6 +4765,7 @@ This is your link for group %@! Keep your chats clean + 保持聊天洁净 No comment provided by engineer. @@ -4742,6 +4825,7 @@ This is your link for group %@! Less traffic on mobile networks. + 消耗更少的移动网络数据。 No comment provided by engineer. @@ -4774,6 +4858,11 @@ This is your link for group %@! 已链接桌面 No comment provided by engineer. + + Links + 链接 + No comment provided by engineer. + List 列表 @@ -4801,6 +4890,7 @@ This is your link for group %@! Loading profile… + 正加载个人资料… in progress text @@ -4880,10 +4970,12 @@ This is your link for group %@! Member %@ + 成员 %@ past/unknown group member Member admission + 成员准入 No comment provided by engineer. @@ -4893,8 +4985,13 @@ This is your link for group %@! Member is deleted - can't accept request + 成员被删除——无法接受请求 No comment provided by engineer. + + Member messages will be deleted - this cannot be undone! + alert message + Member reports 成员举报 @@ -4918,15 +5015,16 @@ This is your link for group %@! Member will be removed from chat - this cannot be undone! 将从聊天中删除成员 - 此操作无法撤销! - No comment provided by engineer. + alert message Member will be removed from group - this cannot be undone! 成员将被移出群组——此操作无法撤消! - No comment provided by engineer. + alert message Member will join the group, accept member? + 成员将加入本群,接受成员吗? alert message @@ -5006,6 +5104,7 @@ This is your link for group %@! Message instantly once you tap Connect. + 轻按连接后即刻发消息。 No comment provided by engineer. @@ -5085,6 +5184,7 @@ This is your link for group %@! Messages are protected by **end-to-end encryption**. + 消息已通过**端到端加密**保护。 No comment provided by engineer. @@ -5344,6 +5444,7 @@ This is your link for group %@! New group role: Moderator + 新的群角色:协管 No comment provided by engineer. @@ -5363,6 +5464,7 @@ This is your link for group %@! New member wants to join the group. + 新成员要加入本群。 rcv group event chat item @@ -5407,6 +5509,7 @@ This is your link for group %@! No chats with members + 没有和成员的聊天 No comment provided by engineer. @@ -5491,6 +5594,7 @@ This is your link for group %@! No private routing session + 无私密路由会话 alert title @@ -5700,6 +5804,7 @@ Requires compatible VPN. Only you can send files and media. + 只有你可以发送文件和媒体。 No comment provided by engineer. @@ -5729,6 +5834,7 @@ Requires compatible VPN. Only your contact can send files and media. + 只有你的联系人可以发送文件和媒体。 No comment provided by engineer. @@ -5763,6 +5869,7 @@ Requires compatible VPN. Open clean link + 打开干净链接 alert action @@ -5772,6 +5879,7 @@ Requires compatible VPN. Open full link + 打开完整链接 alert action @@ -5781,6 +5889,7 @@ Requires compatible VPN. Open link? + 打开链接? alert title @@ -5790,26 +5899,32 @@ Requires compatible VPN. Open new chat + 打开新聊天 new chat action Open new group + 打开新群 new chat action Open to accept + 打开以接受 No comment provided by engineer. Open to connect + 打开以连接 No comment provided by engineer. Open to join + 打开以加入 No comment provided by engineer. Open to use bot + 打开来使用机器人 No comment provided by engineer. @@ -5870,6 +5985,8 @@ Requires compatible VPN. Other file errors: %@ + 其他文件错误: +%@ alert message @@ -6048,18 +6165,22 @@ Error: %@ Please try to disable and re-enable notfications. + 请尝试禁用并重新启用通知。 token info Please wait for group moderators to review your request to join the group. + 请等待群的协管审核你加入该群的请求。 snd group event chat item Please wait for token activation to complete. + 请等待token激活完成。 token info Please wait for token to be registered. + 请等待token注册完成。 token info @@ -6069,6 +6190,7 @@ Error: %@ Port + 端口 No comment provided by engineer. @@ -6083,6 +6205,7 @@ Error: %@ Preset servers + 预设服务器 No comment provided by engineer. @@ -6102,6 +6225,7 @@ Error: %@ Privacy for your customers. + 客户隐私。 No comment provided by engineer. @@ -6126,6 +6250,7 @@ Error: %@ Private media file names. + 私密媒体文件名。 No comment provided by engineer. @@ -6155,6 +6280,7 @@ Error: %@ Private routing timeout + 私密路由超时 alert title @@ -6209,6 +6335,7 @@ Error: %@ Prohibit reporting messages to moderators. + 禁止向 协管 举报消息。 No comment provided by engineer. @@ -6260,6 +6387,7 @@ Enable in *Network & servers* settings. Protocol background timeout + 协议后台超时 No comment provided by engineer. @@ -6284,6 +6412,7 @@ Enable in *Network & servers* settings. Proxy requires password + 代理需要密码 No comment provided by engineer. @@ -6468,6 +6597,7 @@ Enable in *Network & servers* settings. Register + 注册 No comment provided by engineer. @@ -6476,6 +6606,7 @@ Enable in *Network & servers* settings. Registered + 已注册 token status text @@ -6497,6 +6628,7 @@ swipe action Reject member? + 拒绝成员? alert title @@ -6512,10 +6644,16 @@ swipe action Remove 移除 - No comment provided by engineer. + alert action + + + Remove and delete messages + 移除并删除消息 + alert action Remove archive? + 删除存档? No comment provided by engineer. @@ -6525,6 +6663,7 @@ swipe action Remove link tracking + 删除链接跟踪 No comment provided by engineer. @@ -6535,7 +6674,7 @@ swipe action Remove member? 删除成员吗? - No comment provided by engineer. + alert title Remove passphrase from keychain? @@ -6544,6 +6683,7 @@ swipe action Removes messages and blocks members. + 删除消息并封禁成员。 No comment provided by engineer. @@ -6583,46 +6723,57 @@ swipe action Report + 举报 chat item action Report content: only group moderators will see it. + 举报内容:仅协管会看到。 report reason Report member profile: only group moderators will see it. + 举报成员个人资料:仅协管会看到。 report reason Report other: only group moderators will see it. + 举报其他:仅协管会看到。 report reason Report reason? + 举报理由? No comment provided by engineer. Report sent to moderators + 举报已发送至 协管 alert title Report spam: only group moderators will see it. + 举报垃圾信息:仅协管会看到。 report reason Report violation: only group moderators will see it. + 举报违规:仅协管会看到。 report reason Report: %@ + 举报: %@ report in notification Reporting messages to moderators is prohibited. + 向协管举报消息已被禁止。 No comment provided by engineer. Reports + 举报 No comment provided by engineer. @@ -6717,10 +6868,12 @@ swipe action Review group members + 审核群成员 No comment provided by engineer. Review members + 审核成员 admission stage @@ -6759,6 +6912,7 @@ swipe action SOCKS proxy + SOCKS代理 No comment provided by engineer. @@ -6784,10 +6938,12 @@ chat item action Save (and notify members) + 保存(并通知成员) alert button Save admission settings? + 保存入群设置? alert title @@ -6817,6 +6973,7 @@ chat item action Save group profile? + 保存群资料? alert title @@ -6934,11 +7091,36 @@ chat item action 搜索栏接受邀请链接。 No comment provided by engineer. + + Search files + 搜索文件 + No comment provided by engineer. + + + Search images + 搜索图片 + No comment provided by engineer. + + + Search links + 搜索链接 + No comment provided by engineer. + Search or paste SimpleX link 搜索或粘贴 SimpleX 链接 No comment provided by engineer. + + Search videos + 搜索视频 + No comment provided by engineer. + + + Search voice messages + 搜索语音消息 + No comment provided by engineer. + Secondary 二级 @@ -6971,6 +7153,7 @@ chat item action Select chat profile + 选择聊天个人资料 No comment provided by engineer. @@ -7015,6 +7198,7 @@ chat item action Send contact request? + 发送联络请求? No comment provided by engineer. @@ -7069,6 +7253,7 @@ chat item action Send private reports + 发送私下举报 No comment provided by engineer. @@ -7083,10 +7268,12 @@ chat item action Send request + 发送请求 No comment provided by engineer. Send request without message + 发送无消息请求 No comment provided by engineer. @@ -7101,6 +7288,7 @@ chat item action Send your private feedback to groups. + 向群发送私密反馈。 No comment provided by engineer. @@ -7200,10 +7388,12 @@ chat item action Server + 服务器 No comment provided by engineer. Server added to operator %@. + 服务器已添加到运营方 %@。 alert message @@ -7223,14 +7413,17 @@ chat item action Server operator changed. + 服务器运营方已更改。 alert title Server operators + 服务器运营方 No comment provided by engineer. Server protocol changed. + 服务器协议已更改。 alert title @@ -7290,6 +7483,7 @@ chat item action Set chat name… + 设置聊天名称… No comment provided by engineer. @@ -7314,10 +7508,12 @@ chat item action Set member admission + 设置成员入群准许 No comment provided by engineer. Set message expiration in chats. + 在聊天中设置消息过期时间。 No comment provided by engineer. @@ -7337,6 +7533,7 @@ chat item action Set profile bio and welcome message. + 设置自我介绍和欢迎消息。 No comment provided by engineer. @@ -7356,6 +7553,7 @@ chat item action Settings were changed. + 设置已修改。 alert message @@ -7376,10 +7574,12 @@ chat item action Share 1-time link with a friend + 和一位好友分享一次性链接 No comment provided by engineer. Share SimpleX address on social media. + 在社媒上分享 SimpleX 地址。 No comment provided by engineer. @@ -7389,6 +7589,7 @@ chat item action Share address publicly + 公开分享地址 No comment provided by engineer. @@ -7408,14 +7609,17 @@ chat item action Share old address + 分享旧地址 alert button Share old link + 分享旧链接 alert button Share profile + 分享资料 No comment provided by engineer. @@ -7435,18 +7639,22 @@ chat item action Share your address + 分享地址 No comment provided by engineer. Short SimpleX address + SimpleX 短地址 No comment provided by engineer. Short description + 短描述 No comment provided by engineer. Short link + 短链接 No comment provided by engineer. @@ -7601,6 +7809,7 @@ chat item action SimpleX relay link + SimpleX 中继链接 simplex link type @@ -7656,6 +7865,8 @@ chat item action Some servers failed the test: %@ + 有服务器测试未通过: +%@ alert message @@ -7665,6 +7876,7 @@ chat item action Spam + 垃圾信息 blocking reason report reason @@ -7789,10 +8001,12 @@ report reason Switch audio and video during the call. + 通话期间切换音频和视频。 No comment provided by engineer. Switch chat profile for 1-time invitations. + 对一次性邀请切换聊天个人资料。 No comment provided by engineer. @@ -7812,6 +8026,7 @@ report reason TCP connection bg timeout + TCP 连接后台超时 No comment provided by engineer. @@ -7821,6 +8036,7 @@ report reason TCP port for messaging + 用于消息收发的 TCP 端口 No comment provided by engineer. @@ -7840,6 +8056,7 @@ report reason Tail + 尾部 No comment provided by engineer. @@ -7849,22 +8066,27 @@ report reason Tap Connect to chat + 轻按连接进行聊天 No comment provided by engineer. Tap Connect to send request + 轻按连接来发送请求 No comment provided by engineer. Tap Connect to use bot + 轻按“连接”使用机器人 No comment provided by engineer. Tap Create SimpleX address in the menu to create it later. + 要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址” No comment provided by engineer. Tap Join group + 轻按加入群 No comment provided by engineer. @@ -7914,6 +8136,7 @@ report reason Test notifications + 测试通知 No comment provided by engineer. @@ -7955,6 +8178,7 @@ It can happen because of some bug or when the connection is compromised. The address will be short, and your profile will be shared via the address. + 地址不会长,将通过该简短地址分享个人资料。 alert message @@ -7964,6 +8188,7 @@ It can happen because of some bug or when the connection is compromised. The app protects your privacy by using different operators in each conversation. + 应用通过在每个对话中使用不同运营方保护你的隐私。 No comment provided by engineer. @@ -7983,6 +8208,7 @@ It can happen because of some bug or when the connection is compromised. The connection reached the limit of undelivered messages, your contact may be offline. + 连接达到了未送达消息上限,你的联系人可能处于离线状态。 No comment provided by engineer. @@ -8017,6 +8243,7 @@ It can happen because of some bug or when the connection is compromised. The link will be short, and group profile will be shared via the link. + 链接不会长,群资料会通过短链接分享。 alert message @@ -8050,6 +8277,7 @@ It can happen because of some bug or when the connection is compromised. The second preset operator in the app! + 应用中的第二个预设运营方! No comment provided by engineer. @@ -8078,6 +8306,7 @@ It can happen because of some bug or when the connection is compromised. The uploaded database archive will be permanently removed from the servers. + 已上传的数据库归档将会从服务器中永久移除。 No comment provided by engineer. @@ -8087,6 +8316,7 @@ It can happen because of some bug or when the connection is compromised. These conditions will also apply for: **%@**. + 这些条件将同样适用于: **%@**。 No comment provided by engineer. @@ -8111,6 +8341,7 @@ It can happen because of some bug or when the connection is compromised. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + 此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。 alert message @@ -8150,6 +8381,7 @@ It can happen because of some bug or when the connection is compromised. This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + 此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。 No comment provided by engineer. @@ -8159,6 +8391,7 @@ It can happen because of some bug or when the connection is compromised. This message was deleted or not received yet. + 此消息被删除或尚未收到。 No comment provided by engineer. @@ -8168,10 +8401,12 @@ It can happen because of some bug or when the connection is compromised. This setting is for your current profile **%@**. + 此设置用于当前个人资料 **%@**。 No comment provided by engineer. Time to disappear is set only for new contacts. + 只为新联系人设置了消失时间。 No comment provided by engineer. @@ -8201,6 +8436,7 @@ It can happen because of some bug or when the connection is compromised. To protect against your link being replaced, you can compare contact security codes. + 为了防止链接被替换,你可以比较联系人安全代码。 No comment provided by engineer. @@ -8227,14 +8463,17 @@ You will be prompted to complete authentication before this feature is enabled.< To receive + 消息接收 No comment provided by engineer. To record speech please grant permission to use Microphone. + 为了记录语音请授予使用麦克风权限。 No comment provided by engineer. To record video please grant permission to use Camera. + 为了录制视频请授予使用相机权限。 No comment provided by engineer. @@ -8249,10 +8488,12 @@ You will be prompted to complete authentication before this feature is enabled.< To send + 发送 No comment provided by engineer. To send commands you must be connected. + 你必须已连接才能发送命令。 alert message @@ -8262,10 +8503,12 @@ You will be prompted to complete authentication before this feature is enabled.< To use another profile after connection attempt, delete the chat and use the link again. + 要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。 alert message To use the servers of **%@**, accept conditions of use. + 要使用**%@**的服务器,需接受条款。 No comment provided by engineer. @@ -8309,6 +8552,7 @@ You will be prompted to complete authentication before this feature is enabled.< Trying to connect to the server used to receive messages from this connection. + 尝试连接到用于从该连接接收消息的服务器。 subscription status explanation @@ -8358,6 +8602,7 @@ You will be prompted to complete authentication before this feature is enabled.< Undelivered messages + 未送达的消息 No comment provided by engineer. @@ -8454,6 +8699,7 @@ To connect, please ask your contact to create another connection link and check Unsupported connection link + 不支持的连接链接 No comment provided by engineer. @@ -8483,6 +8729,7 @@ To connect, please ask your contact to create another connection link and check Updated conditions + 条款已更新 No comment provided by engineer. @@ -8492,14 +8739,17 @@ To connect, please ask your contact to create another connection link and check Upgrade + 升级 alert button Upgrade address + 升级地址 No comment provided by engineer. Upgrade address? + 升级地址? alert message @@ -8509,14 +8759,17 @@ To connect, please ask your contact to create another connection link and check Upgrade group link? + 升级群链接? alert message Upgrade link + 升级链接 No comment provided by engineer. Upgrade your address + 升级你的地址 No comment provided by engineer. @@ -8551,6 +8804,7 @@ To connect, please ask your contact to create another connection link and check Use %@ + 使用 %@ No comment provided by engineer. @@ -8560,6 +8814,7 @@ To connect, please ask your contact to create another connection link and check Use SOCKS proxy + 使用 SOCKS 代理 No comment provided by engineer. @@ -8569,10 +8824,12 @@ To connect, please ask your contact to create another connection link and check Use TCP port %@ when no port is specified. + 当未指定端口时使用TCP端口%@。 No comment provided by engineer. Use TCP port 443 for preset servers only. + 仅预设服务器使用 TCP 协议 443 端口。 No comment provided by engineer. @@ -8587,10 +8844,12 @@ To connect, please ask your contact to create another connection link and check Use for files + 用于文件 No comment provided by engineer. Use for messages + 用于消息 No comment provided by engineer. @@ -8610,6 +8869,7 @@ To connect, please ask your contact to create another connection link and check Use incognito profile + 使用隐身个人资料 No comment provided by engineer. @@ -8639,6 +8899,7 @@ To connect, please ask your contact to create another connection link and check Use servers + 使用服务器 No comment provided by engineer. @@ -8653,6 +8914,7 @@ To connect, please ask your contact to create another connection link and check Use web port + 使用 web 端口 No comment provided by engineer. @@ -8662,6 +8924,7 @@ To connect, please ask your contact to create another connection link and check Username + 用户名 No comment provided by engineer. @@ -8729,6 +8992,11 @@ To connect, please ask your contact to create another connection link and check 视频将在您的联系人在线时收到,请稍等或稍后查看! No comment provided by engineer. + + Videos + 视频 + No comment provided by engineer. + Videos and files up to 1gb 最大 1gb 的视频和文件 @@ -8736,6 +9004,7 @@ To connect, please ask your contact to create another connection link and check View conditions + 查看条款 No comment provided by engineer. @@ -8745,6 +9014,7 @@ To connect, please ask your contact to create another connection link and check View updated conditions + 查看更新后的条款 No comment provided by engineer. @@ -8844,6 +9114,7 @@ To connect, please ask your contact to create another connection link and check Welcome your contacts 👋 + 欢迎联系人👋 No comment provided by engineer. @@ -8863,6 +9134,7 @@ To connect, please ask your contact to create another connection link and check When more than one operator is enabled, none of them has metadata to learn who communicates with whom. + 当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。 No comment provided by engineer. @@ -8962,6 +9234,7 @@ To connect, please ask your contact to create another connection link and check You are already connected with %@. + 你已经与%@保持连接。 No comment provided by engineer. @@ -8998,6 +9271,7 @@ Repeat join request? You are connected to the server used to receive messages from this connection. + 你已连接到用于接收该连接消息的服务器。 subscription status explanation @@ -9007,6 +9281,7 @@ Repeat join request? You are not connected to the server used to receive messages from this connection (no subscription). + 未连接到用于从该连接接收消息的服务器(无订阅)。 subscription status explanation @@ -9026,6 +9301,7 @@ Repeat join request? You can configure servers via settings. + 你可以通过设置配置服务器。 No comment provided by engineer. @@ -9070,6 +9346,7 @@ Repeat join request? You can set connection name, to remember who the link was shared with. + 你可以设置连接名称,用来记住和谁分享了这个链接。 No comment provided by engineer. @@ -9114,6 +9391,7 @@ Repeat join request? You can view your reports in Chat with admins. + 你可以在和管理员和聊天中查看你的举报。 alert message @@ -9199,6 +9477,7 @@ Repeat connection request? You will be able to send messages **only after your request is accepted**. + **只有在你的请求被接受后**你才能发送消息。 No comment provided by engineer. @@ -9233,6 +9512,7 @@ Repeat connection request? You will stop receiving messages from this chat. Chat history will be preserved. + 你将停止从这个聊天收到消息。聊天历史将被保留。 No comment provided by engineer. @@ -9267,6 +9547,7 @@ Repeat connection request? Your business contact + 你的企业联系人 No comment provided by engineer. @@ -9286,6 +9567,7 @@ Repeat connection request? Your chat preferences + 你的聊天偏好设置 alert title @@ -9303,6 +9585,7 @@ Repeat connection request? Your contact + 你的联系人 No comment provided by engineer. @@ -9322,6 +9605,7 @@ Repeat connection request? Your credentials may be sent unencrypted. + 你的凭据可能以未经加密的方式被发送。 No comment provided by engineer. @@ -9336,6 +9620,7 @@ Repeat connection request? Your group + 你的群 No comment provided by engineer. @@ -9370,6 +9655,7 @@ Repeat connection request? Your profile was changed. If you save it, the updated profile will be sent to all your contacts. + 您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。 alert message @@ -9384,6 +9670,7 @@ Repeat connection request? Your servers + 你的服务器 No comment provided by engineer. @@ -9432,10 +9719,12 @@ Repeat connection request? accepted invitation + 已接受邀请 chat list item title accepted you + 接受了你 rcv group event chat item @@ -9460,6 +9749,7 @@ Repeat connection request? all + 全部 member criteria value @@ -9479,6 +9769,7 @@ Repeat connection request? archived report + 已存档的举报 No comment provided by engineer. @@ -9549,6 +9840,7 @@ marked deleted chat item preview text can't send messages + 无法发送消息 No comment provided by engineer. @@ -9653,10 +9945,12 @@ marked deleted chat item preview text contact deleted + 删除了联系人 No comment provided by engineer. contact disabled + 禁用了联系人 No comment provided by engineer. @@ -9671,10 +9965,12 @@ marked deleted chat item preview text contact not ready + 联系人未就绪 No comment provided by engineer. contact should accept… + 联系人应当接受… No comment provided by engineer. @@ -9838,6 +10134,10 @@ pref value 过期 No comment provided by engineer. + + failed + No comment provided by engineer. + forwarded 已转发 @@ -9845,6 +10145,7 @@ pref value group + shown on group welcome message @@ -9854,6 +10155,7 @@ pref value group is deleted + 群被删除了 No comment provided by engineer. @@ -9978,6 +10280,7 @@ pref value member has old version + 成员有旧版本 No comment provided by engineer. @@ -10012,6 +10315,7 @@ pref value moderator + 协管 member role @@ -10041,6 +10345,7 @@ pref value no subscription + 无订阅 No comment provided by engineer. @@ -10050,6 +10355,7 @@ pref value not synchronized + 未同步 No comment provided by engineer. @@ -10111,10 +10417,12 @@ time to disappear pending approval + 待批准 No comment provided by engineer. pending review + 待审核 No comment provided by engineer. @@ -10134,6 +10442,7 @@ time to disappear rejected + 被拒绝 No comment provided by engineer. @@ -10158,6 +10467,7 @@ time to disappear removed from group + 从群被删除了 No comment provided by engineer. @@ -10172,30 +10482,37 @@ time to disappear request is sent + 发送了请求 No comment provided by engineer. request to join rejected + 加入请求被拒绝 No comment provided by engineer. requested connection + 已请求连接 rcv group event chat item requested connection from group %@ + 来自群组%@的已请求连接 rcv direct event chat item requested to connect + 被请求连接 chat list item title review + 审核 No comment provided by engineer. reviewed by admins + 由管理员审核 No comment provided by engineer. @@ -10384,6 +10701,7 @@ last received msg: %2$@ you accepted this member + 你接受了该成员 snd group event chat item @@ -10519,22 +10837,27 @@ last received msg: %2$@ %d new events + %d条新事件 notification body From %d chat(s) + 来自 %d 条聊天 notification body From: %@ + 来自: %@ notification body New events + 新事件 notification New messages + 新消息 notification diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 5d619ac130..25df063f82 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -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 { diff --git a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings index 3a577620a0..3da1eb8e9b 100644 --- a/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/pl.lproj/Localizable.strings @@ -1,3 +1,15 @@ /* notification body */ -"New messages in %d chats" = "Nowe wiadomości w %d czatach"; +"%d new events" = "%d nowych wydarzeń"; + +/* notification body */ +"From %d chat(s)" = "Z %d czatu(ów)"; + +/* notification body */ +"From: %@" = "Od: %@"; + +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* notification */ +"New messages" = "Nowe wiadomości"; diff --git a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings index 5ef592ec70..4e4b130fa4 100644 --- a/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/zh-Hans.lproj/Localizable.strings @@ -1,7 +1,15 @@ -/* - Localizable.strings - SimpleX +/* notification body */ +"%d new events" = "%d条新事件"; + +/* notification body */ +"From %d chat(s)" = "来自 %d 条聊天"; + +/* notification body */ +"From: %@" = "来自: %@"; + +/* notification */ +"New events" = "新事件"; + +/* notification */ +"New messages" = "新消息"; - Created by EP on 30/07/2024. - Copyright © 2024 SimpleX Chat. All rights reserved. -*/ diff --git a/apps/ios/SimpleX SE/ShareAPI.swift b/apps/ios/SimpleX SE/ShareAPI.swift index 6495d09b03..f13401d437 100644 --- a/apps/ios/SimpleX SE/ShareAPI.swift +++ b/apps/ios/SimpleX SE/ShareAPI.swift @@ -68,6 +68,7 @@ func apiSendMessages( type: chatInfo.chatType, id: chatInfo.apiId, scope: chatInfo.groupChatScope(), + sendAsGroup: chatInfo.groupInfo.map { $0.useRelays && $0.membership.memberRole >= .owner } ?? false, live: false, ttl: nil, composedMessages: composedMessages @@ -124,7 +125,7 @@ enum SEChatCommand: ChatCmdProtocol { case apiSetEncryptLocalFiles(enable: Bool) case apiGetChats(userId: Int64) case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]) - case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) + case apiSendMessages(type: ChatType, id: Int64, scope: GroupChatScope?, sendAsGroup: Bool, live: Bool, ttl: Int?, composedMessages: [ComposedMessage]) var cmdString: String { switch self { @@ -140,10 +141,11 @@ enum SEChatCommand: ChatCmdProtocol { case let .apiCreateChatItems(noteFolderId, composedMessages): let msgs = encodeJSON(composedMessages) return "/_create *\(noteFolderId) json \(msgs)" - case let .apiSendMessages(type, id, scope, live, ttl, composedMessages): + case let .apiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages): let msgs = encodeJSON(composedMessages) let ttlStr = ttl != nil ? "\(ttl!)" : "default" - return "/_send \(ref(type, id, scope: scope)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" + let asGroup = sendAsGroup ? "(as_group=on)" : "" + return "/_send \(ref(type, id, scope: scope))\(asGroup) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)" } } diff --git a/apps/ios/SimpleX SE/de.lproj/Localizable.strings b/apps/ios/SimpleX SE/de.lproj/Localizable.strings index ed96f44a15..403fb3820a 100644 --- a/apps/ios/SimpleX SE/de.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/de.lproj/Localizable.strings @@ -65,7 +65,7 @@ "No active profile" = "Kein aktives Profil"; /* No comment provided by engineer. */ -"Ok" = "OK"; +"Ok" = "Ok"; /* No comment provided by engineer. */ "Open the app to downgrade the database." = "Öffnen Sie die App, um die Datenbank herunterzustufen."; diff --git a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings index 2fedf0e6f1..dfb7a302b9 100644 --- a/apps/ios/SimpleX SE/hu.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/hu.lproj/Localizable.strings @@ -14,10 +14,10 @@ "Cannot forward message" = "Nem lehet továbbítani az üzenetet"; /* No comment provided by engineer. */ -"Comment" = "Hozzászólás"; +"Comment" = "Megjegyzés"; /* No comment provided by engineer. */ -"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájl méret: %@."; +"Currently maximum supported file size is %@." = "Jelenleg támogatott legnagyobb fájlméret: %@."; /* No comment provided by engineer. */ "Database downgrade required" = "Adatbázis visszafejlesztése szükséges"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Hozzon létre egy profilt a SimpleX alkalmazásban"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "Az üzenet elküldése a vártnál tovább tart."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 314f1c072c..63191e4fb2 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -162,9 +162,13 @@ 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; 646BB38C283BEEB9001CE359 /* LocalAuthentication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */; }; 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; + 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E72F4C8D2500EB431E /* AddChannelView.swift */; }; + 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; + 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; }; + 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; }; @@ -178,8 +182,8 @@ 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; }; 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; }; 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; }; - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */; }; 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; }; 64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; }; 64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; }; @@ -528,10 +532,14 @@ 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; 646BB38B283BEEB9001CE359 /* LocalAuthentication.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = LocalAuthentication.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.4.sdk/System/Library/Frameworks/LocalAuthentication.framework; sourceTree = DEVELOPER_DIR; }; 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddChannelView.swift; sourceTree = ""; }; + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatRelayView.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; @@ -545,8 +553,8 @@ 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a"; sourceTree = ""; }; 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; @@ -708,8 +716,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -795,8 +803,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.9-3esYZFBUokREq84LvcOgzJ.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.0.14-357Qkjfr6Ry4Z1G22pOLpT.a */, ); path = Libraries; sourceTree = ""; @@ -959,6 +967,7 @@ 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */, 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */, 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */, + 647B15E72F4C8D2500EB431E /* AddChannelView.swift */, ); path = NewChat; sourceTree = ""; @@ -1122,6 +1131,7 @@ 5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */, 5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */, 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */, + 647B15E92F4C8D5100EB431E /* ChatRelayView.swift */, ); path = NetworkAndServers; sourceTree = ""; @@ -1141,6 +1151,8 @@ 64A779F72DBFDBF200FDEF2F /* MemberSupportView.swift */, 64A779FB2DC1040000FDEF2F /* SecondaryChatView.swift */, 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, + 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, + 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, ); path = Group; sourceTree = ""; @@ -1470,6 +1482,7 @@ 5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */, 5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */, 644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */, + 647B15EA2F4C8D5100EB431E /* ChatRelayView.swift in Sources */, 6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */, 8CC317462D4FEBA800292A20 /* ScrollViewCells.swift in Sources */, 5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */, @@ -1572,6 +1585,8 @@ 6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */, E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */, 8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */, + 647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */, + 6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */, 5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */, 5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */, 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */, @@ -1611,6 +1626,7 @@ 5C9C2DA7289957AE00CC63B1 /* AdvancedNetworkSettings.swift in Sources */, 5CADE79A29211BB900072E13 /* PreferencesView.swift in Sources */, 644EFFE42937BE9700525D5B /* MarkedDeletedItemView.swift in Sources */, + 6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */, 1841594C978674A7B42EF0C0 /* AnimatedImageView.swift in Sources */, 5C7031162953C97F00150A12 /* CIFeaturePreferenceView.swift in Sources */, 1841538E296606C74533367C /* UserPicker.swift in Sources */, @@ -2003,7 +2019,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2053,7 +2069,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2095,7 +2111,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2115,7 +2131,7 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -2140,7 +2156,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2177,7 +2193,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2214,7 +2230,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2265,7 +2281,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2316,7 +2332,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2350,7 +2366,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 321; + CURRENT_PROJECT_VERSION = 325; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index 40cee93faf..6bf46fb0dd 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -110,6 +110,7 @@ public func resetChatCtrl() { migrationResult = nil } +// Spec: spec/api.md#sendSimpleXCmd @inline(__always) public func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil, retryNum: Int32 = 0) -> APIResult { if let d = sendSimpleXCmdStr(cmd.cmdString, ctrl, retryNum: retryNum) { @@ -368,6 +369,15 @@ public struct UpMigration: Decodable, Equatable { // public var withDown: Bool } +public func downMigrationWarnings(_ downMigrations: [String]) -> [String] { + let warnings: [(String, String)] = [ + ("20260222_chat_relays", NSLocalizedString("If you joined or created channels, they will stop working permanently.", comment: "down migration warning")) + ] + return warnings.compactMap { (key, message) in + downMigrations.contains(key) ? message : nil + } +} + public enum MTRError: Decodable, Equatable { case noDown(dbMigrations: [String]) case different(appMigration: String, dbMigration: String) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index fce0f100f2..5f1d8ef6c2 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -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: Decodable where R: Decodable, R: ChatAPIResult { case result(R) case error(ChatError) @@ -59,6 +61,7 @@ public enum APIResult: 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(_ d: Data) -> APIResult { // print("decodeAPIResult \(String(describing: R.self))") do { @@ -691,6 +695,7 @@ private func encodeCJSON(_ 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) @@ -721,6 +727,7 @@ public enum ChatErrorType: Decodable, Hashable { case userUnknown case activeUserExists case userExists + case chatRelayExists case invalidDisplayName case differentActiveUser(commandUserId: Int64, activeUserId: Int64) case cantDeleteActiveUser(userId: Int64) @@ -788,6 +795,7 @@ public enum ChatErrorType: Decodable, Hashable { case connectionIncognitoChangeProhibited case connectionUserChangeProhibited case peerChatVRangeIncompatible + case relayTestError(message: String) case internalError(message: String) case exception(message: String) } @@ -795,6 +803,7 @@ public enum ChatErrorType: Decodable, Hashable { public enum StoreError: Decodable, Hashable { case duplicateName case userNotFound(userId: Int64) + case relayUserNotFound case userNotFoundByName(contactName: ContactName) case userNotFoundByContactId(contactId: Int64) case userNotFoundByGroupId(groupId: Int64) @@ -819,6 +828,7 @@ public enum StoreError: Decodable, Hashable { case memberContactGroupMemberNotFound(contactId: Int64) case groupWithoutUser case duplicateGroupMember + case duplicateMemberId case groupAlreadyJoined case groupInvitationNotFound case sndFileNotFound(fileId: Int64) @@ -853,6 +863,9 @@ public enum StoreError: Decodable, Hashable { case hostMemberIdNotFound(groupId: Int64) case contactNotFoundByFileId(fileId: Int64) case noGroupSndStatus(itemId: Int64, groupMemberId: Int64) + case userChatRelayNotFound(chatRelayId: Int64) + case groupRelayNotFound(groupRelayId: Int64) + case groupRelayNotFoundByMemberId(groupMemberId: Int64) case dBException(message: String) } diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index da1720c134..ece65130e6 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -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 diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index e1bf8614e2..4a14d3ae99 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -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 @@ -42,6 +43,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var autoAcceptMemberContacts: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? + public var userChatRelay: Bool public var id: Int64 { userId } @@ -67,7 +69,8 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { showNtfs: true, sendRcptsContacts: true, sendRcptsSmallGroups: false, - autoAcceptMemberContacts: false + autoAcceptMemberContacts: false, + userChatRelay: false ) } @@ -1367,6 +1370,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?) @@ -1575,7 +1579,9 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { switch(groupChatScope) { case .none: if groupInfo.membership.memberPending { return ("reviewed by admins", "Please contact group admin.") } - if groupInfo.membership.memberRole == .observer { return ("you are observer", "Please contact group admin.") } + if groupInfo.membership.memberRole == .observer { + return groupInfo.useRelays ? ("you are subscriber", nil) : ("you are observer", "Please contact group admin.") + } return nil case let .some(.memberSupport(groupMember_: .some(supportMember))): if supportMember.versionRange.maxVersion < GROUP_KNOCKING_VERSION && !supportMember.memberPending { @@ -1646,6 +1652,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { } } + public var isChannel: Bool { + groupInfo?.useRelays == true + } + // this works for features that are common for contacts and groups public func featureEnabled(_ feature: ChatFeature) -> Bool { switch self { @@ -1871,6 +1881,7 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike { } } +// Spec: spec/state.md#ChatStats public struct ChatStats: Decodable, Hashable { public init( unreadCount: Int = 0, @@ -2110,6 +2121,11 @@ public struct Connection: Decodable, Hashable { public var id: ChatId { get { ":\(connId)" } } + public var connFailedErr: String? { + if case let .failed(err) = connStatus { return err } + return nil + } + public var connDisabled: Bool { authErrCounter >= 10 // authErrDisableCount in core } @@ -2295,15 +2311,16 @@ public struct PendingContactConnection: Decodable, NamedChat, Hashable { } } -public enum ConnStatus: String, Decodable, Hashable { - case new = "new" - case prepared = "prepared" - case joined = "joined" - case requested = "requested" - case accepted = "accepted" - case sndReady = "snd-ready" - case ready = "ready" - case deleted = "deleted" +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case failed(connError: String) var initiated: Bool? { get { @@ -2316,6 +2333,7 @@ public enum ConnStatus: String, Decodable, Hashable { case .sndReady: return nil case .ready: return nil case .deleted: return nil + case .failed: return nil } } } @@ -2333,6 +2351,8 @@ public struct Group: Decodable, Hashable { public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { public var groupId: Int64 + public var useRelays: Bool + public var relayOwnStatus: RelayStatus? = nil var localDisplayName: GroupName public var groupProfile: GroupProfile public var businessChat: BusinessChatInfo? @@ -2344,6 +2364,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var chatTs: Date? public var preparedGroup: PreparedGroup? public var uiThemes: ThemeModeOverrides? + public var groupSummary: GroupSummary public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } @@ -2376,15 +2397,20 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { } public var chatIconName: String { - switch businessChat?.chatType { - case .none: "person.2.circle.fill" - case .business: "briefcase.circle.fill" - case .customer: "person.crop.circle.fill" + if useRelays { + "antenna.radiowaves.left.and.right.circle.fill" + } else { + switch businessChat?.chatType { + case .none: "person.2.circle.fill" + case .business: "briefcase.circle.fill" + case .customer: "person.crop.circle.fill" + } } } public static let sampleData = GroupInfo( groupId: 1, + useRelays: false, localDisplayName: "team", groupProfile: GroupProfile.sampleData, fullGroupPreferences: FullGroupPreferences.sampleData, @@ -2392,6 +2418,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + groupSummary: GroupSummary(currentMembers: 0), membersRequireAttention: 0, chatTags: [], localAlias: "" @@ -2409,6 +2436,34 @@ public struct GroupRef: Decodable, Hashable { var localDisplayName: GroupName } +public enum GroupType: Codable, Hashable { + case channel + case unknown(type: String) + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + let type = try container.decode(String.self) + switch type { + case "channel": self = .channel + default: self = .unknown(type: type) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .channel: try container.encode("channel") + case let .unknown(type): try container.encode(type) + } + } +} + +public struct PublicGroupProfile: Codable, Hashable { + public var groupType: GroupType + public var groupLink: String + public var publicGroupId: String +} + public struct GroupProfile: Codable, NamedChat, Hashable { public init( displayName: String, @@ -2416,6 +2471,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { shortDescr: String? = nil, description: String? = nil, image: String? = nil, + publicGroup: PublicGroupProfile? = nil, groupPreferences: GroupPreferences? = nil, memberAdmission: GroupMemberAdmission? = nil ) { @@ -2424,6 +2480,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { self.shortDescr = shortDescr self.description = description self.image = image + self.publicGroup = publicGroup self.groupPreferences = groupPreferences self.memberAdmission = memberAdmission } @@ -2433,6 +2490,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var shortDescr: String? public var description: String? public var image: String? + public var publicGroup: PublicGroupProfile? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? public var localAlias: String { "" } @@ -2482,8 +2540,104 @@ public struct ContactShortLinkData: Codable, Hashable { public var business: Bool } +public struct GroupSummary: Decodable, Hashable { + public var currentMembers: Int64 + public var publicMemberCount: Int64? + + public init(currentMembers: Int64 = 0, publicMemberCount: Int64? = nil) { + self.currentMembers = currentMembers + self.publicMemberCount = publicMemberCount + } +} + +public struct PublicGroupData: Codable, Hashable { + public var publicMemberCount: Int64 +} + public struct GroupShortLinkData: Codable, Hashable { public var groupProfile: GroupProfile + public var publicGroupData: PublicGroupData? +} + +public enum RelayStatus: String, Decodable, Equatable, Hashable { + case rsNew = "new" + case rsInvited = "invited" + case rsAccepted = "accepted" + case rsActive = "active" +} + +public struct RelayProfile: Codable, Equatable, Hashable { + public var displayName: String + public var fullName: String + public var shortDescr: String? + public var image: String? +} + +public struct UserChatRelay: Identifiable, Codable, Equatable, Hashable { + public var chatRelayId: Int64? + public var address: String + public var relayProfile: RelayProfile + public var domains: [String] + public var preset: Bool + public var tested: Bool? + public var enabled: Bool + public var deleted: Bool + public var createdAt = Date() + + public var displayName: String { + get { relayProfile.displayName } + set { relayProfile.displayName = newValue } + } + + public init(chatRelayId: Int64? = nil, address: String, name: String, domains: [String], preset: Bool, tested: Bool? = nil, enabled: Bool, deleted: Bool, createdAt: Date = Date()) { + self.chatRelayId = chatRelayId + self.address = address + self.relayProfile = RelayProfile(displayName: name, fullName: "", shortDescr: nil, image: nil) + self.domains = domains + self.preset = preset + self.tested = tested + self.enabled = enabled + self.deleted = deleted + self.createdAt = createdAt + } + + public static func == (l: UserChatRelay, r: UserChatRelay) -> Bool { + l.chatRelayId == r.chatRelayId && l.address == r.address && l.relayProfile == r.relayProfile && l.domains == r.domains && + l.preset == r.preset && l.tested == r.tested && l.enabled == r.enabled && l.deleted == r.deleted + } + + public var id: String { "\(address) \(createdAt)" } + + public enum CodingKeys: CodingKey { + case chatRelayId + case address + case relayProfile + case domains + case preset + case tested + case enabled + case deleted + } +} + +public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { + public var groupRelayId: Int64 + public var groupMemberId: Int64 + public var userChatRelay: UserChatRelay + public var relayStatus: RelayStatus + public var relayLink: String? + public var id: Int64 { groupRelayId } +} + +extension RelayStatus { + public var text: LocalizedStringKey { + switch self { + case .rsNew: "new" + case .rsInvited: "invited" + case .rsAccepted: "accepted" + case .rsActive: "active" + } + } } public struct BusinessChatInfo: Decodable, Hashable { @@ -2514,6 +2668,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var activeConn: Connection? public var supportChat: GroupSupportChat? public var memberChatVRange: VersionRange + public var relayLink: String? public var id: String { "#\(groupId) @\(groupMemberId)" } public var ready: Bool { get { activeConn?.connStatus == .ready } } @@ -2639,14 +2794,14 @@ public struct GroupMember: Identifiable, Decodable, Hashable { } public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? { - if !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } + if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil } let userRole = groupInfo.membership.memberRole return GroupMemberRole.supportedRoles.filter { $0 <= userRole } } public func canBlockForAll(groupInfo: GroupInfo) -> Bool { let userRole = groupInfo.membership.memberRole - return memberRole < .moderator + return memberRole != .relay && memberRole < .moderator && userRole >= .moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2717,6 +2872,7 @@ public struct GroupMemberIds: Decodable, Hashable { } public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable { + case relay case observer case author case member @@ -2730,6 +2886,7 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod public var text: String { switch self { + case .relay: return NSLocalizedString("relay", comment: "member role") case .observer: return NSLocalizedString("observer", comment: "member role") case .author: return NSLocalizedString("author", comment: "member role") case .member: return NSLocalizedString("member", comment: "member role") @@ -2741,12 +2898,13 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod private var comparisonValue: Int { switch self { - case .observer: 0 - case .author: 1 - case .member: 2 - case .moderator: 3 - case .admin: 4 - case .owner: 5 + case .relay: 0 + case .observer: 1 + case .author: 2 + case .member: 3 + case .moderator: 4 + case .admin: 5 + case .owner: 6 } } @@ -3037,11 +3195,13 @@ public struct ChatItem: Identifiable, Decodable, Hashable { public var timestampText: Text { meta.timestampText } - public var text: String { - switch (content.text, content.msgContent, file) { + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch (content.text(isChannel: isChannel), content.msgContent, file) { case let ("", .some(.voice(_, duration)), _): return "Voice message (\(durationText(duration)))" case let ("", _, .some(file)): return file.fileName - default: return content.text + default: return content.text(isChannel: isChannel) } } @@ -3214,6 +3374,8 @@ public struct ChatItem: Identifiable, Decodable, Hashable { case let (.group(groupInfo, _), .groupSnd): let m = groupInfo.membership return m.memberRole >= .moderator ? (groupInfo, nil) : nil + case (.group, .channelRcv): + return nil default: return nil } } @@ -3434,6 +3596,7 @@ public enum CIDirection: Decodable, Hashable { case directRcv case groupSnd case groupRcv(groupMember: GroupMember) + case channelRcv case localSnd case localRcv @@ -3444,6 +3607,7 @@ public enum CIDirection: Decodable, Hashable { case .directRcv: return false case .groupSnd: return true case .groupRcv: return false + case .channelRcv: return false case .localSnd: return true case .localRcv: return false } @@ -3453,6 +3617,7 @@ public enum CIDirection: Decodable, Hashable { public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId + case (.channelRcv, .channelRcv): true default: sent == dir.sent } } @@ -3888,42 +4053,42 @@ public enum CIContent: Decodable, ItemContent, Hashable { case chatBanner case invalidJSON(json: Data?) - public var text: String { - get { - switch self { - case let .sndMsgContent(mc): return mc.text - case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") - case let .sndCall(status, duration): return status.text(duration) - case let .rcvCall(status, duration): return status.text(duration) - case let .rcvIntegrityError(msgError): return msgError.text - case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text - case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text - case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text - case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text - case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text - case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text - case let .sndConnEvent(sndConnEvent): return sndConnEvent.text - case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) - case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) - case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) - case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) - case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") - case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") - case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) - case .sndGroupE2EEInfo: return e2eeInfoNoPQStr - case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr - case .chatBanner: return "" - case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") - } + public var text: String { text(isChannel: false) } + + public func text(isChannel: Bool) -> String { + switch self { + case let .sndMsgContent(mc): return mc.text + case let .rcvMsgContent(mc): return mc.text + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case let .sndCall(status, duration): return status.text(duration) + case let .rcvCall(status, duration): return status.text(duration) + case let .rcvIntegrityError(msgError): return msgError.text + case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text + case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text + case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text(isChannel: isChannel) + case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text(isChannel: isChannel) + case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text + case let .sndConnEvent(sndConnEvent): return sndConnEvent.text + case let .rcvChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) + case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) + case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) + case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) + case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvModerated: return NSLocalizedString("moderated", comment: "moderated chat item") + case .rcvBlocked: return NSLocalizedString("blocked by admin", comment: "blocked chat item") + case let .sndDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case let .rcvDirectE2EEInfo(e2eeInfo): return directE2EEInfoStr(e2eeInfo) + case .sndGroupE2EEInfo: return e2eeInfoNoPQStr + case .rcvGroupE2EEInfo: return e2eeInfoNoPQStr + case .chatBanner: return "" + case .invalidJSON: return NSLocalizedString("invalid data", comment: "invalid chat item") } } @@ -4044,6 +4209,7 @@ public struct CIQuote: Decodable, ItemContent, Hashable { case .directRcv: return nil case .groupSnd: return membership?.displayName ?? "you" case let .groupRcv(member): return member.displayName + case .channelRcv: return nil case .localSnd: return "you" case .localRcv: return nil case nil: return nil @@ -4234,6 +4400,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 +4448,7 @@ public struct CryptoFile: Codable, Hashable { static var decryptedUrls = Dictionary() } +// Spec: spec/services/files.md#CryptoFileArgs public struct CryptoFileArgs: Codable, Hashable { public var fileKey: String public var fileNonce: String @@ -4651,6 +4819,7 @@ public enum Format: Decodable, Equatable, Hashable { case strikeThrough case snippet case secret + case small case colored(color: FormatColor) case uri case hyperLink(showText: String?, linkUri: String) @@ -4684,7 +4853,7 @@ public enum SimplexLinkType: String, Decodable, Hashable { case .invitation: return NSLocalizedString("SimpleX one-time invitation", comment: "simplex link type") case .group: return NSLocalizedString("SimpleX group link", comment: "simplex link type") case .channel: return NSLocalizedString("SimpleX channel link", comment: "simplex link type") - case .relay: return NSLocalizedString("SimpleX relay link", comment: "simplex link type") + case .relay: return NSLocalizedString("SimpleX relay address", comment: "simplex link type") } } } @@ -4990,7 +5159,9 @@ public enum RcvGroupEvent: Decodable, Hashable { case memberProfileUpdated(fromProfile: Profile, toProfile: Profile) case newMemberPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberAdded(_, profile): return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.profileViewName) @@ -5012,8 +5183,12 @@ public enum RcvGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName) case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item") - case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item") - case .groupUpdated: return NSLocalizedString("updated group profile", comment: "rcv group event chat item") + case .groupDeleted: return isChannel + ? NSLocalizedString("deleted channel", comment: "rcv group event chat item") + : NSLocalizedString("deleted group", comment: "rcv group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("updated channel profile", comment: "rcv group event chat item") + : NSLocalizedString("updated group profile", comment: "rcv group event chat item") case .invitedViaGroupLink: return NSLocalizedString("invited via your group link", comment: "rcv group event chat item") case .memberCreatedContact: return NSLocalizedString("requested connection", comment: "rcv group event chat item") case let .memberProfileUpdated(fromProfile, toProfile): return profileUpdatedText(fromProfile, toProfile) @@ -5045,7 +5220,9 @@ public enum SndGroupEvent: Decodable, Hashable { case memberAccepted(groupMemberId: Int64, profile: Profile) case userPendingReview - var text: String { + var text: String { text(isChannel: false) } + + func text(isChannel: Bool) -> String { switch self { case let .memberRole(_, profile, role): return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text) @@ -5060,7 +5237,9 @@ public enum SndGroupEvent: Decodable, Hashable { case let .memberDeleted(_, profile): return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.profileViewName) case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") - case .groupUpdated: return NSLocalizedString("group profile updated", comment: "snd group event chat item") + case .groupUpdated: return isChannel + ? NSLocalizedString("channel profile updated", comment: "snd group event chat item") + : NSLocalizedString("group profile updated", comment: "snd group event chat item") case .memberAccepted: return NSLocalizedString("you accepted this member", comment: "snd group event chat item") case .userPendingReview: return NSLocalizedString("Please wait for group moderators to review your request to join the group.", comment: "snd group event chat item") diff --git a/apps/ios/SimpleXChat/CryptoFile.swift b/apps/ios/SimpleXChat/CryptoFile.swift index dfe833f832..5a0d48dced 100644 --- a/apps/ios/SimpleXChat/CryptoFile.swift +++ b/apps/ios/SimpleXChat/CryptoFile.swift @@ -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)! diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index 2341eb4a4f..3d0dd663c1 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -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 diff --git a/apps/ios/SimpleXChat/ImageUtils.swift b/apps/ios/SimpleXChat/ImageUtils.swift index c70ca5edd8..f93b090517 100644 --- a/apps/ios/SimpleXChat/ImageUtils.swift +++ b/apps/ios/SimpleXChat/ImageUtils.swift @@ -402,6 +402,11 @@ extension UIImage { } } +// Max image height/width ratio for chat item display, taller images are cropped +public func heightRatio(_ size: CGSize) -> CGFloat { + size.width > 0 ? min(size.height / size.width, 2.33) : 1 +} + public func imageFromBase64(_ base64Encoded: String?) -> UIImage? { if let base64Encoded { if let img = imageCache.object(forKey: base64Encoded as NSString) { diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index 31b7ef83ff..a40e8eda99 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -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 @@ -70,7 +74,7 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ return createNotification( categoryIdentifier: ntfCategoryMessageReceived, title: title, - body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), + body: previewMode == .message ? hideSecrets(cItem, isChannel: cInfo.isChannel) : NSLocalizedString("new message", comment: "notification"), targetContentIdentifier: cInfo.id, userInfo: ["userId": user.userId], // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] @@ -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,7 +196,8 @@ public func createNotification( return content } -func hideSecrets(_ cItem: ChatItem) -> String { +// Spec: spec/services/notifications.md#hideSecrets +func hideSecrets(_ cItem: ChatItem, isChannel: Bool = false) -> String { if let md = cItem.formattedText { var res = "" for ft in md { @@ -203,7 +213,7 @@ func hideSecrets(_ cItem: ChatItem) -> String { if case let .report(text, reason) = mc { return String.localizedStringWithFormat(NSLocalizedString("Report: %@", comment: "report in notification"), text.isEmpty ? reason.text : text) } else { - return cItem.text + return cItem.text(isChannel: isChannel) } } } diff --git a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift index 662f8b43d1..2b64627dc2 100644 --- a/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ChatWallpaperTypes.swift @@ -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 { diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index 4074382543..a4e8050c6e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -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() diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 038546f889..fb8529fb88 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -1613,7 +1613,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Изтрий съобщението?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Изтрий съобщенията"; /* No comment provided by engineer. */ @@ -2750,7 +2751,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Ролята на члена ще бъде променена на \"%@\". Членът ще получи нова покана."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Членът ще бъде премахнат от групата - това не може да бъде отменено!"; /* No comment provided by engineer. */ @@ -3417,13 +3418,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay сървърът защитава вашия IP адрес, но може да наблюдава продължителността на разговора."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Премахване"; /* No comment provided by engineer. */ "Remove member" = "Острани член"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Острани член?"; /* No comment provided by engineer. */ diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index dd486001c7..9e1fe7139c 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -1258,7 +1258,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Smazat zprávu?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Smazat zprávy"; /* No comment provided by engineer. */ @@ -1753,7 +1754,7 @@ snd error text */ "Find chats faster" = "Najděte chaty rychleji"; /* server test error */ -"Fingerprint in server address does not match certificate." = "Je možné, že otisk certifikátu v adrese serveru je nesprávný"; +"Fingerprint in server address does not match certificate." = "Otisk certifikátu v adrese serveru neodpovídá."; /* No comment provided by engineer. */ "Fix" = "Opravit"; @@ -2193,7 +2194,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Role člena se změní na \"%@\". Člen obdrží novou pozvánku."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Člen bude odstraněn ze skupiny - toto nelze vzít zpět!"; /* No comment provided by engineer. */ @@ -2389,7 +2390,7 @@ snd error text */ "no text" = "žádný text"; /* No comment provided by engineer. */ -"No user identifiers." = "Bez uživatelských identifikátorů"; +"No user identifiers." = "Bez uživatelských identifikátorů."; /* No comment provided by engineer. */ "Notifications" = "Oznámení"; @@ -2728,13 +2729,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Přenosový server chrání vaši IP adresu, ale může sledovat dobu trvání hovoru."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Odstranit"; /* No comment provided by engineer. */ "Remove member" = "Odstranit člena"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Odebrat člena?"; /* No comment provided by engineer. */ @@ -2979,10 +2980,10 @@ chat item action */ "Sent messages will be deleted after set time." = "Odeslané zprávy se po uplynutí nastavené doby odstraní."; /* server test error */ -"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo"; +"Server requires authorization to create queues, check password." = "Server vyžaduje autorizaci pro vytváření front, zkontrolujte heslo."; /* server test error */ -"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo"; +"Server requires authorization to upload, check password." = "Server vyžaduje autorizaci pro nahrávání, zkontrolujte heslo."; /* No comment provided by engineer. */ "Server test failed!" = "Test serveru se nezdařil!"; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index caf58399de..e3979abc37 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "Alle Mitglieder"; +/* No comment provided by engineer. */ +"All messages" = "Alle Nachrichten"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Alle Nachrichten und Dateien werden **Ende-zu-Ende verschlüsselt** versendet - in Direkt-Nachrichten mit Post-Quantum-Security."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Audio- und Videoanrufe"; +/* No comment provided by engineer. */ +"Audio call" = "Audioanruf"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "Audioanruf (nicht E2E verschlüsselt)"; @@ -1730,10 +1736,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Nachricht des Mitglieds löschen?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Mitgliedsnachrichten löschen"; + +/* alert title */ +"Delete member messages?" = "Mitgliedsnachrichten löschen?"; + /* No comment provided by engineer. */ "Delete message?" = "Die Nachricht löschen?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Nachrichten löschen"; /* No comment provided by engineer. */ @@ -2564,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Dateien und Medien sind nicht erlaubt!"; +/* No comment provided by engineer. */ +"Filter" = "Filter"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Nach ungelesenen und favorisierten Chats filtern."; @@ -2867,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "Das Bild wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder schauen Sie später nochmal nach!"; +/* No comment provided by engineer. */ +"Images" = "Bilder"; + /* No comment provided by engineer. */ "Immediately" = "Sofort"; @@ -3050,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Freunde einladen"; +/* No comment provided by engineer. */ +"Invite member" = "Mitglied einladen"; + /* No comment provided by engineer. */ "Invite members" = "Mitglieder einladen"; @@ -3203,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Verknüpfte Desktops"; +/* No comment provided by engineer. */ +"Links" = "Links"; + /* swipe action */ "List" = "Liste"; @@ -3296,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Mitglied ist gelöscht - Anfrage kann nicht angenommen werden"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden!"; + /* chat feature */ "Member reports" = "Mitglieder-Meldungen"; @@ -3308,10 +3336,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Die Mitgliederrolle wird auf \"%@\" geändert. Das Mitglied wird eine neue Einladung erhalten."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Das Mitglied wird aus dem Chat entfernt. Dies kann nicht rückgängig gemacht werden!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden!"; /* alert message */ @@ -4380,9 +4408,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relais-Server schützen Ihre IP-Adresse, aber sie können die Anrufdauer erfassen."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Entfernen"; +/* alert action */ +"Remove and delete messages" = "Mitglied entfernen und Nachrichten löschen"; + /* No comment provided by engineer. */ "Remove archive?" = "Archiv entfernen?"; @@ -4395,7 +4426,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Mitglied entfernen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Das Mitglied entfernen?"; /* No comment provided by engineer. */ @@ -4690,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "In der Suchleiste werden nun auch Einladungslinks angenommen."; +/* No comment provided by engineer. */ +"Search files" = "Dateien suchen"; + +/* No comment provided by engineer. */ +"Search images" = "Bilder suchen"; + +/* No comment provided by engineer. */ +"Search links" = "Links suchen"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Suchen oder SimpleX-Link einfügen"; +/* No comment provided by engineer. */ +"Search videos" = "Videos suchen"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Sprachnachrichten suchen"; + /* network option */ "sec" = "sek"; @@ -5898,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Das Video wird heruntergeladen, sobald Ihr Kontakt online ist. Bitte warten oder überprüfen Sie es später!"; +/* No comment provided by engineer. */ +"Videos" = "Videos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videos und Dateien bis zu 1GB"; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index 5ce7ab6843..a05bc9f4b6 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -257,7 +257,7 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

¡Hola!

\n

Conecta conmigo a través de SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strike~"; @@ -384,7 +384,7 @@ swipe action */ "accepted invitation" = "invitación aceptada"; /* rcv group event chat item */ -"accepted you" = "te ha aceptado"; +"accepted you" = "te ha admitido"; /* No comment provided by engineer. */ "Acknowledged" = "Confirmaciones"; @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "todos los miembros"; +/* No comment provided by engineer. */ +"All messages" = "Todos los mensajes"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Todos los mensajes y archivos son enviados **cifrados de extremo a extremo** y con seguridad de cifrado postcuántico en mensajes directos."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Llamadas y videollamadas"; +/* No comment provided by engineer. */ +"Audio call" = "Llamada"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "llamada (sin cifrar)"; @@ -1730,10 +1736,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "¿Eliminar el mensaje de miembro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Eliminar mensajes del miembro"; + +/* alert title */ +"Delete member messages?" = "¿Eliminar mensajes del miembro?"; + /* No comment provided by engineer. */ "Delete message?" = "¿Eliminar mensaje?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Activar"; /* No comment provided by engineer. */ @@ -2564,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "¡Archivos y multimedia no permitidos!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra chats no leídos y favoritos."; @@ -2867,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "La imagen se recibirá cuando el contacto esté en línea, ¡por favor espera o revisa más tarde!"; +/* No comment provided by engineer. */ +"Images" = "Imágenes"; + /* No comment provided by engineer. */ "Immediately" = "Inmediatamente"; @@ -3050,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invitar amigos"; +/* No comment provided by engineer. */ +"Invite member" = "Invitar miembro"; + /* No comment provided by engineer. */ "Invite members" = "Invitar miembros"; @@ -3203,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Ordenadores enlazados"; +/* No comment provided by engineer. */ +"Links" = "Enlaces"; + /* swipe action */ "List" = "Lista"; @@ -3296,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Miembro eliminado, no puede aceptar solicitudes"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Los mensajes del miembro serán eliminados. ¡No puede deshacerse!"; + /* chat feature */ "Member reports" = "Informes de miembros"; @@ -3308,10 +3336,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "El rol del miembro cambiará a \"%@\" y recibirá una invitación nueva."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "El miembro será eliminado del chat. ¡No puede deshacerse!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "El miembro será expulsado del grupo. ¡No puede deshacerse!"; /* alert message */ @@ -3654,7 +3682,7 @@ snd error text */ "No device token!" = "¡Sin dispositivo token!"; /* item status description */ -"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa con este miembro, el mensaje es reenviado por el administrador."; +"No direct connection yet, message is forwarded by admin." = "Aún no hay conexión directa, los mensajes son reenviados por el administrador."; /* No comment provided by engineer. */ "no e2e encryption" = "sin cifrar"; @@ -3938,7 +3966,7 @@ new chat action */ "Or securely share this file link" = "O comparte de forma segura este enlace al archivo"; /* No comment provided by engineer. */ -"Or show this code" = "O muestra el código QR"; +"Or show this code" = "O muestra este código"; /* No comment provided by engineer. */ "Or to share privately" = "O para compartir en privado"; @@ -4380,9 +4408,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "El servidor de retransmisión protege tu IP pero puede ver la duración de la llamada."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Eliminar"; +/* alert action */ +"Remove and delete messages" = "Eliminar miembro y sus mensajes"; + /* No comment provided by engineer. */ "Remove archive?" = "¿Eliminar archivo?"; @@ -4395,7 +4426,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Expulsar miembro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "¿Expulsar miembro?"; /* No comment provided by engineer. */ @@ -4690,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra de búsqueda acepta enlaces de invitación."; +/* No comment provided by engineer. */ +"Search files" = "Buscar archivos"; + +/* No comment provided by engineer. */ +"Search images" = "Buscar imágenes"; + +/* No comment provided by engineer. */ +"Search links" = "Buscar enlaces"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Buscar o pegar enlace SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Buscar vídeos"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Buscar mensajes de voz"; + /* network option */ "sec" = "seg"; @@ -5686,7 +5732,7 @@ report reason */ "Unmute" = "Activar audio"; /* No comment provided by engineer. */ -"unprotected" = "con IP desprotegida"; +"unprotected" = "desprotegida"; /* swipe action */ "Unread" = "No leído"; @@ -5898,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "El vídeo se recibirá cuando el contacto esté en línea, por favor espera o revisa más tarde."; +/* No comment provided by engineer. */ +"Videos" = "Vídeos"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Vídeos y archivos de hasta 1Gb"; @@ -6052,7 +6101,7 @@ report reason */ "You accepted connection" = "Has aceptado la conexión"; /* snd group event chat item */ -"you accepted this member" = "has aceptado al miembro"; +"you accepted this member" = "has admitido al miembro"; /* No comment provided by engineer. */ "You allow" = "Permites"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index 884be40cc1..ea3f9c4386 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -943,7 +943,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Poista viesti?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Poista viestit"; /* No comment provided by engineer. */ @@ -1869,7 +1870,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Jäsenen rooli muutetaan muotoon \"%@\". Jäsen saa uuden kutsun."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Jäsen poistetaan ryhmästä - tätä ei voi perua!"; /* No comment provided by engineer. */ @@ -2398,13 +2399,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Välityspalvelin suojaa IP-osoitteesi, mutta se voi tarkkailla puhelun kestoa."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Poista"; /* No comment provided by engineer. */ "Remove member" = "Poista jäsen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Poista jäsen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index dbfac375d1..2b2a1e98e5 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -1661,7 +1661,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Supprimer le message ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Supprimer les messages"; /* No comment provided by engineer. */ @@ -3104,10 +3105,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; /* No comment provided by engineer. */ @@ -4005,7 +4006,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Le serveur relais protège votre adresse IP, mais il peut observer la durée de l'appel."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Supprimer"; /* No comment provided by engineer. */ @@ -4017,7 +4018,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Retirer le membre"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Retirer ce membre ?"; /* No comment provided by engineer. */ diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 451bdfc699..56277f4fd3 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -5,7 +5,7 @@ "_italic_" = "\\_dőlt_"; /* No comment provided by engineer. */ -"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtár szolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tag).\n- gyorsabb és stabilabb."; +"- connect to [directory service](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- delivery receipts (up to 20 members).\n- faster and more stable." = "- kapcsolódás a [könyvtárszolgáltatáshoz](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb."; /* No comment provided by engineer. */ "- more stable message delivery.\n- a bit better groups.\n- and more!" = "- stabilabb üzenetkézbesítés.\n- picit továbbfejlesztett csoportok.\n- és még sok más!"; @@ -41,10 +41,10 @@ "**Create group**: to create a new group." = "**Csoport létrehozása:** új csoport létrehozásához."; /* No comment provided by engineer. */ -"**e2e encrypted** audio call" = "**e2e titkosított** hanghívás"; +"**e2e encrypted** audio call" = "**végpontok között titkosított** hanghívás"; /* No comment provided by engineer. */ -"**e2e encrypted** video call" = "**e2e titkosított** videóhívás"; +"**e2e encrypted** video call" = "**végpontok között titkosított** videóhívás"; /* No comment provided by engineer. */ "**More private**: check new messages every 20 minutes. Only device token is shared with our push server. It doesn't see how many contacts you have, or any message metadata." = "**Privátabb:** 20 percenként ellenőrzi az új üzeneteket. Az eszköztoken meg lesz osztva a SimpleX Chat kiszolgálóval, de az nem, hogy hány partnere vagy üzenete van."; @@ -65,7 +65,7 @@ "**Scan / Paste link**: to connect via a link you received." = "**Hivatkozás beolvasása / beillesztése**: egy kapott hivatkozáson keresztüli kapcsolódáshoz."; /* No comment provided by engineer. */ -"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali push-értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; +"**Warning**: Instant push notifications require passphrase saved in Keychain." = "**Figyelmeztetés:** Az azonnali leküldéses értesítésekhez a kulcstartóban tárolt jelmondat megadása szükséges."; /* No comment provided by engineer. */ "**Warning**: the archive will be removed." = "**Figyelmeztetés:** az archívum el lesz távolítva."; @@ -119,10 +119,10 @@ "%@ is connected!" = "%@ kapcsolódott!"; /* No comment provided by engineer. */ -"%@ is not verified" = "%@ nincs hitelesítve"; +"%@ is not verified" = "%@ nincs ellenőrizve"; /* No comment provided by engineer. */ -"%@ is verified" = "%@ hitelesítve"; +"%@ is verified" = "%@ ellenőrizve"; /* No comment provided by engineer. */ "%@ server" = "%@ kiszolgáló"; @@ -194,7 +194,7 @@ "%lld %@" = "%lld %@"; /* No comment provided by engineer. */ -"%lld contact(s) selected" = "%lld partner kijelölve"; +"%lld contact(s) selected" = "%lld partner kiválasztva"; /* No comment provided by engineer. */ "%lld file(s) with total size of %@" = "%lld fájl, %@ összméretben"; @@ -507,11 +507,14 @@ swipe action */ "All data is kept private on your device." = "Az összes adat privát módon van tárolva az eszközén."; /* No comment provided by engineer. */ -"All group members will remain connected." = "Az összes csoporttag kapcsolatban marad."; +"All group members will remain connected." = "Az összes csoporttag továbbra is kapcsolatban marad."; /* feature role */ "all members" = "összes tag"; +/* No comment provided by engineer. */ +"All messages" = "Összes üzenet"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Az összes üzenet és fájl **végpontok közötti titkosítással**, a közvetlen üzenetek továbbá kvantumbiztos titkosítással is rendelkeznek."; @@ -534,10 +537,10 @@ swipe action */ "All servers" = "Összes kiszolgáló"; /* No comment provided by engineer. */ -"All your contacts will remain connected." = "Az összes partnerével kapcsolatban marad."; +"All your contacts will remain connected." = "Az összes partnerével továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ -"All your contacts will remain connected. Profile update will be sent to your contacts." = "A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; +"All your contacts will remain connected. Profile update will be sent to your contacts." = "Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára."; /* No comment provided by engineer. */ "All your contacts, conversations and files will be securely encrypted and uploaded in chunks to configured XFTP relays." = "Az összes partnere, -beszélgetése és -fájlja biztonságosan titkosítva lesz, majd töredékekre bontva feltöltődnek a beállított XFTP-továbbítókiszolgálókra."; @@ -609,7 +612,7 @@ swipe action */ "Allow your contacts to irreversibly delete sent messages. (24 hours)" = "Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra)"; /* No comment provided by engineer. */ -"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldésének engedélyezése a partnerei számára."; +"Allow your contacts to send disappearing messages." = "Az eltűnő üzenetek küldése engedélyezve van a partnerei számára."; /* No comment provided by engineer. */ "Allow your contacts to send files and media." = "A fájlok és a médiatartalmak küldése engedélyezve van a partnerei számára."; @@ -630,10 +633,10 @@ swipe action */ "always" = "mindig"; /* No comment provided by engineer. */ -"Always use private routing." = "Mindig használjon privát útválasztást."; +"Always use private routing." = "Mindig legyen használva privát útválasztás."; /* No comment provided by engineer. */ -"Always use relay" = "Mindig használjon továbbítókiszolgálót"; +"Always use relay" = "Mindig legyen használva továbbítókiszolgáló"; /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "Egy üres csevegési profil lesz létrehozva a megadott névvel, és az alkalmazás a szokásos módon megnyílik."; @@ -735,7 +738,10 @@ swipe action */ "Audio and video calls" = "Hang- és videóhívások"; /* No comment provided by engineer. */ -"audio call (not e2e encrypted)" = "hanghívás (nem e2e titkosított)"; +"Audio call" = "Hanghívás"; + +/* No comment provided by engineer. */ +"audio call (not e2e encrypted)" = "hanghívás (végpontok között NEM titkosított)"; /* chat feature */ "Audio/video calls" = "Hang- és videóhívások"; @@ -819,10 +825,10 @@ swipe action */ "Better user experience" = "Továbbfejlesztett felhasználói élmény"; /* No comment provided by engineer. */ -"Bio" = "Névjegy"; +"Bio" = "Életrajz"; /* alert title */ -"Bio too large" = "A névjegy túl hosszú"; +"Bio too large" = "Az életrajz túl hosszú"; /* No comment provided by engineer. */ "Black" = "Fekete"; @@ -913,7 +919,7 @@ marked deleted chat item preview text */ "call" = "hívás"; /* No comment provided by engineer. */ -"Call already ended!" = "A hívás már befejeződött!"; +"Call already ended!" = "A hívás már véget ért!"; /* call status */ "call error" = "híváshiba"; @@ -1120,7 +1126,7 @@ set passcode view */ "Chinese and Spanish interface" = "Kínai és spanyol kezelőfelület"; /* No comment provided by engineer. */ -"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ opciót az új eszközén és olvassa be a QR-kódot."; +"Choose _Migrate from another device_ on the new device and scan QR code." = "Válassza az _Átköltöztetés egy másik eszközről_ beállítást az új eszközén és olvassa be a QR-kódot."; /* No comment provided by engineer. */ "Choose file" = "Fájl kiválasztása"; @@ -1138,25 +1144,25 @@ set passcode view */ "Chunks uploaded" = "Feltöltött töredékek"; /* swipe action */ -"Clear" = "Kiürítés"; +"Clear" = "Ürítés"; /* No comment provided by engineer. */ -"Clear conversation" = "Üzenetek kiürítése"; +"Clear conversation" = "Üzenetek ürítése"; /* No comment provided by engineer. */ -"Clear conversation?" = "Kiüríti az üzeneteket?"; +"Clear conversation?" = "Üríti a beszélgetés üzeneteit?"; /* No comment provided by engineer. */ -"Clear group?" = "Kiüríti a csoportot?"; +"Clear group?" = "Üríti a csoport üzeneteit?"; /* No comment provided by engineer. */ -"Clear or delete group?" = "Csoport kiürítése vagy törlése?"; +"Clear or delete group?" = "Csoport ürítése vagy törlése?"; /* No comment provided by engineer. */ -"Clear private notes?" = "Kiüríti a privát jegyzeteket?"; +"Clear private notes?" = "Üríti a privát jegyzetek tartalmát?"; /* No comment provided by engineer. */ -"Clear verification" = "Hitelesítés törlése"; +"Clear verification" = "Ellenőrzés törlése"; /* No comment provided by engineer. */ "Color chats with the new themes." = "Csevegések színezése új témákkal."; @@ -1177,7 +1183,7 @@ set passcode view */ "Compare security codes with your contacts." = "Biztonsági kódok összehasonlítása a partnerekével."; /* No comment provided by engineer. */ -"complete" = "befejezett"; +"complete" = "kész"; /* No comment provided by engineer. */ "Completed" = "Elkészült"; @@ -1273,7 +1279,7 @@ set passcode view */ "Connect via link" = "Kapcsolódás egy hivatkozáson keresztül"; /* new chat sheet title */ -"Connect via one-time link" = "Kapcsolódás egyszer használható meghívón keresztül"; +"Connect via one-time link" = "Kapcsolódás az egyszer használható meghívón keresztül"; /* new chat action */ "Connect with %@" = "Kapcsolódás a következővel: %@"; @@ -1291,7 +1297,7 @@ set passcode view */ "Connected servers" = "Kapcsolódott kiszolgálók"; /* No comment provided by engineer. */ -"Connected to desktop" = "Kapcsolódva a számítógéphez"; +"Connected to desktop" = "Társítva a számítógéppel"; /* No comment provided by engineer. */ "connecting" = "kapcsolódás"; @@ -1312,7 +1318,7 @@ set passcode view */ "connecting (introduction invitation)" = "kapcsolódás (bemutatkozó meghívó)"; /* call status */ -"connecting call" = "kapcsolódási hívás…"; +"connecting call" = "hívás kapcsolása…"; /* No comment provided by engineer. */ "Connecting server…" = "Kapcsolódás a kiszolgálóhoz…"; @@ -1324,7 +1330,7 @@ set passcode view */ "Connecting to contact, please wait or check later!" = "Kapcsolódás a partnerhez, várjon vagy ellenőrizze később!"; /* No comment provided by engineer. */ -"Connecting to desktop" = "Kapcsolódás a számítógéphez"; +"Connecting to desktop" = "Társítás számítógéppel"; /* No comment provided by engineer. */ "connecting…" = "kapcsolódás…"; @@ -1399,10 +1405,10 @@ set passcode view */ "contact disabled" = "partner letiltva"; /* No comment provided by engineer. */ -"contact has e2e encryption" = "a partner e2e titkosítással rendelkezik"; +"contact has e2e encryption" = "a partner végpontok közötti titkosítással rendelkezik"; /* No comment provided by engineer. */ -"contact has no e2e encryption" = "a partner nem rendelkezik e2e titkosítással"; +"contact has no e2e encryption" = "a partner nem rendelkezik végpontok közötti titkosítással"; /* notification */ "Contact hidden:" = "Rejtett név:"; @@ -1486,7 +1492,7 @@ set passcode view */ "Create list" = "Lista létrehozása"; /* No comment provided by engineer. */ -"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógép-alkalmazásban](https://simplex.chat/downloads/). 💻"; +"Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Új profil létrehozása a [számítógépes alkalmazásban](https://simplex.chat/downloads/). 💻"; /* No comment provided by engineer. */ "Create profile" = "Profil létrehozása"; @@ -1656,7 +1662,7 @@ swipe action */ "Delete after" = "Törlés ennyi idő után"; /* No comment provided by engineer. */ -"Delete all files" = "Az összes fájl törlése"; +"Delete all files" = "Összes fájl törlése"; /* No comment provided by engineer. */ "Delete and notify contact" = "Törlés, és a partner értesítése"; @@ -1730,10 +1736,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Törli a tag üzenetét?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Tag üzeneteinek törlése"; + +/* alert title */ +"Delete member messages?" = "Törli a tag üzeneteit?"; + /* No comment provided by engineer. */ "Delete message?" = "Törli az üzenetet?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Üzenetek törlése"; /* No comment provided by engineer. */ @@ -1815,7 +1828,7 @@ swipe action */ "Desktop address" = "Számítógép címe"; /* No comment provided by engineer. */ -"Desktop app version %@ is not compatible with this app." = "A számítógép-alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; +"Desktop app version %@ is not compatible with this app." = "A számítógépes alkalmazás verziója (%@) nem kompatibilis ezzel az alkalmazással."; /* No comment provided by engineer. */ "Desktop devices" = "Számítógépek"; @@ -1935,7 +1948,7 @@ swipe action */ "Do not use credentials with proxy." = "Ne használja a hitelesítési adatokat proxyval."; /* No comment provided by engineer. */ -"Do NOT use private routing." = "NE használjon privát útválasztást."; +"Do NOT use private routing." = "NE legyen használva privát útválasztás."; /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NE használja a SimpleXet segélyhívásokhoz."; @@ -1947,13 +1960,13 @@ swipe action */ "Don't create address" = "Ne hozzon létre címet"; /* No comment provided by engineer. */ -"Don't enable" = "Ne engedélyezze"; +"Don't enable" = "Nem engedélyezem"; /* No comment provided by engineer. */ "Don't miss important messages." = "Ne maradjon le a fontos üzenetekről."; /* alert action */ -"Don't show again" = "Ne mutasd újra"; +"Don't show again" = "Ne jelenjen meg újra"; /* No comment provided by engineer. */ "Done" = "Kész"; @@ -2002,10 +2015,10 @@ chat item action */ "Duration" = "Időtartam"; /* No comment provided by engineer. */ -"e2e encrypted" = "e2e titkosított"; +"e2e encrypted" = "végpontok között titkosított"; /* No comment provided by engineer. */ -"E2E encrypted notifications." = "Végpontok közötti titkosított értesítések."; +"E2E encrypted notifications." = "Végpontok között titkosított értesítések."; /* chat item action */ "Edit" = "Szerkesztés"; @@ -2080,7 +2093,7 @@ chat item action */ "enabled for you" = "engedélyezve az Ön számára"; /* No comment provided by engineer. */ -"Encrypt" = "Titkosít"; +"Encrypt" = "Titkosítás"; /* No comment provided by engineer. */ "Encrypt database?" = "Titkosítja az adatbázist?"; @@ -2149,10 +2162,10 @@ chat item action */ "Encryption renegotiation in progress." = "A titkosítás újraegyeztetése folyamatban van."; /* No comment provided by engineer. */ -"ended" = "befejeződött"; +"ended" = "hívás vége"; /* call status */ -"ended call %@" = "%@ hívása befejeződött"; +"ended call %@" = "%@ hívása véget ért"; /* No comment provided by engineer. */ "Enter correct passphrase." = "Adja meg a helyes jelmondatot."; @@ -2431,7 +2444,7 @@ chat item action */ "Error uploading the archive" = "Hiba történt az archívum feltöltésekor"; /* No comment provided by engineer. */ -"Error verifying passphrase:" = "Hiba történt a jelmondat hitelesítésekor:"; +"Error verifying passphrase:" = "Hiba történt a jelmondat ellenőrzésekor:"; /* No comment provided by engineer. */ "Error: " = "Hiba: "; @@ -2564,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "A fájlok és a médiatartalmak küldése le van tiltva!"; +/* No comment provided by engineer. */ +"Filter" = "Szűrő"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Olvasatlan és kedvenc csevegésekre való szűrés."; @@ -2832,7 +2848,7 @@ snd error text */ "How SimpleX works" = "Hogyan működik a SimpleX"; /* No comment provided by engineer. */ -"How to" = "Hogyan"; +"How to" = "Útmutató"; /* No comment provided by engineer. */ "How to use it" = "Használati útmutató"; @@ -2856,7 +2872,7 @@ snd error text */ "If you enter your self-destruct passcode while opening the app:" = "Ha az alkalmazás megnyitásakor megadja az önmegsemmisítő jelkódot:"; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson alább a **Befejezés később** lehetőségre (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Ha most kell használnia a csevegést, koppintson lentebb a **Befejezés később** beállításra (az alkalmazás újraindításakor fel lesz ajánlva az adatbázis átköltöztetése)."; /* No comment provided by engineer. */ "Ignore" = "Mellőzés"; @@ -2867,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "A kép akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; +/* No comment provided by engineer. */ +"Images" = "Képek"; + /* No comment provided by engineer. */ "Immediately" = "Azonnal"; @@ -2979,7 +2998,7 @@ snd error text */ "Instant" = "Azonnali"; /* No comment provided by engineer. */ -"Instant push notifications will be hidden!\n" = "Az azonnali push-értesítések el lesznek rejtve!\n"; +"Instant push notifications will be hidden!\n" = "Az azonnali leküldéses értesítések el lesznek rejtve!\n"; /* No comment provided by engineer. */ "Interface" = "Kezelőfelület"; @@ -3050,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Barátok meghívása"; +/* No comment provided by engineer. */ +"Invite member" = "Tag meghívása"; + /* No comment provided by engineer. */ "Invite members" = "Tagok meghívása"; @@ -3072,10 +3094,10 @@ snd error text */ "invited via your group link" = "meghíva a saját csoporthivatkozásán keresztül"; /* No comment provided by engineer. */ -"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain is used to securely store passphrase - it allows receiving push notifications." = "Az iOS kulcstartó a jelmondat biztonságos tárolására szolgál – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ -"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a push-értesítések fogadását."; +"iOS Keychain will be used to securely store passphrase after you restart the app or change passphrase - it will allow receiving push notifications." = "Az iOS kulcstartó biztonságosan fogja tárolni a jelmondatot az alkalmazás újraindítása, vagy a jelmondat módosítása után – lehetővé teszi a leküldéses értesítések fogadását."; /* No comment provided by engineer. */ "IP address" = "IP-cím"; @@ -3141,7 +3163,7 @@ snd error text */ "Keep conversation" = "Beszélgetés megtartása"; /* No comment provided by engineer. */ -"Keep the app open to use it from desktop" = "A számítógépről való használathoz tartsd nyitva az alkalmazást"; +"Keep the app open to use it from desktop" = "Alkalmazás megnyitva tartása a számítógépről való használathoz"; /* alert title */ "Keep unused invitation?" = "Megtartja a fel nem használt meghívót?"; @@ -3195,7 +3217,7 @@ snd error text */ "Limitations" = "Korlátozások"; /* No comment provided by engineer. */ -"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗"; +"Link mobile and desktop apps! 🔗" = "Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗"; /* No comment provided by engineer. */ "Linked desktop options" = "Társított számítógép beállítások"; @@ -3203,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Társított számítógépek"; +/* No comment provided by engineer. */ +"Links" = "Hivatkozások"; + /* swipe action */ "List" = "Lista"; @@ -3252,7 +3277,7 @@ snd error text */ "Mark read" = "Megjelölés olvasottként"; /* No comment provided by engineer. */ -"Mark verified" = "Hitelesítés"; +"Mark verified" = "Megjelölés ellenőrzöttként"; /* No comment provided by engineer. */ "Markdown in messages" = "Markdown az üzenetekben"; @@ -3261,7 +3286,7 @@ snd error text */ "marked deleted" = "törlésre jelölve"; /* No comment provided by engineer. */ -"Max 30 seconds, received instantly." = "Max. 30 másodperc, azonnal érkezett."; +"Max 30 seconds, received instantly." = "Legfeljebb 30 másodperc, azonnal megérkezik."; /* No comment provided by engineer. */ "Media & file servers" = "Fájl- és médiakiszolgálók"; @@ -3296,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "A tag törölve lett – nem lehet elfogadni a kérést"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza!"; + /* chat feature */ "Member reports" = "Tagok jelentései"; @@ -3308,10 +3336,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "A tag szerepköre a következőre fog módosulni: „%@”. A tag új meghívást fog kapni."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "A tag el lesz távolítva a csevegésből – ez a művelet nem vonható vissza!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza!"; /* alert message */ @@ -3360,7 +3388,7 @@ snd error text */ "Message delivery warning" = "Üzenetkézbesítési figyelmeztetés"; /* No comment provided by engineer. */ -"Message draft" = "Üzenetvázlat"; +"Message draft" = "Piszkozatok"; /* item status text */ "Message forwarded" = "Továbbított üzenet"; @@ -3432,7 +3460,7 @@ snd error text */ "Messages sent" = "Elküldött üzenetek"; /* alert message */ -"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kijelölte őket."; +"Messages were deleted after you selected them." = "Az üzeneteket törölték miután kiváasztotta őket."; /* No comment provided by engineer. */ "Messages, files and calls are protected by **end-to-end encryption** with perfect forward secrecy, repudiation and break-in recovery." = "Az üzenetek, a fájlok és a hívások **végpontok közötti titkosítással**, kompromittálás előtti és utáni titkosságvédelemmel, illetve letagadhatósággal vannak védve."; @@ -3462,16 +3490,16 @@ snd error text */ "Migrating database archive…" = "Adatbázis-archívum átköltöztetése…"; /* No comment provided by engineer. */ -"Migration complete" = "Átköltöztetés befejezve"; +"Migration complete" = "Átköltöztetés kész"; /* No comment provided by engineer. */ "Migration error:" = "Átköltöztetési hiba:"; /* No comment provided by engineer. */ -"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** lehetőségre a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Sikertelen átköltöztetés. Koppintson a **Kihagyás** beállításra a jelenlegi adatbázis használatának folytatásához. Jelentse a problémát az alkalmazás fejlesztőinek csevegésben vagy e-mailben [chat@simplex.chat](mailto:chat@simplex.chat)."; /* No comment provided by engineer. */ -"Migration is completed" = "Az átköltöztetés befejeződött"; +"Migration is completed" = "Az átköltöztetés elkészült"; /* No comment provided by engineer. */ "Migrations:" = "Átköltöztetések:"; @@ -3573,10 +3601,10 @@ snd error text */ "New contact request" = "Új partneri kapcsolatkérés"; /* notification */ -"New contact:" = "Új kapcsolat:"; +"New contact:" = "Új partner:"; /* No comment provided by engineer. */ -"New desktop app!" = "Új számítógép-alkalmazás!"; +"New desktop app!" = "Új számítógépes alkalmazás!"; /* No comment provided by engineer. */ "New display name" = "Új megjelenítendő név"; @@ -3642,7 +3670,7 @@ snd error text */ "No chats with members" = "Nincsenek csevegések a tagokkal"; /* No comment provided by engineer. */ -"No contacts selected" = "Nincs partner kijelölve"; +"No contacts selected" = "Nincs partner kiválasztva"; /* No comment provided by engineer. */ "No contacts to add" = "Nincs hozzáadandó partner"; @@ -3657,7 +3685,7 @@ snd error text */ "No direct connection yet, message is forwarded by admin." = "Még nincs közvetlen kapcsolat, az üzenetet az adminisztrátor továbbítja."; /* No comment provided by engineer. */ -"no e2e encryption" = "nincs e2e titkosítás"; +"no e2e encryption" = "nincs végpontok közötti titkosítás"; /* No comment provided by engineer. */ "No filtered chats" = "Nincsenek szűrt csevegések"; @@ -3696,7 +3724,7 @@ snd error text */ "No private routing session" = "Nincs privát útválasztási munkamenet"; /* No comment provided by engineer. */ -"No push server" = "Helyi"; +"No push server" = "Nincs kiszolgáló a leküldéses értesítésekhez"; /* No comment provided by engineer. */ "No received or sent files" = "Nincsenek fogadott vagy küldött fájlok"; @@ -3714,7 +3742,7 @@ snd error text */ "No servers to send files." = "Nincsenek fájlküldési kiszolgálók."; /* No comment provided by engineer. */ -"no subscription" = "nincs előfizetés"; +"no subscription" = "nincs feliratkozás"; /* copied message info in history */ "no text" = "nincs szöveg"; @@ -3738,7 +3766,7 @@ snd error text */ "Notes" = "Jegyzetek"; /* No comment provided by engineer. */ -"Nothing selected" = "Nincs semmi kijelölve"; +"Nothing selected" = "Nincs semmi kiválasztva"; /* alert title */ "Nothing to forward!" = "Nincs mit továbbítani!"; @@ -3833,37 +3861,37 @@ new chat action */ "Only you can add message reactions." = "Csak Ön adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra)"; +"Only you can irreversibly delete messages (your contact can mark them for deletion). (24 hours)" = "Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra)"; /* No comment provided by engineer. */ -"Only you can make calls." = "Csak Ön tud hívásokat indítani."; +"Only you can make calls." = "Csak Ön kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only you can send disappearing messages." = "Csak Ön tud eltűnő üzeneteket küldeni."; +"Only you can send disappearing messages." = "Csak Ön küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ "Only you can send files and media." = "Csak Ön küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only you can send voice messages." = "Csak Ön tud hangüzeneteket küldeni."; +"Only you can send voice messages." = "Csak Ön küldhet hangüzeneteket."; /* No comment provided by engineer. */ "Only your contact can add message reactions." = "Csak a partnere adhat hozzá reakciókat az üzenetekhez."; /* No comment provided by engineer. */ -"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra)"; +"Only your contact can irreversibly delete messages (you can mark them for deletion). (24 hours)" = "Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra)"; /* No comment provided by engineer. */ -"Only your contact can make calls." = "Csak a partnere tud hívást indítani."; +"Only your contact can make calls." = "Csak a partnere kezdeményezhet hívásokat."; /* No comment provided by engineer. */ -"Only your contact can send disappearing messages." = "Csak a partnere tud eltűnő üzeneteket küldeni."; +"Only your contact can send disappearing messages." = "Csak a partnere küldhet eltűnő üzeneteket."; /* No comment provided by engineer. */ "Only your contact can send files and media." = "Csak a partnere küldhet fájlokat és médiatartalmakat."; /* No comment provided by engineer. */ -"Only your contact can send voice messages." = "Csak a partnere tud hangüzeneteket küldeni."; +"Only your contact can send voice messages." = "Csak a partnere küldhet hangüzeneteket."; /* alert action */ "Open" = "Megnyitás"; @@ -4070,7 +4098,7 @@ new chat action */ "Please report it to the developers." = "Jelentse a fejlesztőknek."; /* No comment provided by engineer. */ -"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges push-értesítések engedélyezéséhez."; +"Please restart the app and migrate the database to enable push notifications." = "Indítsa újra az alkalmazást az adatbázis-átköltöztetéséhez szükséges leküldéses értesítések engedélyezéséhez."; /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Tárolja el biztonságosan jelmondát, mert ha elveszti azt, akkor NEM férhet hozzá a csevegéshez."; @@ -4085,7 +4113,7 @@ new chat action */ "Please wait for group moderators to review your request to join the group." = "Várja meg, amíg a csoport moderátorai áttekintik a csoporthoz való csatlakozási kérését."; /* token info */ -"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása befejeződik."; +"Please wait for token activation to complete." = "Várjon, amíg a token aktiválása elkészül."; /* token info */ "Please wait for token to be registered." = "Várjon a token regisztrálására."; @@ -4181,7 +4209,7 @@ new chat action */ "Prohibit messages reactions." = "A reakciók hozzáadása az üzenetekhez le van tiltva."; /* No comment provided by engineer. */ -"Prohibit reporting messages to moderators." = "Az üzenetek a moderátorok felé történő jelentésének megtiltása."; +"Prohibit reporting messages to moderators." = "Az üzenetek jelentése a moderátorok felé le van tiltva."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "A közvetlen üzenetek küldése a tagok között le van tiltva."; @@ -4229,10 +4257,10 @@ new chat action */ "Proxy requires password" = "A proxy jelszót igényel"; /* No comment provided by engineer. */ -"Push notifications" = "Push-értesítések"; +"Push notifications" = "Leküldéses értesítések"; /* No comment provided by engineer. */ -"Push server" = "Push-kiszolgáló"; +"Push server" = "Leküldéses értesítéskiszolgáló"; /* chat item text */ "quantum resistant e2e encryption" = "végpontok közötti kvantumbiztos titkosítás"; @@ -4241,13 +4269,13 @@ new chat action */ "Quantum resistant encryption" = "Kvantumbiztos titkosítás"; /* No comment provided by engineer. */ -"Rate the app" = "Értékelje az alkalmazást"; +"Rate the app" = "Alkalmazás értékelése"; /* No comment provided by engineer. */ "Reachable chat toolbar" = "Könnyen elérhető csevegési eszköztár"; /* chat item menu */ -"React…" = "Reagálj…"; +"React…" = "Reagálás…"; /* swipe action */ "Read" = "Olvasott"; @@ -4274,7 +4302,7 @@ new chat action */ "Receive errors" = "Üzenetfogadási hibák"; /* No comment provided by engineer. */ -"received answer…" = "válasz fogadása…"; +"received answer…" = "válasz érkezett…"; /* No comment provided by engineer. */ "Received at" = "Fogadva"; @@ -4283,7 +4311,7 @@ new chat action */ "Received at: %@" = "Fogadva: %@"; /* No comment provided by engineer. */ -"received confirmation…" = "visszaigazolás fogadása…"; +"received confirmation…" = "visszaigazolás érkezett…"; /* message info title */ "Received message" = "Fogadott üzenetbuborék színe"; @@ -4360,7 +4388,7 @@ swipe action */ "Reject" = "Elutasítás"; /* No comment provided by engineer. */ -"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM fog értesítést kapni)"; +"Reject (sender NOT notified)" = "Elutasítás (a kérés küldője NEM lesz értesítve)"; /* alert title */ "Reject contact request" = "Partneri kapcsolatkérés elutasítása"; @@ -4380,9 +4408,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Eltávolítás"; +/* alert action */ +"Remove and delete messages" = "Eltávolítás és az üzeneteinek törlése"; + /* No comment provided by engineer. */ "Remove archive?" = "Eltávolítja az archívumot?"; @@ -4395,7 +4426,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Eltávolítás"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Eltávolítja a tagot?"; /* No comment provided by engineer. */ @@ -4501,7 +4532,7 @@ swipe action */ "Reset all hints" = "Tippek visszaállítása"; /* No comment provided by engineer. */ -"Reset all statistics" = "Az összes statisztika visszaállítása"; +"Reset all statistics" = "Összes statisztika visszaállítása"; /* No comment provided by engineer. */ "Reset all statistics?" = "Visszaállítja az összes statisztikát?"; @@ -4690,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "A keresősáv elfogadja a meghívási hivatkozásokat."; +/* No comment provided by engineer. */ +"Search files" = "Fájlok keresése"; + +/* No comment provided by engineer. */ +"Search images" = "Képek keresése"; + +/* No comment provided by engineer. */ +"Search links" = "Hivatkozások keresése"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Keresés vagy SimpleX-hivatkozás beillesztése"; +/* No comment provided by engineer. */ +"Search videos" = "Videók keresése"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Hangüzenetek keresése"; + /* network option */ "sec" = "mp"; @@ -4712,7 +4758,7 @@ chat item action */ "Secured" = "Biztosítva"; /* No comment provided by engineer. */ -"Security assessment" = "Biztonsági kiértékelés"; +"Security assessment" = "Biztonsági felmérés"; /* No comment provided by engineer. */ "Security code" = "Biztonsági kód"; @@ -4721,16 +4767,16 @@ chat item action */ "security code changed" = "biztonsági kódja módosult"; /* chat item action */ -"Select" = "Kijelölés"; +"Select" = "Kiválasztás"; /* No comment provided by engineer. */ -"Select chat profile" = "Csevegési profil kijelölése"; +"Select chat profile" = "Csevegési profil kiválasztása"; /* No comment provided by engineer. */ -"Selected %lld" = "%lld kijelölve"; +"Selected %lld" = "%lld kiválasztva"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "A kijelölt csevegési beállítások tiltják ezt az üzenetet."; +"Selected chat preferences prohibit this message." = "A kiválasztott csevegési beállítások tiltják ezt az üzenetet."; /* No comment provided by engineer. */ "Self-destruct" = "Önmegsemmisítés"; @@ -4823,13 +4869,13 @@ chat item action */ "Sending file will be stopped." = "A fájl küldése le fog állni."; /* No comment provided by engineer. */ -"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partnernél"; +"Sending receipts is disabled for %lld contacts" = "A kézbesítési jelentések le vannak tiltva %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is disabled for %lld groups" = "A kézbesítési jelentések le vannak tiltva %lld csoportban"; /* No comment provided by engineer. */ -"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partnernél"; +"Sending receipts is enabled for %lld contacts" = "A kézbesítési jelentések engedélyezve vannak %lld partner számára"; /* No comment provided by engineer. */ "Sending receipts is enabled for %lld groups" = "A kézbesítési jelentések engedélyezve vannak %lld csoportban"; @@ -4919,7 +4965,7 @@ chat item action */ "Servers statistics will be reset - this cannot be undone!" = "A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza!"; /* No comment provided by engineer. */ -"Session code" = "Munkamenet kód"; +"Session code" = "Munkamenet kódja"; /* No comment provided by engineer. */ "Set 1 day" = "Beállítva 1 nap"; @@ -4961,7 +5007,7 @@ chat item action */ "Set passphrase to export" = "Jelmondat beállítása az exportáláshoz"; /* No comment provided by engineer. */ -"Set profile bio and welcome message." = "Névjegy és üdvözlőüzenet beállítása a profilokhoz."; +"Set profile bio and welcome message." = "Életrajz és üdvözlőüzenet beállítása a profilokhoz."; /* No comment provided by engineer. */ "Set the message shown to new members!" = "Megjelenítendő üzenet beállítása az új tagok számára!"; @@ -5079,7 +5125,7 @@ chat item action */ "SimpleX address or 1-time link?" = "SimpleX-cím vagy egyszer használható meghívó?"; /* alert title */ -"SimpleX address settings" = "Beállítások automatikus elfogadása"; +"SimpleX address settings" = "SimpleX-címbeállítások"; /* simplex link type */ "SimpleX channel link" = "SimpleX-csatornahivatkozás"; @@ -5142,7 +5188,7 @@ chat item action */ "Skipped messages" = "Kihagyott üzenetek"; /* No comment provided by engineer. */ -"Small groups (max 20)" = "Kis csoportok (max. 20 tag)"; +"Small groups (max 20)" = "Kis csoportok (legfeljebb 20 tag)"; /* No comment provided by engineer. */ "SMP server" = "SMP-kiszolgáló"; @@ -5182,7 +5228,7 @@ report reason */ "standard end-to-end encryption" = "szabványos végpontok közötti titkosítás"; /* No comment provided by engineer. */ -"Start chat" = "Csevegés indítása"; +"Start chat" = "Csevegés elindítása"; /* No comment provided by engineer. */ "Start chat?" = "Elindítja a csevegést?"; @@ -5194,7 +5240,7 @@ report reason */ "Starting from %@." = "Statisztikagyűjtés kezdete: %@."; /* No comment provided by engineer. */ -"starting…" = "indítás…"; +"starting…" = "hívás indítása…"; /* No comment provided by engineer. */ "Statistics" = "Statisztikák"; @@ -5425,10 +5471,10 @@ report reason */ "The second preset operator in the app!" = "A második előre beállított üzemeltető az alkalmazásban!"; /* No comment provided by engineer. */ -"The second tick we missed! ✅" = "A második jelölés, amit kihagytunk! ✅"; +"The second tick we missed! ✅" = "A második pipa, ami már nagyon hiányzott! ✅"; /* alert message */ -"The sender will NOT be notified" = "A kérés küldője NEM fog értesítést kapni"; +"The sender will NOT be notified" = "A kérés küldője NEM lesz értesítve"; /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "A jelenlegi **%@** nevű csevegési profiljához tartozó új kapcsolatok kiszolgálói."; @@ -5458,10 +5504,10 @@ report reason */ "This action cannot be undone - all received and sent files and media will be deleted. Low resolution pictures will remain." = "Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak."; /* No comment provided by engineer. */ -"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; +"This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet."; /* alert message */ -"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből."; /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Ez a művelet nem vonható vissza – profiljai, partnerei, üzenetei és fájljai véglegesen törölve lesznek."; @@ -5557,7 +5603,7 @@ report reason */ "To send commands you must be connected." = "A parancsok küldéséhez kapcsolódva kell lennie."; /* No comment provided by engineer. */ -"To support instant push notifications the chat database has to be migrated." = "Az azonnali push-értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; +"To support instant push notifications the chat database has to be migrated." = "Az azonnali leküldéses értesítések támogatásához a csevegési adatbázis átköltöztetése szükséges."; /* alert message */ "To use another profile after connection attempt, delete the chat and use the link again." = "Másik profil használatához a kapcsolatfelvételi kísérlet után törölje a csevegést, és használja újra a hivatkozást."; @@ -5566,7 +5612,7 @@ report reason */ "To use the servers of **%@**, accept conditions of use." = "A(z) **%@** kiszolgálóinak használatához fogadja el a használati feltételeket."; /* No comment provided by engineer. */ -"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; +"To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal."; /* No comment provided by engineer. */ "Toggle chat list:" = "Csevegési lista ki/be:"; @@ -5701,7 +5747,7 @@ report reason */ "Update" = "Frissítés"; /* No comment provided by engineer. */ -"Update database passphrase" = "Az adatbázis jelmondatának módosítása"; +"Update database passphrase" = "Adatbázis jelmondatának módosítása"; /* No comment provided by engineer. */ "Update network settings?" = "Módosítja a hálózati beállításokat?"; @@ -5830,7 +5876,7 @@ report reason */ "Use web port" = "Webport használata"; /* No comment provided by engineer. */ -"User selection" = "Felhasználó kijelölése"; +"User selection" = "Felhasználó kiválasztása"; /* No comment provided by engineer. */ "Username" = "Felhasználónév"; @@ -5845,25 +5891,25 @@ report reason */ "v%@ (%@)" = "v%@ (%@)"; /* No comment provided by engineer. */ -"Verify code with desktop" = "Kód hitelesítése a számítógépen"; +"Verify code with desktop" = "Kód ellenőrzése a számítógépen"; /* No comment provided by engineer. */ -"Verify connection" = "Kapcsolat hitelesítése"; +"Verify connection" = "Kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify connection security" = "Biztonságos kapcsolat hitelesítése"; +"Verify connection security" = "Biztonságos kapcsolat ellenőrzése"; /* No comment provided by engineer. */ -"Verify connections" = "Kapcsolatok hitelesítése"; +"Verify connections" = "Kapcsolatok ellenőrzése"; /* No comment provided by engineer. */ -"Verify database passphrase" = "Az adatbázis jelmondatának hitelesítése"; +"Verify database passphrase" = "Adatbázis jelmondatának ellenőrzése"; /* No comment provided by engineer. */ -"Verify passphrase" = "Jelmondat hitelesítése"; +"Verify passphrase" = "Jelmondat ellenőrzése"; /* No comment provided by engineer. */ -"Verify security code" = "Biztonsági kód hitelesítése"; +"Verify security code" = "Biztonsági kód ellenőrzése"; /* No comment provided by engineer. */ "Via browser" = "Böngészőn keresztül"; @@ -5890,7 +5936,7 @@ report reason */ "Video call" = "Videóhívás"; /* No comment provided by engineer. */ -"video call (not e2e encrypted)" = "videóhívás (nem e2e titkosított)"; +"video call (not e2e encrypted)" = "videóhívás (végpontok között NEM titkosított)"; /* No comment provided by engineer. */ "Video will be received when your contact completes uploading it." = "A videó akkor érkezik meg, amikor a küldője befejezte annak feltöltését."; @@ -5898,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "A videó akkor érkezik meg, amikor a küldője elérhető lesz, várjon, vagy ellenőrizze később!"; +/* No comment provided by engineer. */ +"Videos" = "Videók"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Videók és fájlok legfeljebb 1GB méretig"; @@ -6091,7 +6140,7 @@ report reason */ "You are invited to group" = "Ön meghívást kapott a csoportba"; /* subscription status explanation */ -"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés)."; +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás)."; /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Ön nem kapcsolódik ezekhez a kiszolgálókhoz. A privát útválasztás az üzenetek kézbesítésére szolgál."; @@ -6148,7 +6197,7 @@ report reason */ "You can share this address with your contacts to let them connect with **%@**." = "Megoszthatja ezt a SimpleX-címet a partnereivel, hogy kapcsolatba léphessenek vele: **%@**."; /* No comment provided by engineer. */ -"You can start chat via app Settings / Database or by restarting the app" = "A csevegést az alkalmazás „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával indíthatja el"; +"You can start chat via app Settings / Database or by restarting the app" = "A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával"; /* No comment provided by engineer. */ "You can still view conversation with %@ in the list of chats." = "A(z) %@ nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában."; @@ -6181,7 +6230,7 @@ report reason */ "you changed role of %@ to %@" = "Ön a következőre módosította %1$@ szerepkörét: „%2$@”"; /* No comment provided by engineer. */ -"You could not be verified; please try again." = "Nem sikerült hitelesíteni; próbálja meg újra."; +"You could not be verified; please try again." = "Nem sikerült ellenőrizni; próbálja meg újra."; /* No comment provided by engineer. */ "You decide who can connect." = "Ön dönti el, hogy kivel beszélget."; @@ -6262,10 +6311,10 @@ report reason */ "You will still receive calls and notifications from muted profiles when they are active." = "Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak."; /* No comment provided by engineer. */ -"You will stop receiving messages from this group. Chat history will be preserved." = "Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak."; +"You will stop receiving messages from this group. Chat history will be preserved." = "Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak."; /* No comment provided by engineer. */ "You won't lose your contacts if you later delete your address." = "Nem veszíti el a partnereit, ha később törli a címét."; @@ -6307,13 +6356,13 @@ report reason */ "Your contact" = "Partner"; /* No comment provided by engineer. */ -"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg megengedett maximális méretű (%@) fájlnál nagyobbat küldött."; +"Your contact sent a file that is larger than currently supported maximum size (%@)." = "A partnere a jelenleg támogatott legnagyobb (%@) fájlméretnél nagyobbat küldött."; /* No comment provided by engineer. */ "Your contacts can allow full message deletion." = "A partnerei engedélyezhetik a teljes üzenet törlését."; /* No comment provided by engineer. */ -"Your contacts will remain connected." = "A partnerei továbbra is kapcsolódva maradnak."; +"Your contacts will remain connected." = "A partnereivel továbbra is kapcsolatban marad."; /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "A hitelesítési adatai titkosítatlanul is elküldhetők."; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 511a6835e5..3955f267ce 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -512,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "tutti i membri"; +/* No comment provided by engineer. */ +"All messages" = "Tutti i messaggi"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Tutti i messaggi e i file vengono inviati **crittografati end-to-end**, con sicurezza resistenti alla quantistica nei messaggi diretti."; @@ -734,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Chiamate audio e video"; +/* No comment provided by engineer. */ +"Audio call" = "Chiamata audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "chiamata audio (non crittografata e2e)"; @@ -1730,10 +1736,17 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "Eliminare il messaggio del membro?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Elimina i messaggi del membro"; + +/* alert title */ +"Delete member messages?" = "Eliminare i messaggi del membro?"; + /* No comment provided by engineer. */ "Delete message?" = "Eliminare il messaggio?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Elimina messaggi"; /* No comment provided by engineer. */ @@ -2564,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "File e contenuti multimediali vietati!"; +/* No comment provided by engineer. */ +"Filter" = "Filtro"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtra le chat non lette e preferite."; @@ -2867,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "L'immagine verrà ricevuta quando il tuo contatto sarà in linea, aspetta o controlla più tardi!"; +/* No comment provided by engineer. */ +"Images" = "Immagini"; + /* No comment provided by engineer. */ "Immediately" = "Immediatamente"; @@ -3050,6 +3069,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Invita amici"; +/* No comment provided by engineer. */ +"Invite member" = "Invita membro"; + /* No comment provided by engineer. */ "Invite members" = "Invita membri"; @@ -3203,6 +3225,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Desktop collegati"; +/* No comment provided by engineer. */ +"Links" = "Link"; + /* swipe action */ "List" = "Elenco"; @@ -3296,6 +3321,9 @@ snd error text */ /* No comment provided by engineer. */ "Member is deleted - can't accept request" = "Il membro è eliminato - impossibile accettare la richiesta"; +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "I messaggi del membro verranno eliminati. Non è reversibile!"; + /* chat feature */ "Member reports" = "Segnalazioni dei membri"; @@ -3308,10 +3336,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Il ruolo del membro verrà cambiato in \"%@\". Il membro riceverà un invito nuovo."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Il membro verrà rimosso dalla chat, non è reversibile!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Il membro verrà rimosso dal gruppo, non è reversibile!"; /* alert message */ @@ -3899,7 +3927,7 @@ new chat action */ "Open new chat" = "Apri una chat nuova"; /* new chat action */ -"Open new group" = "Apri un gruppo nuovo"; +"Open new group" = "Apri il nuovo gruppo"; /* No comment provided by engineer. */ "Open Settings" = "Apri le impostazioni"; @@ -4380,9 +4408,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Il server relay protegge il tuo indirizzo IP, ma può osservare la durata della chiamata."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Rimuovi"; +/* alert action */ +"Remove and delete messages" = "Rimuovi ed elimina i messaggi"; + /* No comment provided by engineer. */ "Remove archive?" = "Rimuovere l'archivio?"; @@ -4395,7 +4426,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Rimuovi membro"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Rimuovere il membro?"; /* No comment provided by engineer. */ @@ -4690,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "La barra di ricerca accetta i link di invito."; +/* No comment provided by engineer. */ +"Search files" = "Cerca file"; + +/* No comment provided by engineer. */ +"Search images" = "Cerca immagini"; + +/* No comment provided by engineer. */ +"Search links" = "Cerca link"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Cerca o incolla un link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Cerca video"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Cerca messaggi vocali"; + /* network option */ "sec" = "sec"; @@ -5898,6 +5944,9 @@ report reason */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Il video verrà ricevuto quando il tuo contatto sarà in linea, attendi o controlla più tardi!"; +/* No comment provided by engineer. */ +"Videos" = "Video"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Video e file fino a 1 GB"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index d4510af72f..480eb39d36 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -374,9 +374,18 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledged" = "了承済み"; +/* No comment provided by engineer. */ +"Active connections" = "アクティブな接続"; + /* No comment provided by engineer. */ "Add address to your profile, so that your contacts can share it with other people. Profile update will be sent to your contacts." = "プロフィールにアドレスを追加し、連絡先があなたのアドレスを他の人と共有できるようにします。プロフィールの更新は連絡先に送信されます。"; +/* No comment provided by engineer. */ +"Add friends" = "友達を追加"; + +/* No comment provided by engineer. */ +"Add list" = "リストを追加"; + /* No comment provided by engineer. */ "Add profile" = "プロフィールを追加"; @@ -386,9 +395,15 @@ swipe action */ /* No comment provided by engineer. */ "Add servers by scanning QR codes." = "QRコードでサーバを追加する。"; +/* No comment provided by engineer. */ +"Add team members" = "チームメンバーを追加"; + /* No comment provided by engineer. */ "Add to another device" = "別の端末に追加"; +/* No comment provided by engineer. */ +"Add to list" = "リストに追加"; + /* No comment provided by engineer. */ "Add welcome message" = "ウェルカムメッセージを追加"; @@ -404,6 +419,9 @@ swipe action */ /* No comment provided by engineer. */ "Address change will be aborted. Old receiving address will be used." = "アドレス変更は中止されます。古い受信アドレスが使用されます。"; +/* No comment provided by engineer. */ +"Address settings" = "アドレス設定"; + /* member role */ "admin" = "管理者"; @@ -422,6 +440,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "暗号化に同意しています…"; +/* No comment provided by engineer. */ +"All" = "すべて"; + /* No comment provided by engineer. */ "All app data is deleted." = "すべてのアプリデータが削除されます。"; @@ -434,6 +455,9 @@ swipe action */ /* No comment provided by engineer. */ "All group members will remain connected." = "グループ全員の接続が継続します。"; +/* No comment provided by engineer. */ +"All messages will be deleted - this cannot be undone!" = "すべてのメッセージが削除されます。この操作は元に戻せません!"; + /* No comment provided by engineer. */ "All messages will be deleted - this cannot be undone! The messages will be deleted ONLY for you." = "全てのメッセージが削除されます(※注意:元に戻せません!※)。削除されるのは片方あなたのメッセージのみ。"; @@ -455,9 +479,15 @@ swipe action */ /* No comment provided by engineer. */ "Allow calls only if your contact allows them." = "連絡先が通話を許可している場合のみ通話を許可する。"; +/* No comment provided by engineer. */ +"Allow calls?" = "通話を許可しますか?"; + /* No comment provided by engineer. */ "Allow disappearing messages only if your contact allows it to you." = "連絡先が許可している場合のみ消えるメッセージを許可する。"; +/* No comment provided by engineer. */ +"Allow downgrade" = "ダウングレードを許可する"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "送信相手も永久メッセージ削除を許可する時のみに許可する。(24時間)"; @@ -530,6 +560,9 @@ swipe action */ /* No comment provided by engineer. */ "An empty chat profile with the provided name is created, and the app opens as usual." = "指定された名前の空のチャット プロファイルが作成され、アプリが通常どおり開きます。"; +/* report reason */ +"Another reason" = "他の理由"; + /* No comment provided by engineer. */ "Answer call" = "通話に応答"; @@ -569,9 +602,15 @@ swipe action */ /* No comment provided by engineer. */ "Apply to" = "に適用する"; +/* No comment provided by engineer. */ +"Archive" = "アーカイブ"; + /* No comment provided by engineer. */ "Archive and upload" = "アーカイブとアップロード"; +/* No comment provided by engineer. */ +"Archived contacts" = "アーカイブされた連絡先"; + /* No comment provided by engineer. */ "Attach" = "添付する"; @@ -668,6 +707,9 @@ swipe action */ /* No comment provided by engineer. */ "Calls" = "通話"; +/* alert title */ +"Can't change profile" = "プロフィールを変更できません"; + /* No comment provided by engineer. */ "Can't invite contact!" = "連絡先を招待できません!"; @@ -679,12 +721,18 @@ alert button new chat action */ "Cancel" = "中止"; +/* No comment provided by engineer. */ +"Cancel migration" = "移行を中止する"; + /* feature offered item */ "cancelled %@" = "キャンセルされました %@"; /* No comment provided by engineer. */ "Cannot access keychain to save database password" = "データベースのパスワードを保存するためのキーチェーンにアクセスできません"; +/* No comment provided by engineer. */ +"Cannot forward message" = "メッセージを転送できません"; + /* alert title */ "Cannot receive file" = "ファイル受信ができません"; @@ -734,6 +782,9 @@ set passcode view */ /* chat item text */ "changing address…" = "アドレスを変更しています…"; +/* No comment provided by engineer. */ +"Chat" = "チャット"; + /* No comment provided by engineer. */ "Chat console" = "チャットのコンソール"; @@ -752,6 +803,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chat is stopped" = "チャットが停止してます"; +/* No comment provided by engineer. */ +"Chat list" = "チャット一覧"; + /* No comment provided by engineer. */ "Chat preferences" = "チャット設定"; @@ -764,6 +818,9 @@ set passcode view */ /* No comment provided by engineer. */ "Chats" = "チャット"; +/* No comment provided by engineer. */ +"Check messages every 20 min." = "20分おきにメッセージを確認する。"; + /* alert title */ "Check server address and try again." = "サーバのアドレスを確認してから再度試してください。"; @@ -1168,7 +1225,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "メッセージを削除しますか?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "メッセージを削除"; /* No comment provided by engineer. */ @@ -2103,7 +2161,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "メンバーの役割が \"%@\" に変更されます。 メンバーは新たな招待を受け取ります。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "メンバーをグループから除名する (※元に戻せません※)!"; /* No comment provided by engineer. */ @@ -2644,13 +2702,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "リレー サーバーは IP アドレスを保護しますが、通話時間は監視されます。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "削除"; /* No comment provided by engineer. */ "Remove member" = "メンバーを除名する"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "メンバーを除名しますか?"; /* No comment provided by engineer. */ diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 79e3da3b01..29f8bb5b3f 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -1688,7 +1688,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Verwijder bericht?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Verwijder berichten"; /* No comment provided by engineer. */ @@ -3197,10 +3198,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "De rol van lid wordt gewijzigd in \"%@\". Het lid ontvangt een nieuwe uitnodiging."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Lid wordt verwijderd uit de chat - dit kan niet ongedaan worden gemaakt!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt!"; /* alert message */ @@ -4218,7 +4219,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay server beschermt uw IP-adres, maar kan de duur van het gesprek observeren."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Verwijderen"; /* No comment provided by engineer. */ @@ -4230,7 +4231,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Lid verwijderen"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Lid verwijderen?"; /* No comment provided by engineer. */ diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 34c79eeef4..ed1f8850d8 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -257,7 +257,7 @@ "`a + b`" = "\\`a + b`"; /* email text */ -"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat.

"; +"

Hi!

\n

Connect to me via SimpleX Chat

" = "

Cześć!

\n

Połącz się ze mną poprzez SimpleX Chat

"; /* No comment provided by engineer. */ "~strike~" = "\\~strajk~"; @@ -346,12 +346,21 @@ alert action swipe action */ "Accept" = "Akceptuj"; +/* alert action */ +"Accept as member" = "Zaakceptuj jako członka"; + +/* alert action */ +"Accept as observer" = "Zaakceptuj jako obserwatora"; + /* No comment provided by engineer. */ "Accept conditions" = "Zaakceptuj warunki"; /* No comment provided by engineer. */ "Accept connection request?" = "Zaakceptować prośbę o połączenie?"; +/* alert title */ +"Accept contact request" = "Zaakceptuj prośby o kontakt"; + /* notification body */ "Accept contact request from %@?" = "Zaakceptuj prośbę o kontakt od %@?"; @@ -359,12 +368,24 @@ swipe action */ swipe action */ "Accept incognito" = "Akceptuj incognito"; +/* alert title */ +"Accept member" = "Zaakceptuj członka"; + +/* rcv group event chat item */ +"accepted %@" = "zaakceptowano %@"; + /* call status */ "accepted call" = "zaakceptowane połączenie"; /* No comment provided by engineer. */ "Accepted conditions" = "Zaakceptowano warunki"; +/* chat list item title */ +"accepted invitation" = "zaproszenie zaakceptowane"; + +/* rcv group event chat item */ +"accepted you" = "przyjął cię"; + /* No comment provided by engineer. */ "Acknowledged" = "Potwierdzono"; @@ -386,6 +407,9 @@ swipe action */ /* No comment provided by engineer. */ "Add list" = "Dodaj listę"; +/* placeholder for sending contact request */ +"Add message" = "Dodaj wiadomość"; + /* No comment provided by engineer. */ "Add profile" = "Dodaj profil"; @@ -461,6 +485,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "uzgadnianie szyfrowania…"; +/* member criteria value */ +"all" = "wszystkie"; + /* No comment provided by engineer. */ "All" = "Wszystko"; @@ -485,6 +512,9 @@ swipe action */ /* feature role */ "all members" = "wszyscy członkowie"; +/* No comment provided by engineer. */ +"All messages" = "Wszystkie wiadomości"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "Wszystkie wiadomości i pliki są wysyłane **z szyfrowaniem end-to-end**, z bezpieczeństwem postkwantowym w wiadomościach bezpośrednich."; @@ -503,6 +533,9 @@ swipe action */ /* No comment provided by engineer. */ "All reports will be archived for you." = "Wszystkie raporty zostaną dla Ciebie zarchiwizowane."; +/* No comment provided by engineer. */ +"All servers" = "Wszystkie serwery"; + /* No comment provided by engineer. */ "All your contacts will remain connected." = "Wszystkie Twoje kontakty pozostaną połączone."; @@ -527,6 +560,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "Zezwól na obniżenie wersji"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "Zezwalaj na pliki i media tylko wtedy, gdy Twój kontakt na to pozwala."; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "Zezwalaj na nieodwracalne usuwanie wiadomości tylko wtedy, gdy Twój kontakt Ci na to pozwoli. (24 godziny)"; @@ -578,6 +614,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "Zezwól swoim kontaktom na wysyłanie znikających wiadomości."; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "Pozwól kontaktom wysyłać pliki i media."; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "Zezwól swoim kontaktom na wysyłanie wiadomości głosowych."; @@ -680,6 +719,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "Zarchiwizowane kontakty"; +/* No comment provided by engineer. */ +"archived report" = "zarchiwizowany raport"; + /* No comment provided by engineer. */ "Archiving database" = "Archiwizowanie bazy danych"; @@ -695,6 +737,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "Połączenia audio i wideo"; +/* No comment provided by engineer. */ +"Audio call" = "Połączenie audio"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "połączenie audio (nie szyfrowane e2e)"; @@ -755,6 +800,9 @@ swipe action */ /* No comment provided by engineer. */ "Better groups" = "Lepsze grupy"; +/* No comment provided by engineer. */ +"Better groups performance" = "Lepsze działanie grup"; + /* No comment provided by engineer. */ "Better message dates." = "Lepsze daty wiadomości."; @@ -767,12 +815,21 @@ swipe action */ /* No comment provided by engineer. */ "Better notifications" = "Lepsze powiadomienia"; +/* No comment provided by engineer. */ +"Better privacy and security" = "Lepsza prywatność i bezpieczeństwo"; + /* No comment provided by engineer. */ "Better security ✅" = "Lepsze zabezpieczenia ✅"; /* No comment provided by engineer. */ "Better user experience" = "Lepszy interfejs użytkownika"; +/* No comment provided by engineer. */ +"Bio" = "Bio"; + +/* alert title */ +"Bio too large" = "Bio jest za długie"; + /* No comment provided by engineer. */ "Black" = "Czarny"; @@ -816,6 +873,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "pogrubiona"; +/* No comment provided by engineer. */ +"Bot" = "Bot"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "Zarówno Ty, jak i Twój kontakt możecie dodawać reakcje wiadomości."; @@ -828,6 +888,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać pliki i media."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Zarówno Ty, jak i Twój kontakt możecie wysyłać wiadomości głosowe."; @@ -840,12 +903,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "Czaty biznesowe"; +/* No comment provided by engineer. */ +"Business connection" = "Kontakty biznesowe"; + /* No comment provided by engineer. */ "Businesses" = "Firmy"; /* No comment provided by engineer. */ "By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Według profilu czatu (domyślnie) lub [według połączenia](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +/* No comment provided by engineer. */ +"By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users – no spam." = "Korzystając z SimpleX Chat, zgadzasz się:\n- wysyłać tylko legalne treści w grupach publicznych.\n- szanować innych użytkowników – nie spamować."; + /* No comment provided by engineer. */ "call" = "zadzwoń"; @@ -876,6 +945,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "Nie można zadzwonić do członka"; +/* alert title */ +"Can't change profile" = "Nie można zmienić profilu"; + /* No comment provided by engineer. */ "Can't invite contact!" = "Nie można zaprosić kontaktu!"; @@ -885,6 +957,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Nie można wysłać wiadomości do członka"; +/* No comment provided by engineer. */ +"can't send messages" = "nie można wysłać wiadomości"; + /* alert action alert button new chat action */ @@ -914,6 +989,9 @@ new chat action */ /* No comment provided by engineer. */ "Change" = "Zmień"; +/* alert title */ +"Change automatic message deletion?" = "Zmienić automatyczne usuwanie wiadomości?"; + /* authentication reason */ "Change chat profiles" = "Zmień profil czatu"; @@ -1020,9 +1098,21 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "Czat zostanie usunięty dla Ciebie – tej operacji nie można cofnąć!"; +/* chat toolbar */ +"Chat with admins" = "Czatuj z administratorami"; + +/* No comment provided by engineer. */ +"Chat with member" = "Czatuj z członkiem"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "Porozmawiaj z członkami, zanim dołączą."; + /* No comment provided by engineer. */ "Chats" = "Czaty"; +/* No comment provided by engineer. */ +"Chats with members" = "Czaty z członkami"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "Sprawdzaj wiadomości co 20 min."; @@ -1062,6 +1152,12 @@ set passcode view */ /* No comment provided by engineer. */ "Clear conversation?" = "Wyczyścić rozmowę?"; +/* No comment provided by engineer. */ +"Clear group?" = "Wyczyścić grupę?"; + +/* No comment provided by engineer. */ +"Clear or delete group?" = "Wyczyścić lub usunąć grupę?"; + /* No comment provided by engineer. */ "Clear private notes?" = "Wyczyścić prywatne notatki?"; @@ -1077,6 +1173,9 @@ set passcode view */ /* No comment provided by engineer. */ "colored" = "kolorowy"; +/* report reason */ +"Community guidelines violation" = "Naruszenie zasad społeczności"; + /* server test step */ "Compare file" = "Porównaj plik"; @@ -1101,9 +1200,21 @@ set passcode view */ /* alert button */ "Conditions of use" = "Warunki użytkowania"; +/* No comment provided by engineer. */ +"Conditions will be accepted for the operator(s): **%@**." = "Warunki zostaną zaakceptowane dla operatora(-ów): **%@**."; + +/* No comment provided by engineer. */ +"Conditions will be accepted on: %@." = "Warunki zostaną zaakceptowane w dniu: %@."; + +/* No comment provided by engineer. */ +"Conditions will be automatically accepted for enabled operators on: %@." = "Warunki zostaną automatycznie zaakceptowane dla aktywnych operatorów w dniu: %@."; + /* No comment provided by engineer. */ "Configure ICE servers" = "Skonfiguruj serwery ICE"; +/* No comment provided by engineer. */ +"Configure server operators" = "Skonfiguruj operatorów serwerów"; + /* No comment provided by engineer. */ "Confirm" = "Potwierdź"; @@ -1134,12 +1245,18 @@ set passcode view */ /* No comment provided by engineer. */ "Confirm upload" = "Potwierdź wgranie"; +/* token status text */ +"Confirmed" = "Potwierdzony"; + /* server test step */ "Connect" = "Połącz"; /* No comment provided by engineer. */ "Connect automatically" = "Łącz automatycznie"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "Połącz się szybciej! 🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "Połącz do komputera"; @@ -1224,6 +1341,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connection and servers status." = "Stan połączenia i serwerów."; +/* No comment provided by engineer. */ +"Connection blocked" = "Połączenie zablokowane"; + /* alert title */ "Connection error" = "Błąd połączenia"; @@ -1233,12 +1353,24 @@ set passcode view */ /* chat list item title (it should not be shown */ "connection established" = "połączenie ustanowione"; +/* No comment provided by engineer. */ +"Connection is blocked by server operator:\n%@" = "Połączenie zostało zablokowane przez operatora serwera:\n%@"; + +/* No comment provided by engineer. */ +"Connection not ready." = "Połączenie nie jest gotowe."; + /* No comment provided by engineer. */ "Connection notifications" = "Powiadomienia o połączeniu"; /* No comment provided by engineer. */ "Connection request sent!" = "Prośba o połączenie wysłana!"; +/* No comment provided by engineer. */ +"Connection requires encryption renegotiation." = "Połączenie wymaga renegocjacji szyfrowania."; + +/* No comment provided by engineer. */ +"Connection security" = "Bezpieczeństwo połączenia"; + /* No comment provided by engineer. */ "Connection terminated" = "Połączenie zakończone"; @@ -1263,9 +1395,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "Kontakt już istnieje"; +/* No comment provided by engineer. */ +"contact deleted" = "kontakt usunięty"; + /* No comment provided by engineer. */ "Contact deleted!" = "Kontakt usunięty!"; +/* No comment provided by engineer. */ +"contact disabled" = "kontakt wyłączony"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "kontakt posiada szyfrowanie e2e"; @@ -1284,9 +1422,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "Nazwa kontaktu"; +/* No comment provided by engineer. */ +"contact not ready" = "kontakt nie gotowy"; + /* No comment provided by engineer. */ "Contact preferences" = "Preferencje kontaktu"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "Prośby o kontakt od grup"; + +/* No comment provided by engineer. */ +"contact should accept…" = "kontakt powinien zaakceptować…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Kontakt zostanie usunięty – nie można tego cofnąć!"; @@ -1296,6 +1443,9 @@ set passcode view */ /* No comment provided by engineer. */ "Contacts can mark messages for deletion; you will be able to view them." = "Kontakty mogą oznaczać wiadomości do usunięcia; będziesz mógł je zobaczyć."; +/* blocking reason */ +"Content violates conditions of use" = "Treść narusza warunki użytkowania"; + /* No comment provided by engineer. */ "Continue" = "Kontynuuj"; @@ -1320,6 +1470,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create" = "Utwórz"; +/* No comment provided by engineer. */ +"Create 1-time link" = "Utwórz jednorazowy link"; + /* No comment provided by engineer. */ "Create a group using a random profile." = "Utwórz grupę używając losowego profilu."; @@ -1335,6 +1488,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create link" = "Utwórz link"; +/* No comment provided by engineer. */ +"Create list" = "Utwórz listę"; + /* No comment provided by engineer. */ "Create new profile in [desktop app](https://simplex.chat/downloads/). 💻" = "Utwórz nowy profil w [aplikacji desktopowej](https://simplex.chat/downloads/). 💻"; @@ -1347,6 +1503,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "Utwórz adres SimpleX"; +/* No comment provided by engineer. */ +"Create your address" = "Utwórz swój adres"; + /* No comment provided by engineer. */ "Create your profile" = "Utwórz swój profil"; @@ -1368,6 +1527,9 @@ set passcode view */ /* No comment provided by engineer. */ "creator" = "twórca"; +/* No comment provided by engineer. */ +"Current conditions text couldn't be loaded, you can review conditions via this link:" = "Nie można załadować tekstu dotyczącego aktualnych warunków. Możesz zapoznać się z warunkami, klikając ten link:"; + /* No comment provided by engineer. */ "Current Passcode" = "Aktualny Pin"; @@ -1386,6 +1548,9 @@ set passcode view */ /* No comment provided by engineer. */ "Custom time" = "Niestandardowy czas"; +/* No comment provided by engineer. */ +"Customizable message shape." = "Konfigurowalny kształt wiadomości."; + /* No comment provided by engineer. */ "Customize theme" = "Dostosuj motyw"; @@ -1502,12 +1667,24 @@ swipe action */ /* No comment provided by engineer. */ "Delete and notify contact" = "Usuń i powiadom kontakt"; +/* No comment provided by engineer. */ +"Delete chat" = "Usuń czat"; + +/* No comment provided by engineer. */ +"Delete chat messages from your device." = "Usuń wiadomości czatu ze swojego urządzenia."; + /* No comment provided by engineer. */ "Delete chat profile" = "Usuń profil czatu"; /* No comment provided by engineer. */ "Delete chat profile?" = "Usunąć profil czatu?"; +/* alert title */ +"Delete chat with member?" = "Usunąć czat z członkiem?"; + +/* No comment provided by engineer. */ +"Delete chat?" = "Usunąć czat?"; + /* No comment provided by engineer. */ "Delete connection" = "Usuń połączenie"; @@ -1553,13 +1730,23 @@ swipe action */ /* No comment provided by engineer. */ "Delete link?" = "Usunąć link?"; +/* alert title */ +"Delete list?" = "Usunąć listę?"; + /* No comment provided by engineer. */ "Delete member message?" = "Usunąć wiadomość członka?"; +/* No comment provided by engineer. */ +"Delete member messages" = "Usuń wiadomości członków"; + +/* alert title */ +"Delete member messages?" = "Usunąć wiadomości członków?"; + /* No comment provided by engineer. */ "Delete message?" = "Usunąć wiadomość?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Usuń wiadomości"; /* No comment provided by engineer. */ @@ -1571,6 +1758,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete old database?" = "Usunąć starą bazę danych?"; +/* No comment provided by engineer. */ +"Delete or moderate up to 200 messages." = "Usuń lub moderuj do 200 wiadomości."; + /* No comment provided by engineer. */ "Delete pending connection?" = "Usunąć oczekujące połączenie?"; @@ -1580,6 +1770,9 @@ swipe action */ /* server test step */ "Delete queue" = "Usuń kolejkę"; +/* No comment provided by engineer. */ +"Delete report" = "Usuń raport"; + /* No comment provided by engineer. */ "Delete up to 20 messages at once." = "Usuń do 20 wiadomości na raz."; @@ -1610,6 +1803,9 @@ swipe action */ /* No comment provided by engineer. */ "Deletion errors" = "Błędy usuwania"; +/* No comment provided by engineer. */ +"Delivered even when Apple drops them." = "Dostarczane nawet wtedy, gdy Apple je wycofa."; + /* No comment provided by engineer. */ "Delivery" = "Dostarczenie"; @@ -1619,9 +1815,15 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "Potwierdzenia dostawy!"; +/* No comment provided by engineer. */ +"Deprecated options" = "Opcje wycofane"; + /* No comment provided by engineer. */ "Description" = "Opis"; +/* alert title */ +"Description too large" = "Opis jest zbyt długi"; + /* No comment provided by engineer. */ "Desktop address" = "Adres komputera"; @@ -1676,12 +1878,21 @@ swipe action */ /* chat feature */ "Direct messages" = "Bezpośrednie wiadomości"; +/* No comment provided by engineer. */ +"Direct messages between members are prohibited in this chat." = "W tym czacie zabronione jest wysyłanie bezpośrednich wiadomości między członkami."; + /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Bezpośrednie wiadomości między członkami są zabronione w tej grupie."; /* No comment provided by engineer. */ "Disable (keep overrides)" = "Wyłącz (zachowaj nadpisania)"; +/* alert title */ +"Disable automatic message deletion?" = "Wyłączyć automatyczne usuwanie wiadomości?"; + +/* alert button */ +"Disable delete messages" = "Wyłącz usuwanie wiadomości"; + /* No comment provided by engineer. */ "Disable for all" = "Wyłącz dla wszystkich"; @@ -1742,15 +1953,24 @@ swipe action */ /* No comment provided by engineer. */ "Do NOT use SimpleX for emergency calls." = "NIE używaj SimpleX do połączeń alarmowych."; +/* No comment provided by engineer. */ +"Documents:" = "Dokumenty:"; + /* No comment provided by engineer. */ "Don't create address" = "Nie twórz adresu"; /* No comment provided by engineer. */ "Don't enable" = "Nie włączaj"; +/* No comment provided by engineer. */ +"Don't miss important messages." = "Nie przegap ważnych wiadomości."; + /* alert action */ "Don't show again" = "Nie pokazuj ponownie"; +/* No comment provided by engineer. */ +"Done" = "Gotowe"; + /* No comment provided by engineer. */ "Downgrade and open chat" = "Obniż wersję i otwórz czat"; @@ -1797,12 +2017,18 @@ chat item action */ /* No comment provided by engineer. */ "e2e encrypted" = "zaszyfrowany e2e"; +/* No comment provided by engineer. */ +"E2E encrypted notifications." = "Powiadomienia szyfrowane E2E."; + /* chat item action */ "Edit" = "Edytuj"; /* No comment provided by engineer. */ "Edit group profile" = "Edytuj profil grupy"; +/* No comment provided by engineer. */ +"Empty message!" = "Pusta wiadomość!"; + /* No comment provided by engineer. */ "Enable" = "Włącz"; @@ -1815,6 +2041,12 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "Włącz dostęp do kamery"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "Włącz domyślnie znikające wiadomości."; + +/* No comment provided by engineer. */ +"Enable Flux in Network & servers settings for better metadata privacy." = "Włącz opcję Flux w ustawieniach sieci i serwerów, aby zapewnić lepszą prywatność metadanych."; + /* No comment provided by engineer. */ "Enable for all" = "Włącz dla wszystkich"; @@ -1926,6 +2158,9 @@ chat item action */ /* chat item text */ "encryption re-negotiation required for %@" = "renegocjacja szyfrowania wymagana dla %@"; +/* No comment provided by engineer. */ +"Encryption renegotiation in progress." = "Trwa renegocjacja szyfrowania."; + /* No comment provided by engineer. */ "ended" = "zakończona"; @@ -1974,15 +2209,30 @@ chat item action */ /* No comment provided by engineer. */ "Error aborting address change" = "Błąd przerwania zmiany adresu"; +/* alert title */ +"Error accepting conditions" = "Błąd podczas akceptacji warunków"; + /* No comment provided by engineer. */ "Error accepting contact request" = "Błąd przyjmowania prośby o kontakt"; +/* alert title */ +"Error accepting member" = "Błąd podczas akceptacji członka"; + /* No comment provided by engineer. */ "Error adding member(s)" = "Błąd dodawania członka(ów)"; +/* alert title */ +"Error adding server" = "Błąd podczas dodawania serwera"; + +/* No comment provided by engineer. */ +"Error adding short link" = "Błąd dodawania krótkiego linku"; + /* No comment provided by engineer. */ "Error changing address" = "Błąd zmiany adresu"; +/* alert title */ +"Error changing chat profile" = "Błąd zmiany profilu czatu"; + /* No comment provided by engineer. */ "Error changing connection profile" = "Błąd zmiany połączenia profilu"; @@ -1995,9 +2245,15 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "Błąd zmiany na incognito!"; +/* No comment provided by engineer. */ +"Error checking token status" = "Błąd sprawdzania statusu tokenu"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Błąd połączenia z serwerem przekierowania %@. Spróbuj ponownie później."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Błąd połączenia z serwerem używanym do odbierania wiadomości z tego połączenia: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Błąd tworzenia adresu"; @@ -2007,6 +2263,9 @@ chat item action */ /* No comment provided by engineer. */ "Error creating group link" = "Błąd tworzenia linku grupy"; +/* alert title */ +"Error creating list" = "Błąd tworzenia listy"; + /* No comment provided by engineer. */ "Error creating member contact" = "Błąd tworzenia kontaktu członka"; @@ -2016,9 +2275,15 @@ chat item action */ /* No comment provided by engineer. */ "Error creating profile!" = "Błąd tworzenia profilu!"; +/* No comment provided by engineer. */ +"Error creating report" = "Błąd tworzenia raportu"; + /* No comment provided by engineer. */ "Error decrypting file" = "Błąd odszyfrowania pliku"; +/* alert title */ +"Error deleting chat" = "Błąd usuwania czatu"; + /* alert title */ "Error deleting chat database" = "Błąd usuwania bazy danych czatu"; @@ -2064,12 +2329,18 @@ chat item action */ /* No comment provided by engineer. */ "Error joining group" = "Błąd dołączenia do grupy"; +/* alert title */ +"Error loading servers" = "Błąd ładowania serwerów"; + /* No comment provided by engineer. */ "Error migrating settings" = "Błąd migracji ustawień"; /* No comment provided by engineer. */ "Error opening chat" = "Błąd otwierania czatu"; +/* No comment provided by engineer. */ +"Error opening group" = "Błąd otwierania grupy"; + /* alert title */ "Error receiving file" = "Błąd odbioru pliku"; @@ -2079,12 +2350,24 @@ chat item action */ /* No comment provided by engineer. */ "Error reconnecting servers" = "Błąd ponownego łączenia serwerów"; +/* alert title */ +"Error registering for notifications" = "Błąd rejestracji powiadomień"; + +/* alert title */ +"Error rejecting contact request" = "Błąd odrzucenia prośby o kontakt"; + /* alert title */ "Error removing member" = "Błąd usuwania członka"; +/* alert title */ +"Error reordering lists" = "Błąd ponownego porządkowania list"; + /* No comment provided by engineer. */ "Error resetting statistics" = "Błąd resetowania statystyk"; +/* alert title */ +"Error saving chat list" = "Błąd zapisywania listy czatów"; + /* No comment provided by engineer. */ "Error saving group profile" = "Błąd zapisu profilu grupy"; @@ -2097,6 +2380,9 @@ chat item action */ /* No comment provided by engineer. */ "Error saving passphrase to keychain" = "Błąd zapisu hasła do pęku kluczy"; +/* alert title */ +"Error saving servers" = "Błąd zapisywania serwerów"; + /* when migrating */ "Error saving settings" = "Błąd zapisywania ustawień"; @@ -2115,6 +2401,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "Błąd wysyłania wiadomości"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "Błąd ustawiania automatycznego akceptowania"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Błąd ustawiania potwierdzeń dostawy!"; @@ -2133,12 +2422,18 @@ chat item action */ /* No comment provided by engineer. */ "Error synchronizing connection" = "Błąd synchronizacji połączenia"; +/* No comment provided by engineer. */ +"Error testing server connection" = "Błąd testowania połączenia z serwerem"; + /* No comment provided by engineer. */ "Error updating group link" = "Błąd aktualizacji linku grupy"; /* No comment provided by engineer. */ "Error updating message" = "Błąd aktualizacji wiadomości"; +/* alert title */ +"Error updating server" = "Błąd aktualizacji serwera"; + /* No comment provided by engineer. */ "Error updating settings" = "Błąd aktualizacji ustawień"; @@ -2159,6 +2454,9 @@ file error text snd error text */ "Error: %@" = "Błąd: %@"; +/* server test error */ +"Error: %@." = "Błąd: %@."; + /* No comment provided by engineer. */ "Error: no database file" = "Błąd: brak pliku bazy danych"; @@ -2168,6 +2466,9 @@ snd error text */ /* No comment provided by engineer. */ "Errors" = "Błędy"; +/* servers error */ +"Errors in servers configuration." = "Błędy w konfiguracji serwerów."; + /* No comment provided by engineer. */ "Even when disabled in the conversation." = "Nawet po wyłączeniu w rozmowie."; @@ -2180,6 +2481,9 @@ snd error text */ /* No comment provided by engineer. */ "expired" = "wygasły"; +/* token status text */ +"Expired" = "Wygasło"; + /* No comment provided by engineer. */ "Export database" = "Eksportuj bazę danych"; @@ -2204,18 +2508,30 @@ snd error text */ /* No comment provided by engineer. */ "Fast and no wait until the sender is online!" = "Szybko i bez czekania aż nadawca będzie online!"; +/* No comment provided by engineer. */ +"Faster deletion of groups." = "Szybsze usuwanie grup."; + /* No comment provided by engineer. */ "Faster joining and more reliable messages." = "Szybsze dołączenie i bardziej niezawodne wiadomości."; +/* No comment provided by engineer. */ +"Faster sending messages." = "Szybsze wysyłanie wiadomości."; + /* swipe action */ "Favorite" = "Ulubione"; +/* No comment provided by engineer. */ +"Favorites" = "Ulubione"; + /* file error alert title */ "File error" = "Błąd pliku"; /* alert message */ "File errors:\n%@" = "Błędy pliku:\n%@"; +/* file error text */ +"File is blocked by server operator:\n%@." = "Plik jest zablokowany przez operatora serwera:\n%@."; + /* file error text */ "File not found - most likely file was deleted or cancelled." = "Nie odnaleziono pliku - najprawdopodobniej plik został usunięty lub anulowany."; @@ -2249,6 +2565,9 @@ snd error text */ /* chat feature */ "Files and media" = "Pliki i media"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "W tym czacie nie wolno przesyłać plików ani multimediów."; + /* No comment provided by engineer. */ "Files and media are prohibited." = "Pliki i media są zabronione w tej grupie."; @@ -2258,6 +2577,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "Pliki i media zabronione!"; +/* No comment provided by engineer. */ +"Filter" = "Filtr"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "Filtruj nieprzeczytane i ulubione czaty."; @@ -2273,8 +2595,17 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "Szybciej znajduj czaty"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "Odcisk palca w adresie serwera docelowego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "Odcisk palca w adresie serwera przekazującego nie zgadza się z certyfikatem: %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "Odcisk palca w adresie serwera nie zgadza się z certyfikatem: %@."; + /* server test error */ -"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy"; +"Fingerprint in server address does not match certificate." = "Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy."; /* No comment provided by engineer. */ "Fix" = "Napraw"; @@ -2294,9 +2625,27 @@ snd error text */ /* No comment provided by engineer. */ "Fix not supported by group member" = "Naprawa nie jest obsługiwana przez członka grupy"; +/* No comment provided by engineer. */ +"For all moderators" = "Dla wszystkich moderatorów"; + +/* servers error */ +"For chat profile %@:" = "Dla profilu czatu %@:"; + /* No comment provided by engineer. */ "For console" = "Dla konsoli"; +/* No comment provided by engineer. */ +"For example, if your contact receives messages via a SimpleX Chat server, your app will deliver them via a Flux server." = "Na przykład, jeśli Twój kontakt odbiera wiadomości za pośrednictwem serwera SimpleX Chat, Twoja aplikacja będzie je dostarczać za pośrednictwem serwera Flux."; + +/* No comment provided by engineer. */ +"For me" = "Dla mnie"; + +/* No comment provided by engineer. */ +"For private routing" = "Dla prywatnego routingu"; + +/* No comment provided by engineer. */ +"For social media" = "Dla mediów społecznościowych"; + /* chat item action */ "Forward" = "Przekaż dalej"; @@ -2312,6 +2661,9 @@ snd error text */ /* alert message */ "Forward messages without files?" = "Przekazać wiadomości bez plików?"; +/* No comment provided by engineer. */ +"Forward up to 20 messages at once." = "Przekaż jednocześnie do 20 wiadomości."; + /* No comment provided by engineer. */ "forwarded" = "przekazane dalej"; @@ -2360,6 +2712,9 @@ snd error text */ /* No comment provided by engineer. */ "Further reduced battery usage" = "Jeszcze mniejsze zużycie baterii"; +/* No comment provided by engineer. */ +"Get notified when mentioned." = "Otrzymuj powiadomienia, gdy ktoś wspomni o Tobie."; + /* No comment provided by engineer. */ "GIFs and stickers" = "GIF-y i naklejki"; @@ -2369,6 +2724,9 @@ snd error text */ /* message preview */ "Good morning!" = "Dzień dobry!"; +/* shown on group welcome message */ +"group" = "grupa"; + /* No comment provided by engineer. */ "Group" = "Grupa"; @@ -2399,6 +2757,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "Zaproszenie do grupy jest już nieważne, zostało usunięte przez nadawcę."; +/* No comment provided by engineer. */ +"group is deleted" = "grupa została usunięta"; + /* No comment provided by engineer. */ "Group link" = "Link do grupy"; @@ -2423,6 +2784,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "zaktualizowano profil grupy"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "Profil grupy został zmieniony. Jeśli go zapiszesz, zaktualizowany profil zostanie wysłany do członków grupy."; + /* No comment provided by engineer. */ "Group welcome message" = "Wiadomość powitalna grupy"; @@ -2432,9 +2796,15 @@ snd error text */ /* No comment provided by engineer. */ "Group will be deleted for you - this cannot be undone!" = "Grupa zostanie usunięta dla Ciebie - nie można tego cofnąć!"; +/* No comment provided by engineer. */ +"Groups" = "Grupy"; + /* No comment provided by engineer. */ "Help" = "Pomoc"; +/* No comment provided by engineer. */ +"Help admins moderating their groups." = "Pomóż administratorom moderować ich grupy."; + /* No comment provided by engineer. */ "Hidden" = "Ukryte"; @@ -2465,6 +2835,15 @@ snd error text */ /* time unit */ "hours" = "godziny"; +/* No comment provided by engineer. */ +"How it affects privacy" = "Jak to wpływa na prywatność"; + +/* No comment provided by engineer. */ +"How it helps privacy" = "Jak to pomaga chronić prywatność"; + +/* alert button */ +"How it works" = "Jak to działa"; + /* No comment provided by engineer. */ "How SimpleX works" = "Jak działa SimpleX"; @@ -2504,6 +2883,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "Obraz zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; +/* No comment provided by engineer. */ +"Images" = "Zdjęcia"; + /* No comment provided by engineer. */ "Immediately" = "Natychmiast"; @@ -2528,6 +2910,9 @@ snd error text */ /* No comment provided by engineer. */ "Importing archive" = "Importowanie archiwum"; +/* No comment provided by engineer. */ +"Improved delivery, reduced traffic usage.\nMore improvements are coming soon!" = "Ulepszona dostawa, mniejsze zużycie ruchu.\nWkrótce pojawią się kolejne ulepszenia!"; + /* No comment provided by engineer. */ "Improved message delivery" = "Ulepszona dostawa wiadomości"; @@ -2549,6 +2934,12 @@ snd error text */ /* No comment provided by engineer. */ "inactive" = "nieaktywny"; +/* report reason */ +"Inappropriate content" = "Nieodpowiednia treść"; + +/* report reason */ +"Inappropriate profile" = "Nieodpowiedni profil"; + /* No comment provided by engineer. */ "Incognito" = "Incognito"; @@ -2615,6 +3006,21 @@ snd error text */ /* No comment provided by engineer. */ "Interface colors" = "Kolory interfejsu"; +/* token status text */ +"Invalid" = "Nieprawidłowy"; + +/* token status text */ +"Invalid (bad token)" = "Nieprawidłowy (zły token)"; + +/* token status text */ +"Invalid (expired)" = "Nieważny (wygasły)"; + +/* token status text */ +"Invalid (unregistered)" = "Nieprawidłowy (niezarejestrowany)"; + +/* token status text */ +"Invalid (wrong topic)" = "Nieprawidłowy (niewłaściwy temat)"; + /* invalid chat data */ "invalid chat" = "nieprawidłowy czat"; @@ -2663,9 +3069,15 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "Zaproś znajomych"; +/* No comment provided by engineer. */ +"Invite member" = "Zaproś członka"; + /* No comment provided by engineer. */ "Invite members" = "Zaproś członków"; +/* No comment provided by engineer. */ +"Invite to chat" = "Zaproś do czatu"; + /* No comment provided by engineer. */ "Invite to group" = "Zaproś do grupy"; @@ -2756,6 +3168,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "Zachować nieużyte zaproszenie?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "Utrzymuj czystość swoich czatów"; + /* No comment provided by engineer. */ "Keep your connections" = "Zachowaj swoje połączenia"; @@ -2774,6 +3189,12 @@ snd error text */ /* swipe action */ "Leave" = "Opuść"; +/* No comment provided by engineer. */ +"Leave chat" = "Opuść czat"; + +/* No comment provided by engineer. */ +"Leave chat?" = "Opuścić czat?"; + /* No comment provided by engineer. */ "Leave group" = "Opuść grupę"; @@ -2783,6 +3204,9 @@ snd error text */ /* rcv group event chat item */ "left" = "opuścił"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "Mniejszy ruch w sieciach komórkowych."; + /* email subject */ "Let's talk in SimpleX Chat" = "Porozmawiajmy w SimpleX Chat"; @@ -2801,6 +3225,18 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "Połączone komputery"; +/* No comment provided by engineer. */ +"Links" = "Linki"; + +/* swipe action */ +"List" = "Lista"; + +/* No comment provided by engineer. */ +"List name and emoji should be different for all lists." = "Nazwa listy i emoji powinny być różne dla wszystkich list."; + +/* No comment provided by engineer. */ +"List name..." = "Nazwa listy..."; + /* No comment provided by engineer. */ "LIVE" = "NA ŻYWO"; @@ -2810,6 +3246,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "Wiadomości na żywo"; +/* in progress text */ +"Loading profile…" = "Ładowanie profilu…"; + /* No comment provided by engineer. */ "Local name" = "Nazwa lokalna"; @@ -2861,30 +3300,60 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "Członek"; +/* past/unknown group member */ +"Member %@" = "Członek %@"; + /* profile update event chat item */ "member %@ changed to %@" = "członek %1$@ zmieniony na %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "Przyjmowanie członków"; + /* rcv group event chat item */ "member connected" = "połączony"; +/* No comment provided by engineer. */ +"member has old version" = "członek posiada starą wersję"; + /* item status text */ "Member inactive" = "Członek nieaktywny"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "Członek został usunięty – nie można zaakceptować prośby"; + +/* alert message */ +"Member messages will be deleted - this cannot be undone!" = "Wiadomości członków zostaną usunięte – nie można tego cofnąć!"; + +/* chat feature */ +"Member reports" = "Raporty członków"; + +/* No comment provided by engineer. */ +"Member role will be changed to \"%@\". All chat members will be notified." = "Rola członka zostanie zmieniona na \"%@\". Wszyscy członkowie czatu zostaną o tym poinformowani."; + /* No comment provided by engineer. */ "Member role will be changed to \"%@\". All group members will be notified." = "Rola członka grupy zostanie zmieniona na \"%@\". Wszyscy członkowie grupy zostaną powiadomieni."; /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Rola członka zostanie zmieniona na \"%@\". Członek otrzyma nowe zaproszenie."; -/* No comment provided by engineer. */ +/* alert message */ +"Member will be removed from chat - this cannot be undone!" = "Członek zostanie usunięty z czatu – nie można tego cofnąć!"; + +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Członek zostanie usunięty z grupy - nie można tego cofnąć!"; +/* alert message */ +"Member will join the group, accept member?" = "Członek dołączy do grupy, zaakceptować członka?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "Członkowie grupy mogą dodawać reakcje wiadomości."; /* No comment provided by engineer. */ "Members can irreversibly delete sent messages. (24 hours)" = "Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny)"; +/* No comment provided by engineer. */ +"Members can report messsages to moderators." = "Członkowie mogą zgłaszać wiadomości moderatorom."; + /* No comment provided by engineer. */ "Members can send direct messages." = "Członkowie grupy mogą wysyłać bezpośrednie wiadomości."; @@ -2900,6 +3369,9 @@ snd error text */ /* No comment provided by engineer. */ "Members can send voice messages." = "Członkowie grupy mogą wysyłać wiadomości głosowe."; +/* No comment provided by engineer. */ +"Mention members 👋" = "Wspomnij członków 👋"; + /* No comment provided by engineer. */ "Menus" = "Menu"; @@ -2921,6 +3393,9 @@ snd error text */ /* item status text */ "Message forwarded" = "Wiadomość przekazana"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "Wysyłaj wiadomości natychmiast po dotknięciu przycisku „Połącz”."; + /* item status description */ "Message may be delivered later if member becomes active." = "Wiadomość może zostać dostarczona później jeśli członek stanie się aktywny."; @@ -2969,9 +3444,15 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "Wiadomości i pliki"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "Wiadomości są chronione przez **szyfrowanie typu end-to-end**."; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "Wiadomości od %@ zostaną pokazane!"; +/* alert message */ +"Messages in this chat will never be deleted." = "Wiadomości na tym czacie nigdy nie zostaną usunięte."; + /* No comment provided by engineer. */ "Messages received" = "Otrzymane wiadomości"; @@ -3044,15 +3525,24 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "moderowany przez %@"; +/* member role */ +"moderator" = "moderator"; + /* time unit */ "months" = "miesiące"; +/* swipe action */ +"More" = "Więcej"; + /* No comment provided by engineer. */ "More improvements are coming soon!" = "Więcej ulepszeń już wkrótce!"; /* No comment provided by engineer. */ "More reliable network connection." = "Bardziej niezawodne połączenia sieciowe."; +/* No comment provided by engineer. */ +"More reliable notifications" = "Bardziej niezawodne powiadomienia"; + /* item status description */ "Most likely this connection is deleted." = "Najprawdopodobniej to połączenie jest usunięte."; @@ -3062,6 +3552,9 @@ snd error text */ /* notification label action */ "Mute" = "Wycisz"; +/* notification label action */ +"Mute all" = "Wycisz wszystko"; + /* No comment provided by engineer. */ "Muted when inactive!" = "Wyciszony, gdy jest nieaktywny!"; @@ -3074,12 +3567,18 @@ snd error text */ /* No comment provided by engineer. */ "Network connection" = "Połączenie z siecią"; +/* No comment provided by engineer. */ +"Network decentralization" = "Decentralizacja sieci"; + /* snd error text */ "Network issues - message expired after many attempts to send it." = "Błąd sieciowy - wiadomość wygasła po wielu próbach wysłania jej."; /* No comment provided by engineer. */ "Network management" = "Zarządzenie sieciowe"; +/* No comment provided by engineer. */ +"Network operator" = "Operator sieci"; + /* No comment provided by engineer. */ "Network settings" = "Ustawienia sieci"; @@ -3089,6 +3588,9 @@ snd error text */ /* delete after time */ "never" = "nigdy"; +/* token status text */ +"New" = "Nowy"; + /* No comment provided by engineer. */ "New chat" = "Nowy czat"; @@ -3107,6 +3609,12 @@ snd error text */ /* No comment provided by engineer. */ "New display name" = "Nowa wyświetlana nazwa"; +/* notification */ +"New events" = "Nowe wydarzenia"; + +/* No comment provided by engineer. */ +"New group role: Moderator" = "Nowa rola w grupie: Moderator"; + /* No comment provided by engineer. */ "New in %@" = "Nowość w %@"; @@ -3116,6 +3624,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "Nowa rola członka"; +/* rcv group event chat item */ +"New member wants to join the group." = "Nowy członek chce dołączyć do grupy."; + /* notification */ "new message" = "nowa wiadomość"; @@ -3128,6 +3639,9 @@ snd error text */ /* No comment provided by engineer. */ "New passphrase…" = "Nowe hasło…"; +/* No comment provided by engineer. */ +"New server" = "Nowy serwer"; + /* No comment provided by engineer. */ "New SOCKS credentials will be used every time you start the app." = "Nowe poświadczenia SOCKS będą używane przy każdym uruchomieniu aplikacji."; @@ -3143,6 +3657,18 @@ snd error text */ /* Authentication unavailable */ "No app password" = "Brak hasła aplikacji"; +/* No comment provided by engineer. */ +"No chats" = "Żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats found" = "Nie znaleziono żadnych czatów"; + +/* No comment provided by engineer. */ +"No chats in list %@" = "Brak czatów na liście %@"; + +/* No comment provided by engineer. */ +"No chats with members" = "Żadnych rozmów z członkami"; + /* No comment provided by engineer. */ "No contacts selected" = "Nie wybrano kontaktów"; @@ -3173,6 +3699,15 @@ snd error text */ /* No comment provided by engineer. */ "No info, try to reload" = "Brak informacji, spróbuj przeładować"; +/* servers error */ +"No media & file servers." = "Brak mediów i serwerów plików multimedialnych."; + +/* No comment provided by engineer. */ +"No message" = "Brak wiadomości"; + +/* servers error */ +"No message servers." = "Brak serwerów wiadomości."; + /* No comment provided by engineer. */ "No network connection" = "Brak połączenia z siecią"; @@ -3185,21 +3720,51 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "Brak uprawnień do nagrywania wiadomości głosowej"; +/* alert title */ +"No private routing session" = "Brak prywatnej sesji routingu"; + /* No comment provided by engineer. */ "No push server" = "Lokalnie"; /* No comment provided by engineer. */ "No received or sent files" = "Brak odebranych lub wysłanych plików"; +/* servers error */ +"No servers for private message routing." = "Brak serwerów prywatnej sesji routingu."; + +/* servers error */ +"No servers to receive files." = "Brak serwerów do otrzymania plików."; + +/* servers error */ +"No servers to receive messages." = "Brak serwerów aby otrzymać wiadomości."; + +/* servers error */ +"No servers to send files." = "Brak serwerów do wysyłania plików."; + +/* No comment provided by engineer. */ +"no subscription" = "brak subskrypcji"; + /* copied message info in history */ "no text" = "brak tekstu"; +/* alert title */ +"No token!" = "Brak tokenu!"; + +/* No comment provided by engineer. */ +"No unread chats" = "Brak nieprzeczytanych czatów"; + /* No comment provided by engineer. */ "No user identifiers." = "Brak identyfikatorów użytkownika."; /* No comment provided by engineer. */ "Not compatible!" = "Nie kompatybilny!"; +/* No comment provided by engineer. */ +"not synchronized" = "nie zsynchronizowano"; + +/* No comment provided by engineer. */ +"Notes" = "Notatki"; + /* No comment provided by engineer. */ "Nothing selected" = "Nic nie jest zaznaczone"; @@ -3212,6 +3777,15 @@ snd error text */ /* No comment provided by engineer. */ "Notifications are disabled!" = "Powiadomienia są wyłączone!"; +/* alert title */ +"Notifications error" = "Błąd powiadomień"; + +/* No comment provided by engineer. */ +"Notifications privacy" = "Prywatność powiadomień"; + +/* alert title */ +"Notifications status" = "Stan powiadomień"; + /* No comment provided by engineer. */ "Now admins can:\n- delete members' messages.\n- disable members (\"observer\" role)" = "Teraz administratorzy mogą:\n- usuwać wiadomości członków.\n- wyłączyć członków (rola \"obserwatora\")"; @@ -3259,6 +3833,9 @@ new chat action */ /* No comment provided by engineer. */ "Onion hosts will not be used." = "Hosty onion nie będą używane."; +/* No comment provided by engineer. */ +"Only chat owners can change preferences." = "Tylko właściciele czatu mogą zmieniać preferencje."; + /* No comment provided by engineer. */ "Only client devices store user profiles, contacts, groups, and messages." = "Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości wysyłane za pomocą **2-warstwowego szyfrowania end-to-end**."; @@ -3274,6 +3851,12 @@ new chat action */ /* No comment provided by engineer. */ "Only group owners can enable voice messages." = "Tylko właściciele grup mogą włączyć wiadomości głosowe."; +/* No comment provided by engineer. */ +"Only sender and moderators see it" = "Widzą to tylko nadawca i moderatorzy"; + +/* No comment provided by engineer. */ +"Only you and moderators see it" = "Widzisz to tylko Ty i moderatorzy"; + /* No comment provided by engineer. */ "Only you can add message reactions." = "Tylko Ty możesz dodawać reakcje wiadomości."; @@ -3286,6 +3869,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "Tylko Ty możesz wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only you can send files and media." = "Tylko Ty możesz wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only you can send voice messages." = "Tylko Ty możesz wysyłać wiadomości głosowe."; @@ -3301,30 +3887,75 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "Tylko Twój kontakt może wysyłać znikające wiadomości."; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "Tylko Twój kontakt może wysyłać pliki i multimedia."; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "Tylko Twój kontakt może wysyłać wiadomości głosowe."; /* alert action */ "Open" = "Otwórz"; +/* No comment provided by engineer. */ +"Open changes" = "Otwórz zmiany"; + /* new chat action */ "Open chat" = "Otwórz czat"; /* authentication reason */ "Open chat console" = "Otwórz konsolę czatu"; +/* alert action */ +"Open clean link" = "Otwórz czysty link"; + +/* No comment provided by engineer. */ +"Open conditions" = "Otwórz warunki"; + +/* alert action */ +"Open full link" = "Otwórz pełny link"; + /* new chat action */ "Open group" = "Grupa otwarta"; +/* alert title */ +"Open link?" = "Otworzyć link?"; + /* authentication reason */ "Open migration to another device" = "Otwórz migrację na innym urządzeniu"; +/* new chat action */ +"Open new chat" = "Otwórz nowy czat"; + +/* new chat action */ +"Open new group" = "Otwórz nową grupę"; + /* No comment provided by engineer. */ "Open Settings" = "Otwórz Ustawienia"; +/* No comment provided by engineer. */ +"Open to accept" = "Otwórz by zaakceptować"; + +/* No comment provided by engineer. */ +"Open to connect" = "Otwórz aby się połączyć"; + +/* No comment provided by engineer. */ +"Open to join" = "Otwórz aby dołączyć"; + +/* No comment provided by engineer. */ +"Open to use bot" = "Otwórz aby skorzystać z bota"; + /* No comment provided by engineer. */ "Opening app…" = "Otwieranie aplikacji…"; +/* No comment provided by engineer. */ +"Operator" = "Operator"; + +/* alert title */ +"Operator server" = "Serwer Operatora"; + +/* No comment provided by engineer. */ +"Or import archive file" = "Lub zaimportuj plik archiwalny"; + /* No comment provided by engineer. */ "Or paste archive link" = "Lub wklej link archiwum"; @@ -3337,6 +3968,12 @@ new chat action */ /* No comment provided by engineer. */ "Or show this code" = "Lub pokaż ten kod"; +/* No comment provided by engineer. */ +"Or to share privately" = "Lub udostępnij prywatnie"; + +/* No comment provided by engineer. */ +"Organize chats into lists" = "Organizuj czaty jako listy"; + /* No comment provided by engineer. */ "other" = "inne"; @@ -3391,9 +4028,18 @@ new chat action */ /* No comment provided by engineer. */ "peer-to-peer" = "peer-to-peer"; +/* No comment provided by engineer. */ +"pending" = "oczekuje"; + /* No comment provided by engineer. */ "Pending" = "Oczekujące"; +/* No comment provided by engineer. */ +"pending approval" = "oczekuje na zatwierdzenie"; + +/* No comment provided by engineer. */ +"pending review" = "oczekuje na ocenę"; + /* No comment provided by engineer. */ "Periodic" = "Okresowo"; @@ -3460,6 +4106,18 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Przechowuj kod dostępu w bezpieczny sposób, w przypadku jego utraty NIE będzie można go zmienić."; +/* token info */ +"Please try to disable and re-enable notfications." = "Spróbuj wyłączyć, a następnie ponownie włączyć powiadomienia."; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy."; + +/* token info */ +"Please wait for token activation to complete." = "Proszę poczekać na zakończenie aktywacji tokenu."; + +/* token info */ +"Please wait for token to be registered." = "Proszę poczekać na zarejestrowanie tokenu."; + /* No comment provided by engineer. */ "Polish interface" = "Polski interfejs"; @@ -3472,6 +4130,9 @@ new chat action */ /* No comment provided by engineer. */ "Preset server address" = "Wstępnie ustawiony adres serwera"; +/* No comment provided by engineer. */ +"Preset servers" = "Domyślne serwery"; + /* No comment provided by engineer. */ "Preview" = "Podgląd"; @@ -3481,12 +4142,24 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "Prywatność i bezpieczeństwo"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "Prywatność dla Twoich klientów."; + +/* No comment provided by engineer. */ +"Privacy policy and conditions of use." = "Polityka prywatności i warunki korzystania."; + /* No comment provided by engineer. */ "Privacy redefined" = "Redefinicja prywatności"; +/* No comment provided by engineer. */ +"Private chats, groups and your contacts are not accessible to server operators." = "Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów."; + /* No comment provided by engineer. */ "Private filenames" = "Prywatne nazwy plików"; +/* No comment provided by engineer. */ +"Private media file names." = "Nazwy prywatnych plików multimedialnych."; + /* No comment provided by engineer. */ "Private message routing" = "Trasowanie prywatnych wiadomości"; @@ -3502,6 +4175,9 @@ new chat action */ /* alert title */ "Private routing error" = "Błąd prywatnego trasowania"; +/* alert title */ +"Private routing timeout" = "Limit czasu routingu prywatnego"; + /* No comment provided by engineer. */ "Profile and server connections" = "Profil i połączenia z serwerem"; @@ -3532,6 +4208,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "Zabroń reakcje wiadomości."; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "Zabroń raportowania wiadomości moderatorom."; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Zabroń wysyłania bezpośrednich wiadomości do członków."; @@ -3559,6 +4238,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Chroni Twój adres IP przed przekaźnikami wiadomości wybranych przez Twoje kontakty.\nWłącz w ustawianiach *Sieć i serwery* ."; +/* No comment provided by engineer. */ +"Protocol background timeout" = "Limit czasu protokołu w tle"; + /* No comment provided by engineer. */ "Protocol timeout" = "Limit czasu protokołu"; @@ -3691,6 +4373,15 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "Zmniejszone zużycie baterii"; +/* No comment provided by engineer. */ +"Register" = "Zarejestruj"; + +/* token info */ +"Register notification token?" = "Zarejestrować token powiadomień?"; + +/* token status text */ +"Registered" = "Zarejestrowany"; + /* alert action reject incoming call via notification swipe action */ @@ -3702,6 +4393,12 @@ swipe action */ /* alert title */ "Reject contact request" = "Odrzuć prośbę kontaktu"; +/* alert title */ +"Reject member?" = "Odrzucić członka?"; + +/* No comment provided by engineer. */ +"rejected" = "odrzucono"; + /* call status */ "rejected call" = "odrzucone połączenie"; @@ -3711,9 +4408,12 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Serwer przekaźnikowy chroni Twój adres IP, ale może obserwować czas trwania połączenia."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Usuń"; +/* alert action */ +"Remove and delete messages" = "Usuń i skasuj wiadomości"; + /* No comment provided by engineer. */ "Remove archive?" = "Usunąć archiwum?"; @@ -3721,9 +4421,12 @@ swipe action */ "Remove image" = "Usuń obraz"; /* No comment provided by engineer. */ -"Remove member" = "Usuń członka"; +"Remove link tracking" = "Usuń śledzenie linków"; /* No comment provided by engineer. */ +"Remove member" = "Usuń członka"; + +/* alert title */ "Remove member?" = "Usunąć członka?"; /* No comment provided by engineer. */ @@ -3738,12 +4441,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "usunięto adres kontaktu"; +/* No comment provided by engineer. */ +"removed from group" = "usunięty z grupy"; + /* profile update event chat item */ "removed profile picture" = "usunięto zdjęcie profilu"; /* rcv group event chat item */ "removed you" = "usunął cię"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "Usuwa wiadomości i blokuje członków."; + /* No comment provided by engineer. */ "Renegotiate" = "Renegocjuj"; @@ -3765,6 +4474,54 @@ swipe action */ /* chat item action */ "Reply" = "Odpowiedz"; +/* chat item action */ +"Report" = "Zgłoś"; + +/* report reason */ +"Report content: only group moderators will see it." = "Zgłoś treść: zobaczą ją tylko moderatorzy grupy."; + +/* report reason */ +"Report member profile: only group moderators will see it." = "Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy."; + +/* report reason */ +"Report other: only group moderators will see it." = "Zgłoś inne: zobaczą to tylko moderatorzy grupy."; + +/* No comment provided by engineer. */ +"Report reason?" = "Jaki jest powód zgłoszenia?"; + +/* alert title */ +"Report sent to moderators" = "Zgłoszenia wysłane do moderatorów"; + +/* report reason */ +"Report spam: only group moderators will see it." = "Zgłoś spam: tylko moderatorzy grupy będą to widzieć."; + +/* report reason */ +"Report violation: only group moderators will see it." = "Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy."; + +/* report in notification */ +"Report: %@" = "Zgłoszenie: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "Zgłaszanie wiadomości moderatorom jest zabronione."; + +/* No comment provided by engineer. */ +"Reports" = "Zgłoszenia"; + +/* No comment provided by engineer. */ +"request is sent" = "prośba została wysłana"; + +/* No comment provided by engineer. */ +"request to join rejected" = "prośba o dołączenie została odrzucona"; + +/* rcv group event chat item */ +"requested connection" = "prośba o połączenie"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "prośba o połączenie od grupy %@"; + +/* chat list item title */ +"requested to connect" = "poproszono o połączenie"; + /* No comment provided by engineer. */ "Required" = "Wymagane"; @@ -3816,6 +4573,24 @@ swipe action */ /* chat item action */ "Reveal" = "Ujawnij"; +/* No comment provided by engineer. */ +"review" = "ocena"; + +/* No comment provided by engineer. */ +"Review conditions" = "Przejrzyj warunki"; + +/* No comment provided by engineer. */ +"Review group members" = "Przejrzyj członków grupy"; + +/* admission stage */ +"Review members" = "Przejrzyj członków"; + +/* admission stage description */ +"Review members before admitting (\"knocking\")." = "Przejrzyj członków przed dopuszczeniem (\"zapukaj\")."; + +/* No comment provided by engineer. */ +"reviewed by admins" = "sprawdzone przez administratorów"; + /* No comment provided by engineer. */ "Revoke" = "Odwołaj"; @@ -3844,6 +4619,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "Zapisz (i powiadom kontakty)"; +/* alert button */ +"Save (and notify members)" = "Zapisz (i powiadom członków)"; + +/* alert title */ +"Save admission settings?" = "Zapisać ustawienia wstępu?"; + /* alert button */ "Save and notify contact" = "Zapisz i powiadom kontakt"; @@ -3859,6 +4640,12 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "Zapisz profil grupy"; +/* alert title */ +"Save group profile?" = "Zapisać profil grupy?"; + +/* No comment provided by engineer. */ +"Save list" = "Zapisz listę"; + /* No comment provided by engineer. */ "Save passphrase and open chat" = "Zapisz hasło i otwórz czat"; @@ -3934,9 +4721,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "Pasek wyszukiwania akceptuje linki zaproszenia."; +/* No comment provided by engineer. */ +"Search files" = "Szukaj plików"; + +/* No comment provided by engineer. */ +"Search images" = "Szukaj zdjęć"; + +/* No comment provided by engineer. */ +"Search links" = "Szukaj linków"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "Wyszukaj lub wklej link SimpleX"; +/* No comment provided by engineer. */ +"Search videos" = "Szukaj wideo"; + +/* No comment provided by engineer. */ +"Search voice messages" = "Szukaj wiadomości głosowych"; + /* network option */ "sec" = "sek"; @@ -3994,6 +4796,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "Wysyłaj wiadomości na żywo - będą one aktualizowane dla odbiorcy(ów) w trakcie ich wpisywania"; +/* No comment provided by engineer. */ +"Send contact request?" = "Wysłać prośbę o kontakt?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "Wyślij potwierdzenia dostawy do"; @@ -4024,18 +4829,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "Wyślij powiadomienia"; +/* No comment provided by engineer. */ +"Send private reports" = "Wyślij prywatne zgłoszenia"; + /* No comment provided by engineer. */ "Send questions and ideas" = "Wyślij pytania i pomysły"; /* No comment provided by engineer. */ "Send receipts" = "Wyślij potwierdzenia"; +/* No comment provided by engineer. */ +"Send request" = "Wyślij prośbę"; + +/* No comment provided by engineer. */ +"Send request without message" = "Wyślij prośbę bez wiadomości"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "Wyślij je z galerii lub niestandardowych klawiatur."; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "Wysyłaj do 100 ostatnich wiadomości do nowych członków."; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "Wyślij swoją prywatną opinię do grup."; + /* alert message */ "Sender cancelled file transfer." = "Nadawca anulował transfer pliku."; @@ -4096,6 +4913,9 @@ chat item action */ /* No comment provided by engineer. */ "Server" = "Serwer"; +/* alert message */ +"Server added to operator %@." = "Serwer został dodany do operatora %@."; + /* No comment provided by engineer. */ "Server address" = "Adres serwera"; @@ -4105,14 +4925,23 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "Adres serwera jest niekompatybilny z ustawieniami sieciowymi."; +/* alert title */ +"Server operator changed." = "Operator serwera został zmieniony."; + +/* No comment provided by engineer. */ +"Server operators" = "Operatorzy serwera"; + +/* alert title */ +"Server protocol changed." = "Protokół serwera zmieniony."; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "Informacje kolejki serwera: %1$@\n\nostatnia otrzymana wiadomość: %2$@"; /* server test error */ -"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło"; +"Server requires authorization to create queues, check password." = "Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło."; /* server test error */ -"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło"; +"Server requires authorization to upload, check password." = "Serwer wymaga autoryzacji do przesłania, sprawdź hasło."; /* No comment provided by engineer. */ "Server test failed!" = "Test serwera nie powiódł się!"; @@ -4141,6 +4970,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "Ustaw 1 dzień"; +/* No comment provided by engineer. */ +"Set chat name…" = "Ustaw nazwę czatu…"; + /* No comment provided by engineer. */ "Set contact name…" = "Ustaw nazwę kontaktu…"; @@ -4153,6 +4985,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "Ustaw go zamiast uwierzytelniania systemowego."; +/* No comment provided by engineer. */ +"Set member admission" = "Ustaw przyjmowanie członków"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "Ustaw datę wygaśnięcia wiadomości na czatach."; + /* profile update event chat item */ "set new contact address" = "ustaw nowy adres kontaktu"; @@ -4168,6 +5006,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "Ustaw hasło do eksportu"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "Ustaw biografię profilu i wiadomość powitalną."; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "Ustaw wiadomość wyświetlaną nowym członkom!"; @@ -4190,9 +5031,15 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "Udostępnij 1-razowy link"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "Udostępnij jednorazowy link znajomemu"; + /* No comment provided by engineer. */ "Share address" = "Udostępnij adres"; +/* No comment provided by engineer. */ +"Share address publicly" = "Udostępnij adres publicznie"; + /* alert title */ "Share address with contacts?" = "Udostępnić adres kontaktom?"; @@ -4202,9 +5049,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "Udostępnij link"; +/* alert button */ +"Share old address" = "Udostępnij stary adres"; + +/* alert button */ +"Share old link" = "Udostępnij stary link"; + /* No comment provided by engineer. */ "Share profile" = "Udostępnij profil"; +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "Udostępnij adres SimpleX w mediach społecznościowych."; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "Udostępnij ten jednorazowy link"; @@ -4214,6 +5070,18 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "Udostępnij kontaktom"; +/* No comment provided by engineer. */ +"Share your address" = "Udostępnij swój adres"; + +/* No comment provided by engineer. */ +"Short description" = "Krótki opis"; + +/* No comment provided by engineer. */ +"Short link" = "Krótki link"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "Krótki adres SimpleX"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "Pokaż → na wiadomościach wysłanych przez prywatne trasowanie."; @@ -4250,9 +5118,21 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX Address" = "Adres SimpleX"; +/* No comment provided by engineer. */ +"SimpleX address and 1-time links are safe to share via any messenger." = "Adres SimpleX i jednorazowe linki są bezpieczne do udostępniania przez dowolny komunikator."; + +/* No comment provided by engineer. */ +"SimpleX address or 1-time link?" = "Adres SimpleX czy link jednorazowy?"; + /* alert title */ "SimpleX address settings" = "Ustawienia automatycznej akceptacji"; +/* simplex link type */ +"SimpleX channel link" = "Link do kanału na SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux."; + /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "Bezpieczeństwo SimpleX Chat zostało zaudytowane przez Trail of Bits."; @@ -4289,6 +5169,12 @@ chat item action */ /* simplex link type */ "SimpleX one-time invitation" = "Zaproszenie jednorazowe SimpleX"; +/* No comment provided by engineer. */ +"SimpleX protocols reviewed by Trail of Bits." = "Protokoły SimpleX sprawdzone przez Trail of Bits."; + +/* simplex link type */ +"SimpleX relay link" = "łącze przekaźnikowe SimpleX"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "Uproszczony tryb incognito"; @@ -4325,9 +5211,16 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "Podczas importu wystąpiły niekrytyczne błędy:"; +/* alert message */ +"Some servers failed the test:\n%@" = "Niektóre serwery nie przeszły testu:\n%@"; + /* notification title */ "Somebody" = "Ktoś"; +/* blocking reason +report reason */ +"Spam" = "Spam"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "Kwadrat, okrąg lub cokolwiek pomiędzy."; @@ -4385,6 +5278,9 @@ chat item action */ /* No comment provided by engineer. */ "Stopping chat" = "Zatrzymywanie czatu"; +/* No comment provided by engineer. */ +"Storage" = "Magazyn"; + /* No comment provided by engineer. */ "strike" = "strajk"; @@ -4406,6 +5302,12 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "Wspieraj SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "Przełączanie audio i wideo podczas połączenia."; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "Przełącz profil czatu dla zaproszeń jednorazowych."; + /* No comment provided by engineer. */ "System" = "System"; @@ -4421,6 +5323,21 @@ chat item action */ /* No comment provided by engineer. */ "Tap button " = "Naciśnij przycisk "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "Dotknij Połącz aby rozpocząć czat"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "Dotknij Połącz, aby wysłać prośbę"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "Dotknij Połącz aby użyć bota"; + +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "Dotknij Stwórz adres SimpleX w menu aby utworzyć go później."; + +/* No comment provided by engineer. */ +"Tap Join group" = "Dotknij Dołącz do grupy"; + /* No comment provided by engineer. */ "Tap to activate profile." = "Dotknij, aby aktywować profil."; @@ -4442,9 +5359,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "Połączenie TCP"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "Przekroczono limit czasu połączenia TCP"; + /* No comment provided by engineer. */ "TCP connection timeout" = "Limit czasu połączenia TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Port TCP dla wiadomości"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4460,6 +5383,9 @@ chat item action */ /* server test failure */ "Test failed at step %@." = "Test nie powiódł się na etapie %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Powiadomienia testowe"; + /* No comment provided by engineer. */ "Test server" = "Przetestuj serwer"; @@ -4478,9 +5404,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "Podziękowania dla użytkowników - wkład za pośrednictwem Weblate!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "Aplikacja może powiadamiać Cię, gdy otrzymujesz wiadomości lub prośby o kontakt — otwórz ustawienia, aby włączyć."; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "Aplikacja zapyta o potwierdzenie pobierania od nieznanych serwerów plików (poza .onion)."; @@ -4490,6 +5422,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Kod, który zeskanowałeś nie jest kodem QR linku SimpleX."; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline."; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "Zaakceptowane przez Ciebie połączenie zostanie anulowane!"; @@ -4511,6 +5446,9 @@ chat item action */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "Identyfikator następnej wiadomości jest nieprawidłowy (mniejszy lub równy poprzedniej).\nMoże się to zdarzyć z powodu jakiegoś błędu lub gdy połączenie jest skompromitowane."; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link."; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "Wiadomość zostanie usunięta dla wszystkich członków."; @@ -4526,6 +5464,12 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "Stara baza danych nie została usunięta podczas migracji, można ją usunąć."; +/* No comment provided by engineer. */ +"The same conditions will apply to operator **%@**." = "Te same warunki będą miały zastosowanie do operatora **%@**."; + +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "Drugi predefiniowany operator w aplikacji!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "Drugi tik, który przegapiliśmy! ✅"; @@ -4535,6 +5479,9 @@ chat item action */ /* No comment provided by engineer. */ "The servers for new connections of your current chat profile **%@**." = "Serwery dla nowych połączeń bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"The servers for new files of your current chat profile **%@**." = "Serwery dla nowych plików Twojego bieżącego profilu czatu **%@**."; + /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Tekst, który wkleiłeś nie jest linkiem SimpleX."; @@ -4544,6 +5491,9 @@ chat item action */ /* No comment provided by engineer. */ "Themes" = "Motywy"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "Warunki te będą miały również zastosowanie w przypadku: **%@**."; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "Te ustawienia dotyczą Twojego bieżącego profilu **%@**."; @@ -4556,6 +5506,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Tego działania nie można cofnąć - wiadomości wysłane i odebrane wcześniej niż wybrane zostaną usunięte. Może to potrwać kilka minut."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Tego działania nie można cofnąć - Twój profil, kontakty, wiadomości i pliki zostaną nieodwracalnie utracone."; @@ -4580,12 +5533,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "Ta grupa już nie istnieje."; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ten link dostał użyty z innym urządzeniem mobilnym, proszę stworzyć nowy link na komputerze."; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "Ta wiadomość została usunięta lub jeszcze nie otrzymana."; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**."; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "To ustawienie jest dla Twojego obecnego profilu **%@**."; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "Czas zniknięcia jest ustawiony tylko dla nowych kontaktów."; + /* No comment provided by engineer. */ "Title" = "Tytuł"; @@ -4601,6 +5566,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "Aby nawiązać nowe połączenie"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu."; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "Aby chronić strefę czasową, pliki obrazów/głosów używają UTC."; @@ -4613,6 +5581,9 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów."; +/* No comment provided by engineer. */ +"To receive" = "Żeby odebrać"; + /* No comment provided by engineer. */ "To record speech please grant permission to use Microphone." = "Aby nagrać rozmowę, proszę zezwolić na użycie Mikrofonu."; @@ -4625,9 +5596,21 @@ chat item action */ /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Aby ujawnić Twój ukryty profil, wprowadź pełne hasło w pole wyszukiwania na stronie **Twoich profili czatu**."; +/* No comment provided by engineer. */ +"To send" = "Żeby wysłać"; + +/* alert message */ +"To send commands you must be connected." = "Aby wysyłać polecenia, musisz być podłączony."; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "Aby obsługiwać natychmiastowe powiadomienia push, należy zmigrować bazę danych czatu."; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie."; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "Aby korzystać z serwerów **%@**, należy zaakceptować warunki użytkowania."; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach."; @@ -4637,6 +5620,9 @@ chat item action */ /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Przełącz incognito przy połączeniu."; +/* token status */ +"Token status: %@." = "Stan tokena: %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Nieprzezroczystość paska narzędzi"; @@ -4649,6 +5635,9 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "Sesje transportowe"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia."; + /* No comment provided by engineer. */ "Turkish interface" = "Turecki interfejs"; @@ -4679,6 +5668,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "odblokowano %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "Niedostarczone wiadomości"; + /* No comment provided by engineer. */ "Unexpected migration state" = "Nieoczekiwany stan migracji"; @@ -4745,6 +5737,9 @@ chat item action */ /* swipe action */ "Unread" = "Nieprzeczytane"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "Nieobsługiwane łącze połączenia"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Do nowych członków wysyłanych jest do 100 ostatnich wiadomości."; @@ -4760,6 +5755,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "Zaktualizować ustawienia?"; +/* No comment provided by engineer. */ +"Updated conditions" = "Zaktualizowane warunki"; + /* rcv group event chat item */ "updated group profile" = "zaktualizowano profil grupy"; @@ -4769,9 +5767,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "Aktualizacja ustawień spowoduje ponowne połączenie klienta ze wszystkimi serwerami."; +/* alert button */ +"Upgrade" = "Zaktualizuj"; + +/* No comment provided by engineer. */ +"Upgrade address" = "Uaktualnij adres"; + +/* alert message */ +"Upgrade address?" = "Uaktualnić adres?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "Zaktualizuj i otwórz czat"; +/* alert message */ +"Upgrade group link?" = "Uaktualnić link do grupy?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "Uaktualnij link"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "Zaktualizuj swój adres"; + /* No comment provided by engineer. */ "Upload errors" = "Błędy przesłania"; @@ -4793,18 +5809,30 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "Użyj hostów .onion"; +/* No comment provided by engineer. */ +"Use %@" = "Użyj %@"; + /* No comment provided by engineer. */ "Use chat" = "Użyj czatu"; /* new chat action */ "Use current profile" = "Użyj obecnego profilu"; +/* No comment provided by engineer. */ +"Use for files" = "Użyj dla plików"; + +/* No comment provided by engineer. */ +"Use for messages" = "Użyj dla wiadomości"; + /* No comment provided by engineer. */ "Use for new connections" = "Użyj dla nowych połączeń"; /* No comment provided by engineer. */ "Use from desktop" = "Użyj z komputera"; +/* No comment provided by engineer. */ +"Use incognito profile" = "Użyj profilu incognito"; + /* No comment provided by engineer. */ "Use iOS call interface" = "Użyj interfejsu połączeń iOS"; @@ -4823,18 +5851,30 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "Użyj serwera"; +/* No comment provided by engineer. */ +"Use servers" = "Użyj serwerów"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "Użyć serwerów SimpleX Chat?"; /* No comment provided by engineer. */ "Use SOCKS proxy" = "Użyj proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Jeśli nie podano portu, należy użyć portu TCP %@."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Używaj portu TCP 443 tylko dla domyślnych serwerów."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Używaj aplikacji podczas połączenia."; /* No comment provided by engineer. */ "Use the app with one hand." = "Korzystaj z aplikacji jedną ręką."; +/* No comment provided by engineer. */ +"Use web port" = "Użyj portu internetowego"; + /* No comment provided by engineer. */ "User selection" = "Wybór użytkownika"; @@ -4904,12 +5944,21 @@ chat item action */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "Film zostanie odebrany, gdy kontakt będzie online, poczekaj lub sprawdź później!"; +/* No comment provided by engineer. */ +"Videos" = "Wideo"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "Filmy i pliki do 1gb"; +/* No comment provided by engineer. */ +"View conditions" = "Zobacz warunki"; + /* No comment provided by engineer. */ "View security code" = "Pokaż kod bezpieczeństwa"; +/* No comment provided by engineer. */ +"View updated conditions" = "Zobacz zaktualizowane warunki"; + /* chat feature */ "Visible history" = "Widoczna historia"; @@ -4979,6 +6028,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "Wiadomość powitalna jest zbyt długa"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "Powitaj swoje kontakty 👋"; + /* No comment provided by engineer. */ "What's new" = "Co nowego"; @@ -4991,6 +6043,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "gdy IP ukryty"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje."; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Gdy udostępnisz komuś profil incognito, będzie on używany w grupach, do których Cię zaprosi."; @@ -5045,6 +6100,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "Zaakceptowałeś połączenie"; +/* snd group event chat item */ +"you accepted this member" = "zaakceptowałeś tego członka"; + /* No comment provided by engineer. */ "You allow" = "Pozwalasz"; @@ -5054,6 +6112,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "Jesteś już połączony z %@."; +/* No comment provided by engineer. */ +"You are already connected with %@." = "Zostałeś już połączony z %@."; + /* new chat sheet message */ "You are already connecting to %@." = "Już się łączysz z %@."; @@ -5072,9 +6133,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Już dołączasz do grupy!\nPowtórzyć prośbę dołączenia?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Jesteś połączony z serwerem służącym do odbierania wiadomości z tego połączenia."; + /* No comment provided by engineer. */ "You are invited to group" = "Jesteś zaproszony do grupy"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Nie masz połączenia z serwerem służącym do odbierania wiadomości w ramach tego połączenia (brak subskrypcji)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości."; @@ -5090,6 +6157,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "Możesz to zmienić w ustawieniach wyglądu."; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "Serwery można skonfigurować w ustawieniach."; + /* No comment provided by engineer. */ "You can create it later" = "Możesz go utworzyć później"; @@ -5114,6 +6184,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "Możesz wysyłać wiadomości do %@ ze zarchiwizowanych kontaktów."; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony."; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Podgląd powiadomień na ekranie blokady można ustawić w ustawieniach."; @@ -5138,6 +6211,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "Możesz zobaczyć link zaproszenia ponownie w szczegółach połączenia."; +/* alert message */ +"You can view your reports in Chat with admins." = "Możesz przeglądać swoje raporty w czacie z administratorami."; + /* alert title */ "You can't send messages!" = "Nie możesz wysyłać wiadomości!"; @@ -5207,9 +6283,15 @@ chat item action */ /* chat list item description */ "you shared one-time link incognito" = "udostępniłeś jednorazowy link incognito"; +/* token info */ +"You should receive notifications." = "Powinieneś otrzymywać powiadomienia."; + /* snd group event chat item */ "you unblocked %@" = "odblokowałeś %@"; +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "Będziesz mógł wysyłać wiadomości **dopiero po zaakceptowaniu Twojej prośby**."; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "Zostaniesz połączony do grupy, gdy urządzenie gospodarza grupy będzie online, proszę czekać lub sprawdzić później!"; @@ -5228,6 +6310,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "Nadal będziesz otrzymywać połączenia i powiadomienia z wyciszonych profili, gdy są one aktywne."; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana."; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "Przestaniesz otrzymywać wiadomości od tej grupy. Historia czatu zostanie zachowana."; @@ -5243,6 +6328,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "Używasz profilu incognito dla tej grupy - aby zapobiec udostępnianiu głównego profilu zapraszanie kontaktów jest zabronione"; +/* No comment provided by engineer. */ +"Your business contact" = "Twój kontakt biznesowy"; + /* No comment provided by engineer. */ "Your calls" = "Twoje połączenia"; @@ -5258,9 +6346,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat profiles" = "Twoje profile czatu"; +/* alert message */ +"Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "Twoja rozmowa została przeniesiona do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; + /* No comment provided by engineer. */ "Your connection was moved to %@ but an error happened when switching profile." = "Twoje połączenie zostało przeniesione do %@, ale podczas przekierowywania do profilu wystąpił nieoczekiwany błąd."; +/* No comment provided by engineer. */ +"Your contact" = "Twój kontakt"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Twój kontakt wysłał plik, który jest większy niż obecnie obsługiwany maksymalny rozmiar (%@)."; @@ -5279,6 +6373,9 @@ chat item action */ /* No comment provided by engineer. */ "Your current profile" = "Twój obecny profil"; +/* No comment provided by engineer. */ +"Your group" = "Twoja grupa"; + /* No comment provided by engineer. */ "Your ICE servers" = "Twoje serwery ICE"; diff --git a/apps/ios/product/README.md b/apps/ios/product/README.md new file mode 100644 index 0000000000..107c0e6569 --- /dev/null +++ b/apps/ios/product/README.md @@ -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` diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md new file mode 100644 index 0000000000..3fa722d47a --- /dev/null +++ b/apps/ios/product/concepts.md @@ -0,0 +1,84 @@ +# 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` | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md) | [spec/api.md](../spec/api.md), [spec/state.md](../spec/state.md), [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/client/compose.md](../spec/client/compose.md) | `SimpleXChat/ChatTypes.swift` (`RelayStatus`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `CIDirection.channelRcv`, `GroupInfo.chatIconName`, `userCantSendReason`), `Shared/Views/Chat/ChatView.swift` (channel message rendering), `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (`sendAsGroup`, Broadcast placeholder), `Shared/Views/Chat/Group/GroupChatInfoView.swift` (channel info adaptations), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`) | + +--- + +## 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` diff --git a/apps/ios/product/flows/calling.md b/apps/ios/product/flows/calling.md new file mode 100644 index 0000000000..86cb026625 --- /dev/null +++ b/apps/ios/product/flows/calling.md @@ -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 diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md new file mode 100644 index 0000000000..c621dc5124 --- /dev/null +++ b/apps/ios/product/flows/connection.md @@ -0,0 +1,178 @@ +# 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) 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:directLink:groupShortLinkData:)` creates a local prepared chat. `directLink` is `true` for standard group links, `false` for channel relay links. +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. + +### 7a. Relay Link Rejection + +1. User scans, pastes, or opens a relay address link (URL path `/r` or `SimplexLinkType.relay`). +2. In `ContentView.connectViaUrl_()`: early return with alert "Relay address" / "This is a chat relay address, it cannot be used to connect." +3. In `NewChatView.planAndConnect()`: `.simplexLink(_, .relay, _, _)` pattern triggers the same alert. +4. The link is NOT processed further. No connection is attempted. + +### 7b. Channel Prepared Group Flow + +1. When connecting to a channel link (`GroupShortLinkInfo.direct == false`): +2. `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` is called with `directLink: false`, preparing the channel locally. +3. `groupShortLinkInfo.groupRelays` (hostnames) stored in `ChatModel.shared.channelRelayHostnames[groupId]`. +4. Pre-join UI shows channel icon and "Open new channel" (not "Open new group"). +5. `apiConnectPreparedGroup(groupId:incognito:msg:)` returns `(GroupInfo, [RelayConnectionResult])`. +6. `RelayConnectionResult` contains `relayMember: GroupMember` and optional `relayError: ChatError?` per relay. +7. Relay members are upserted to `chatModel.groupMembers`; `channelRelayHostnames` entry is cleared. + +### 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` | +| `GroupShortLinkInfo` | `Shared/Model/AppAPITypes.swift` | Contains `direct: Bool`, `groupRelays: [String]`, `publicGroupId: String?`; transient data returned by prepare | +| `RelayConnectionResult` | `Shared/Model/AppAPITypes.swift` | Contains `relayMember: GroupMember`, `relayError: ChatError?`; per-relay join outcome | + +## 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 diff --git a/apps/ios/product/flows/file-transfer.md b/apps/ios/product/flows/file-transfer.md new file mode 100644 index 0000000000..0b4b0538cc --- /dev/null +++ b/apps/ios/product/flows/file-transfer.md @@ -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 diff --git a/apps/ios/product/flows/group-lifecycle.md b/apps/ios/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..e102fa982a --- /dev/null +++ b/apps/ios/product/flows/group-lifecycle.md @@ -0,0 +1,228 @@ +# 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. + +### 1a. Create Public Group (Channel) + +1. Alternative to standard group creation for relay-backed channels. +2. Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`: + ```swift + func apiNewPublicGroup(incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile) async throws -> (GroupInfo, GroupLink, [GroupRelay]) + ``` +3. Sends `ChatCommand.apiNewPublicGroup(userId:incognito:relayIds:groupProfile:)` to core. +4. Core returns `ChatResponse2.publicGroupCreated(user, groupInfo, groupLink, groupRelays)`. +5. The resulting `GroupInfo` has `useRelays == true` and includes a group link. +6. Channel relay members (with role `.relay`) are managed by the core. + +### 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:directLink:groupShortLinkData:)` shows group info before joining. `directLink` is `true` for standard group links, `false` for channel relay links. +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`, `.relay` | +| `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 diff --git a/apps/ios/product/flows/messaging.md b/apps/ios/product/flows/messaging.md new file mode 100644 index 0000000000..d37fefdd7d --- /dev/null +++ b/apps/ios/product/flows/messaging.md @@ -0,0 +1,178 @@ +# Messaging Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Overview + +Complete message lifecycle in SimpleX Chat iOS: composing, sending, receiving, editing, deleting, reacting to, replying to, and forwarding messages. All messages are end-to-end encrypted via the SMP protocol. The Haskell core handles encryption, routing, and persistence; the Swift UI layer drives composition and display. + +## Prerequisites + +- User profile created and chat engine running (`startChat()` completed) +- At least one established contact or group conversation +- `ChatModel.shared` populated with chat list data + +## Step-by-Step Processes + +### 1. Send Text Message + +1. User navigates to a conversation (direct or group) via `ChatListView` -> `ChatView`. +2. User types text into `ComposeView`'s `SendMessageView` text editor. +3. Link previews are detected and fetched asynchronously (`ComposeLinkView`). +4. User taps the send button. +5. `ComposeView` builds a `ComposedMessage`: + ```swift + ComposedMessage( + fileSource: nil, + quotedItemId: nil, + msgContent: .text("Hello"), + mentions: [:] + ) + ``` +6. Calls `apiSendMessages(type:id:scope:live:ttl:composedMessages:sendAsGroup:)` (where `sendAsGroup` defaults to `false`; set to `true` when a channel owner sends as the channel identity). +7. Internally dispatches `ChatCommand.apiSendMessages(...)` to the Haskell core. +8. Core encrypts, queues via SMP, and returns `ChatResponse1.newChatItems(user, aChatItems)`. +9. `processSendMessageCmd` extracts `[ChatItem]` from response. +10. For direct chats, a background task tracks delivery via `chatModel.messageDelivery`. +11. `ChatModel` updates, UI refreshes to show the new message. + +### 2. Send Media (Image/Video/File) + +1. User taps the attachment button in `ComposeView`. +2. **Image**: Picked via `PhotosPicker` or camera. Compressed to <=255KB. Sent inline with `.image(text, base64Image)` content type. +3. **Video**: Picked from library. Thumbnail generated. Video file sent via XFTP for files >255KB. Content type: `.video(text, thumbnail, duration)`. +4. **File**: Picked via document picker. If <=255KB, sent inline. If >255KB, uploaded via XFTP (up to 1GB). Content type: `.file(text)`. +5. `ComposedMessage` includes `fileSource: CryptoFile(filePath:)`. +6. `apiSendMessages(...)` called with the composed message array. +7. Core handles XFTP upload for large files (chunked, encrypted upload to XFTP servers). +8. Recipient receives file reference and can download. + +### 3. Receive Message + +1. `ChatReceiver.shared` runs `receiveMsgLoop()` continuously calling `chatRecvMsg()`. +2. Core delivers events via `APIResult`. +3. On `ChatEvent.newChatItems(user, chatItems)`: + - `processReceivedMsg` is called. + - For the active user, `ChatModel` is updated with new items. + - If the chat is currently open, `ItemsModel` appends to `reversedChatItems`. + - `NtfManager` posts a local notification if the app is in the background. +4. Small files/images attached to incoming messages are auto-received if within size thresholds. + +### 4. Edit Message + +1. User long-presses a sent message -> selects "Edit" from context menu. +2. `ComposeView` enters edit mode with the original text pre-filled. +3. User modifies text and taps send. +4. Calls `apiUpdateChatItem(type:id:scope:itemId:updatedMessage:live:)`. +5. Dispatches `ChatCommand.apiUpdateChatItem(...)`. +6. Core returns `ChatResponse1.chatItemUpdated(user, aChatItem)` or `.chatItemNotChanged(user, aChatItem)`. +7. `ChatModel` updates the item in place. Edit timestamp is shown in the UI. + +### 5. Delete Message + +1. User long-presses a message -> selects "Delete". +2. Presented with options: + - **Delete for me** (`CIDeleteMode.cidmInternal`) -- removes locally only. + - **Delete for everyone** (`CIDeleteMode.cidmBroadcast`) -- sends deletion to recipient(s). +3. Calls `apiDeleteChatItems(type:id:scope:itemIds:mode:)`. +4. Dispatches `ChatCommand.apiDeleteChatItem(type:id:scope:itemIds:mode:)`. +5. Core returns `ChatResponse1.chatItemsDeleted(user, items, _)` containing `[ChatItemDeletion]`. +6. For group messages from other members, admin/owner can call `apiDeleteMemberChatItems(groupId:itemIds:)`. +7. `ChatModel` removes or replaces items with "deleted" placeholders. + +### 6. React to Message + +1. User long-presses a message -> selects "React" -> picks an emoji. +2. Calls `apiChatItemReaction(type:id:scope:itemId:add:reaction:)`. +3. `reaction` is `MsgReaction` (e.g., `.emoji(.heart)`). +4. `add: true` to add, `add: false` to remove. +5. Core returns `ChatResponse1.chatItemReaction(user, _, reaction)`. +6. The reaction is displayed below the message bubble. + +### 7. Reply to Message + +1. User long-presses a message -> selects "Reply". +2. `ComposeView` enters reply mode, showing quoted message in `ContextItemView`. +3. User types reply text and taps send. +4. `ComposedMessage` is created with `quotedItemId: originalItem.id`. +5. `apiSendMessages(...)` sends with the quote reference. +6. Recipient sees the reply with the quoted context rendered above. + +### 8. Forward Message + +1. User long-presses a message -> selects "Forward". +2. `ChatItemForwardingView` is presented for destination chat selection. +3. `apiPlanForwardChatItems(type:id:scope:itemIds:)` validates what can be forwarded, returns `([Int64], ForwardConfirmation?)`. +4. User confirms and selects destination chat. +5. Calls `apiForwardChatItems(toChatType:toChatId:toScope:fromChatType:fromChatId:fromScope:itemIds:ttl:sendAsGroup:)` (where `sendAsGroup` defaults to `false`). +6. Core returns `ChatResponse1.newChatItems(...)` with the forwarded items in the destination chat. + +### 9. Voice Message + +1. User taps and holds the microphone button in `ComposeView`. +2. `AudioRecPlay` starts recording to a temporary file. +3. On release, recording stops. Duration is calculated (max 5 minutes / 300 seconds). +4. `ComposedMessage` created with: + - `fileSource: CryptoFile` pointing to the audio file + - `msgContent: .voice(text: "", duration: seconds)` +5. `apiSendMessages(...)` sends the voice message. +6. Voice messages <=510KB sent inline; larger via XFTP. +7. Recipient sees `CIVoiceView` with waveform and playback controls. + +### 10. Delivery Tracking + +1. On send, message status starts as `CIStatus.sndNew`. +2. After SMP delivery: `CIStatus.sndSent(sndProgress)`. +3. When delivered to recipient's agent: status updates to delivered. +4. If delivery receipts are enabled by both parties, read status is reported. +5. Failed delivery results in `CIStatus.sndError*` or `CIStatus.sndWarning*`. +6. Status is displayed via `CIMetaView` (checkmarks/indicators). + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `ComposedMessage` | `SimpleXChat/APITypes.swift` | Outgoing message: fileSource, quotedItemId, msgContent, mentions | +| `MsgContent` | `SimpleXChat/ChatTypes.swift` | Enum: `.text`, `.link`, `.image`, `.video`, `.voice`, `.file` | +| `CIContent` | `SimpleXChat/ChatTypes.swift` | Chat item content wrapper with sent/received variants | +| `CIStatus` | `SimpleXChat/ChatTypes.swift` | Delivery status: sndNew, sndSent, sndError, rcvNew, rcvRead | +| `CIDirection` | `SimpleXChat/ChatTypes.swift` | `.directSnd`, `.directRcv`, `.groupSnd`, `.groupRcv(groupMember)`, `.channelRcv` | +| `ChatItem` | `SimpleXChat/ChatTypes.swift` | Full message model: content, meta, status, direction, quotedItem | +| `ChatItemDeletion` | `SimpleXChat/ChatTypes.swift` | Deleted item info with old/new item pairs | +| `CIDeleteMode` | `SimpleXChat/ChatTypes.swift` | `.cidmInternal` (local) or `.cidmBroadcast` (for everyone) | +| `MsgReaction` | `SimpleXChat/ChatTypes.swift` | Reaction type (emoji-based) | +| `UpdatedMessage` | `SimpleXChat/APITypes.swift` | Edited message content for update API | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `ChatError.errorAgent(.SMP(_, .AUTH))` | Recipient queue issue | Show "Connection error (AUTH)" alert | +| `ChatError.errorAgent(.BROKER(_, .TIMEOUT))` | Server timeout | Retryable: show retry dialog via `chatApiSendCmdWithRetry` | +| `ChatError.errorAgent(.BROKER(_, .NETWORK))` | Network failure | Retryable: show retry dialog | +| Send message error | Core processing failure | `sendMessageErrorAlert` shown to user | +| `chatItemNotChanged` | Edit with identical content | No error, item returned unchanged | +| File too large (>1GB) | XFTP limit exceeded | Prevented in UI file picker | +| `fileNotApproved` | Unknown XFTP relay servers | Show "Unknown servers!" alert with approve option | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/Views/Chat/ComposeMessage/ComposeView.swift` | Message composition UI and send logic | +| `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` | Text input and send button | +| `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` | Reply/edit context display | +| `Shared/Views/Chat/ChatItemView.swift` | Per-message rendering dispatcher | +| `Shared/Views/Chat/ChatItem/MsgContentView.swift` | Text message content with markdown | +| `Shared/Views/Chat/ChatItem/CIMetaView.swift` | Delivery status indicators | +| `Shared/Views/Chat/ChatItemForwardingView.swift` | Forward destination picker | +| `Shared/Views/Chat/ChatItemInfoView.swift` | Message info (delivery details, timestamps) | +| `Shared/Model/SimpleXAPI.swift` | API functions: `apiSendMessages`, `apiUpdateChatItem`, `apiDeleteChatItems`, `apiChatItemReaction`, `apiForwardChatItems` | +| `SimpleXChat/APITypes.swift` | `ComposedMessage`, `ChatCommand` enum, response types | +| `SimpleXChat/ChatTypes.swift` | `MsgContent`, `CIContent`, `CIStatus`, `CIDirection`, `ChatItem` | +| `Shared/Model/AudioRecPlay.swift` | Voice message recording/playback engine | + +## Related Specifications + +- `apps/ios/product/views/chat.md` -- Chat view UI specification +- `apps/ios/product/README.md` -- Product overview and capability map diff --git a/apps/ios/product/flows/onboarding.md b/apps/ios/product/flows/onboarding.md new file mode 100644 index 0000000000..5e2e04d42a --- /dev/null +++ b/apps/ios/product/flows/onboarding.md @@ -0,0 +1,239 @@ +# Onboarding Flow + +> **Related spec:** [spec/architecture.md](../../spec/architecture.md) | [spec/database.md](../../spec/database.md) + +## Overview + +First-time setup and migration flows for SimpleX Chat iOS. Covers app initialization, profile creation, server operator selection, notification configuration, and database import/export for device migration. The app uses a Haskell runtime for its core chat engine, with SQLite databases shared between the main app and the Notification Service Extension (NSE). + +## Prerequisites + +- Fresh install of SimpleX Chat from the App Store, or +- Existing install with database archive for import/migration +- iOS 15+ with App Group entitlement configured + +## Step-by-Step Processes + +### 1. App Initialization Sequence + +On every app launch, `SimpleXApp.init()` executes the following in order: + +``` +1. haskell_init() -- Start Haskell runtime system (GHC RTS) +2. UserDefaults.standard.register(defaults:) -- Set default preferences (appDefaults) +3. setGroupDefaults() -- Configure app group shared defaults +4. registerGroupDefaults() -- Register group container defaults +5. setDbContainer() -- Configure database paths in app group container +6. BGManager.shared.register() -- Register background task handlers +7. NtfManager.shared.registerCategories() -- Register notification action categories +``` + +Then in `ContentView.onAppear`: +- If no migration is in progress and authentication is set up, `initChatAndMigrate()` is called. +- This triggers `chatMigrateInit()` to initialize/migrate databases. +- Then `startChat()` is called to start the chat engine. + +### 2. Fresh Install -- Onboarding Steps + +Onboarding is managed by `OnboardingStage` enum and `OnboardingView`: + +**Step 1: SimpleX Info** (`step1_SimpleXInfo`) +1. `SimpleXInfo` view is presented. +2. Explains SimpleX's architecture: no user identifiers, E2E encryption, decentralized servers. +3. User taps "Create your profile" to proceed. + +**Step 2: Create Profile** (`step2_CreateProfile` -- now inline in step 1) +1. `CreateFirstProfile` view (embedded in the onboarding flow). +2. User enters display name (required). Full name is set to empty string. +3. Display name is validated via `mkValidName()` and `canCreateProfile()`. +4. On "Create": + ```swift + AppChatState.shared.set(.active) + m.currentUser = try apiCreateActiveUser(profile) + try startChat() + ``` +5. `apiCreateActiveUser(Profile(displayName:fullName:shortDescr:))` creates the user in the Haskell core. +6. `startChat()` initializes the chat engine. +7. Onboarding advances to `step3_ChooseServerOperators`. + +**Step 3: Choose Server Operators** (`step3_ChooseServerOperators`) +1. `OnboardingConditionsView` is presented (simplified conditions acceptance). +2. User reviews and accepts server operator conditions. +3. This configures which SMP/XFTP server operators to use. +4. Advances to `step4_SetNotificationsMode`. + +**Step 4: Set Notifications** (`step4_SetNotificationsMode`) +1. `SetNotificationsMode` view is presented. +2. Three options: + - **Instant**: Requires Apple Push Notification service. Registers device token via `apiRegisterToken(token:notificationMode:)`. + - **Periodic**: Uses iOS background app refresh. No push token needed. + - **Off**: No notifications. +3. For instant mode: `apiRegisterToken` sends `ChatCommand.apiRegisterToken(token:notificationMode:)` and receives `ChatResponse2.ntfTokenStatus(status)`. +4. On completion: `onboardingStageDefault.set(.onboardingComplete)`. + +**Onboarding Complete** (`onboardingComplete`) +1. `ChatListView` is shown. +2. Empty state displays "Add contact" prompt via `ChatHelp`. +3. If delivery receipts haven't been configured: `chatModel.setDeliveryReceipts = true` triggers a prompt. + +### 3. startChat() -- Chat Engine Startup + +Called after profile creation or on subsequent app launches: + +```swift +func startChat(refreshInvitations: Bool = true, onboarding: Bool = false) throws { + 1. setNetworkConfig(getNetCfg()) -- Apply network configuration + 2. apiCheckChatRunning() -- Check if already running + 3. listUsers() -- Load all user profiles + 4. getUserChatData() -- Load chats, tags, address, TTL + 5. NtfManager.shared.setNtfBadgeCount(...) -- Set badge count + 6. refreshCallInvitations() -- Check pending call invitations + 7. apiGetNtfToken() -- Get notification token status + 8. apiStartChat() -- Start the Haskell chat engine + 9. registerToken(token:) -- Register push token if available + 10. ChatReceiver.shared.start() -- Start message receive loop +} +``` + +### 4. Database Setup + +**Location:** +- App group container (shared with NSE): determined by `dbContainerGroupDefault` +- Path prefix: `simplex_v1` (`DB_FILE_PREFIX`) +- Chat database: `simplex_v1_chat.db` (messages, contacts, groups, settings) +- Agent database: `simplex_v1_agent.db` (SMP connections, encryption keys, queues) + +**Initialization:** +- `chatMigrateInit(useKey:confirmMigrations:backgroundMode:)` in `SimpleXChat/API.swift`. +- Creates databases if they do not exist. +- Runs pending migrations with confirmation mode. +- Handles database encryption: + - If keychain storage enabled: generates random DB key on first run (`randomDatabasePassword()`). + - Stores key in keychain via `kcDatabasePassword`. + - `initialRandomDBPassphraseGroupDefault` tracks whether using auto-generated key. + +**Encryption:** +- Optional database encryption passphrase via `DatabaseEncryptionView`. +- `apiStorageEncryption(currentKey:newKey:)` changes encryption key. +- `testStorageEncryption(key:)` validates a key against the database. + +### 5. Database Export (Source Device) + +1. User navigates to Settings -> Database -> "Export database". +2. Chat must be stopped first for data consistency. +3. Calls `apiExportArchive(config: ArchiveConfig)`: + ```swift + func apiExportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core creates a ZIP archive containing both databases and file attachments. +5. Returns any non-fatal `[ArchiveError]` (e.g., file access issues). +6. User transfers the archive to the new device via AirDrop, file share, etc. + +### 6. Database Import (Destination Device) + +1. On new device: during onboarding or Settings -> Database -> "Import database". +2. User selects the archive file. +3. Calls `apiImportArchive(config: ArchiveConfig)`: + ```swift + func apiImportArchive(config: ArchiveConfig) async throws -> [ArchiveError] + ``` +4. Core extracts the archive, replacing local databases. +5. Returns any non-fatal `[ArchiveError]`. +6. Chat engine is restarted with the imported data. +7. All contacts, groups, messages, and settings are restored. + +### 7. In-App Device Migration + +An alternative to manual export/import using direct device-to-device transfer. + +**Source device** (`MigrateFromDevice` view): +1. User navigates to Settings -> Database -> "Migrate to another device". +2. App creates a temporary database and uploads archive via XFTP standalone file. +3. Generates a migration link containing the file URL and encryption key. +4. Displays QR code / share link for the destination device. + +**Destination device** (`MigrateToDevice` view): +1. On new device: onboarding detects migration state or user selects "Migrate". +2. Scans/pastes the migration link. +3. `downloadStandaloneFile(user:url:file:ctrl:)` downloads the archive from XFTP. +4. `standaloneFileInfo(url:ctrl:)` validates the file metadata. +5. Archive is imported, databases are restored. +6. `chatInitTemporaryDatabase(url:key:confirmation:)` may be used for temporary DB operations during migration. +7. Chat engine starts with the migrated data. + +If migration is interrupted: +- `chatModel.migrationState` preserves state across app restarts. +- On next launch, `ContentView.onAppear` detects pending migration and resumes. + +### 8. Additional Profile Creation (Multi-Account) + +1. From `UserPicker` (profile switcher) -> "Add profile". +2. `CreateProfile` view is presented (distinct from `CreateFirstProfile`). +3. User enters display name and optional bio (max 160 bytes JSON-encoded, `MAX_BIO_LENGTH_BYTES`). +4. `apiCreateActiveUser(profile)` creates additional user. +5. `listUsers()` and `getUserChatData()` refresh the model. +6. No onboarding steps -- goes directly to chat list. + +## Data Structures + +| Type | Location | Description | +|------|----------|-------------| +| `OnboardingStage` | `Shared/Views/Onboarding/OnboardingView.swift` | Enum: `step1_SimpleXInfo`, `step2_CreateProfile`, `step3_ChooseServerOperators`, `step4_SetNotificationsMode`, `onboardingComplete` | +| `Profile` | `SimpleXChat/ChatTypes.swift` | `displayName`, `fullName`, `image`, `shortDescr` | +| `User` | `SimpleXChat/ChatTypes.swift` | Full user model with profile, userId, and settings | +| `ArchiveConfig` | `SimpleXChat/APITypes.swift` | Configuration for database export/import | +| `DBMigrationResult` | `SimpleXChat/API.swift` | Result of database migration: `.ok`, `.errorNotADatabase`, `.errorKeychain`, etc. | +| `MigrationConfirmation` | `SimpleXChat/API.swift` | Migration confirmation mode: `.error`, `.yesUp`, `.yesUpDown` | +| `DeviceToken` | `SimpleXChat/ChatTypes.swift` | Apple push notification device token | +| `NtfTknStatus` | `SimpleXChat/ChatTypes.swift` | Notification token status: registered, active, expired, etc. | +| `NotificationsMode` | `SimpleXChat/ChatTypes.swift` | `.off`, `.periodic`, `.instant` | +| `MigrationFileLinkData` | Used in standalone file transfers for device migration | +| `AppChatState` | `SimpleXChat/` | Shared state: `.active`, `.stopped`, `.suspended` | + +## Error Cases + +| Error | Cause | Handling | +|-------|-------|----------| +| `DBMigrationResult.errorNotADatabase` | Wrong encryption key or corrupt DB | Show `DatabaseErrorView` with options | +| `DBMigrationResult.errorKeychain` | Keychain access failed | Show error, offer to re-enter passphrase | +| `DBMigrationResult.errorMigration` | Schema migration failure | Show error with migration details | +| `duplicateUserError` | Display name already in use | `UserProfileAlert.duplicateUserError` | +| `invalidDisplayNameError` | Invalid characters in display name | `UserProfileAlert.invalidDisplayNameError` | +| `createUserError` | Core failed to create user | Alert with error details | +| `invalidNameError(validName)` | Name needs normalization | Alert suggesting the valid name | +| Archive import errors | Missing files, version mismatch | Non-fatal `[ArchiveError]` displayed | +| Migration interrupted | Network failure, app killed | State preserved in `chatModel.migrationState`, resumed on next launch | + +## Key Files + +| File | Purpose | +|------|---------| +| `Shared/SimpleXApp.swift` | App entry point: `haskell_init`, defaults registration, DB container setup, BG tasks | +| `Shared/AppDelegate.swift` | Push notification registration, URL handling | +| `Shared/ContentView.swift` | Root view: authentication, onboarding routing, chat initialization | +| `Shared/Views/Onboarding/OnboardingView.swift` | Onboarding step router, `OnboardingStage` enum | +| `Shared/Views/Onboarding/SimpleXInfo.swift` | Step 1: Privacy architecture explanation | +| `Shared/Views/Onboarding/CreateProfile.swift` | Profile creation: `CreateProfile` (additional) and `CreateFirstProfile` (onboarding) | +| `Shared/Views/Onboarding/ChooseServerOperators.swift` | Step 3: Server operator conditions | +| `Shared/Views/Onboarding/SetNotificationsMode.swift` | Step 4: Notification mode selection | +| `Shared/Views/Onboarding/CreateSimpleXAddress.swift` | Optional address creation during onboarding | +| `Shared/Views/Onboarding/HowItWorks.swift` | Educational content about SimpleX protocol | +| `Shared/Views/Migration/MigrateFromDevice.swift` | Source device migration UI | +| `Shared/Views/Migration/MigrateToDevice.swift` | Destination device migration UI | +| `Shared/Views/Database/DatabaseView.swift` | Database management: export, import, encryption | +| `Shared/Views/Database/DatabaseEncryptionView.swift` | Database passphrase management | +| `Shared/Views/Database/DatabaseErrorView.swift` | Database error recovery UI | +| `Shared/Views/Database/MigrateToAppGroupView.swift` | Legacy migration from Documents to App Group container | +| `Shared/Model/SimpleXAPI.swift` | `startChat`, `apiCreateActiveUser`, `apiExportArchive`, `apiImportArchive`, `apiRegisterToken` | +| `SimpleXChat/API.swift` | `chatMigrateInit`, `chatInitTemporaryDatabase`, low-level DB initialization | +| `SimpleXChat/FileUtils.swift` | DB file paths, constants (`DB_FILE_PREFIX`, `CHAT_DB`, `AGENT_DB`) | +| `SimpleXChat/AppGroup.swift` | App group container configuration | +| `SimpleXChat/KeyChain.swift` | Keychain access for DB passphrase and app passwords | +| `Shared/Model/BGManager.swift` | Background task registration and scheduling | +| `Shared/Model/NtfManager.swift` | Notification management and badge counts | + +## Related Specifications + +- `apps/ios/product/README.md` -- Product overview: architecture and capabilities +- `apps/ios/product/flows/connection.md` -- After onboarding, user establishes first connections +- `apps/ios/product/flows/messaging.md` -- Messaging starts after profile creation diff --git a/apps/ios/product/gaps.md b/apps/ios/product/gaps.md new file mode 100644 index 0000000000..50d6bf2938 --- /dev/null +++ b/apps/ios/product/gaps.md @@ -0,0 +1,64 @@ +# SimpleX Chat iOS -- Known Gaps & Recommendations + +> Aggregation of `[GAP]` and `[REC]` annotations discovered during specification analysis. Organized by product area. +> +> **Related spec:** [spec/README.md](../spec/README.md) + +--- + +## UI: Error Feedback + +### GAP: No user-visible error on FFI command failure +**Source:** [spec/architecture.md](../spec/architecture.md) +API calls via `chatApiSendCmd` return `APIResult` which can be `.error(ChatError)`. Not all error cases surface user-visible feedback in the UI. + +**REC:** Audit all `chatApiSendCmd` call sites and ensure `.error` cases show appropriate alerts or banners. + +--- + +## UI: Loading States + +### GAP: No loading indicator during initial chat list population +**Source:** [spec/client/chat-list.md](../spec/client/chat-list.md) +When `ChatModel.chatInitialized` transitions to `true`, the chat list appears fully formed. There is no intermediate loading state for users with large numbers of chats. + +**REC:** Add a progress indicator during `apiGetChats` for users with 100+ conversations. + +--- + +## Flows: Group Lifecycle + +### GAP: Bulk member role change — API supports batch but UI uses single-member calls +**Source:** [spec/api.md](../spec/api.md) +`APIMembersRole` accepts `NonEmpty GroupMemberId`, supporting batch role changes at the API level. However, the iOS UI (`GroupMemberInfoView.swift`) currently invokes it with a single member at a time. + +**REC:** Expose batch role change in the UI for group admins managing large groups. + +--- + +## Security + +### GAP: Database passphrase not enforced by default +**Source:** [spec/database.md](../spec/database.md) +Database encryption is optional and requires the user to manually set a passphrase. New installations start with an unencrypted database. + +**REC:** Consider prompting users to set a database passphrase during onboarding, especially on devices without hardware encryption. + +### GAP: No forward secrecy indicator in UI +**Source:** [product/glossary.md](glossary.md) +While the double-ratchet protocol provides forward secrecy, there is no UI indicator showing whether a specific conversation has achieved forward secrecy (i.e., completed initial key exchange ratcheting). + +**REC:** Add a security indicator in contact/group info showing ratchet state. + +--- + +## Documentation + +### GAP: Haskell Store layer not fully specified +**Source:** [spec/database.md](../spec/database.md) +The Haskell Store modules (`Store/Direct.hs`, `Store/Groups.hs`, `Store/Messages.hs`, etc.) are referenced by function name but not fully specified with parameter types and return types. + +**REC:** Expand database spec with key Store function signatures as the specification matures. + +--- + diff --git a/apps/ios/product/glossary.md b/apps/ios/product/glossary.md new file mode 100644 index 0000000000..7b2e227ffa --- /dev/null +++ b/apps/ios/product/glossary.md @@ -0,0 +1,253 @@ +# SimpleX Chat iOS -- Glossary + +> SimpleX Chat iOS domain glossary. Defines all domain terms used in SimpleX Chat with links to relevant specifications and source code. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) + +## Table of Contents + +1. [Protocols & Cryptography](#protocols--cryptography) +2. [Core Data Types](#core-data-types) +3. [Commands & Events](#commands--events) +4. [Connection & Identity](#connection--identity) +5. [Messaging Features](#messaging-features) +6. [Calling & Media](#calling--media) +7. [Notifications & Background](#notifications--background) +8. [Application Architecture](#application-architecture) +9. [Configuration & Preferences](#configuration--preferences) + +--- + +## Protocols & Cryptography + +### SMP (Simplex Messaging Protocol) +The core messaging protocol used for asynchronous message delivery through relay servers. Each conversation uses separate unidirectional queues, and sender and receiver queues have no shared identifier. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/simplex-messaging.md`, implementation `simplexmq/src/Simplex/Messaging/Protocol.hs`* + +### SMP Server +A relay server that stores and forwards encrypted messages between parties. Users can configure custom SMP servers or use defaults. Servers cannot see message contents or correlate senders with receivers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### XFTP (eXtended File Transfer Protocol) +A protocol for transferring large files (up to 1GB) through relay servers. Files are encrypted, split into chunks, and uploaded to XFTP servers. Recipients download and reassemble chunks independently. Defined in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/xftp.md`, implementation `simplexmq/src/Simplex/FileTransfer/Protocol.hs`; chat-level integration `../../src/Simplex/Chat/Files.hs`* + +### XFTP Server +A relay server that stores encrypted file chunks for asynchronous file transfer. Like SMP servers, users can configure custom XFTP servers. *See: `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift`* + +### SMP Agent +The lower-level agent library (in [simplexmq](https://github.com/simplex-chat/simplexmq)) that manages SMP connections, queue creation/rotation, duplex connection establishment, message delivery, and the double-ratchet encryption protocol. The chat application layer communicates with the agent via its functional API. *See: protocol spec `simplexmq/protocol/agent-protocol.md`, implementation `simplexmq/src/Simplex/Messaging/Agent.hs`; chat-level integration `../../src/Simplex/Chat/Controller.hs`* + +### Double Ratchet +The key agreement protocol used for E2E encryption. Provides forward secrecy and break-in recovery by deriving new encryption keys for each message. Based on the Signal protocol's double-ratchet algorithm, augmented with post-quantum KEM (PQDR). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Post-Quantum Encryption +Optional quantum-resistant key exchange (PQ) available for direct chats. Uses a hybrid scheme combining classical X25519 with Streamlined NTRU-Prime 761 (sntrup761) KEM. The hybrid secret is SHA3-256(DH_secret || KEM_shared_secret). Implemented in the [simplexmq](https://github.com/simplex-chat/simplexmq) library. *See: protocol spec `simplexmq/protocol/pqdr.md`, implementation `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs`; Swift types `SimpleXChat/ChatTypes.swift` (PQEncryption, PQSupport)* + +### E2E Encryption +End-to-end encryption ensuring that only the communicating parties can read message contents. Neither SMP relay servers nor any network observer can decrypt messages. All SimpleX Chat messages are E2E encrypted by default using the double-ratchet protocol. *See: `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` (ratchet implementation), `simplexmq/src/Simplex/Messaging/Agent/Protocol.hs` (E2E message envelopes)* + +### Forward Secrecy +A property of the double-ratchet protocol ensuring that compromise of current encryption keys does not compromise past session keys. Each message uses a derived key that is deleted after use. *See: `simplexmq/protocol/pqdr.md`, `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs`* + +### Chat Protocol (x-events) +The chat-level protocol defining message envelopes and content types exchanged between chat participants. Includes x-events (XMsgNew, XMsgUpdate, XMsgDel, XCallInv, XFileCancel, XGrpMemNew, etc.), MsgContent (text, image, video, voice, file, link), and message encoding (Binary/JSON). This is distinct from the lower-level SMP transport protocol. *See: `../../src/Simplex/Chat/Protocol.hs`* + +### Security Code +A hash of the shared encryption session displayed as a numeric code and QR code. Contacts can compare security codes out-of-band to verify they have an uncompromised E2E session. *See: `Shared/Views/Chat/VerifyCodeView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIVerifyContact)* + +--- + +## Core Data Types + +### ChatItem +The fundamental unit of content in a conversation. Represents a single message, event, call record, or system notification within a chat. Each ChatItem has direction (sent/received), content, metadata, and optional quoted context. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatItem), `SimpleXChat/ChatTypes.swift`* + +### ChatInfo +A type-safe wrapper identifying a conversation and its metadata. Variants: DirectChat (1:1 with Contact), GroupChat (with GroupInfo), LocalChat (note folder), ContactRequest, ContactConnection. *See: `../../src/Simplex/Chat/Messages.hs` (data ChatInfo), `SimpleXChat/ChatTypes.swift`* + +### CIContent +The content payload of a ChatItem. Differentiates sent vs. received content types: message content (text/image/file/voice/link), deletion markers, call records, group events, and feature preference changes. *See: `../../src/Simplex/Chat/Messages/CIContent.hs` (data CIContent)* + +### User +A local user profile within the app. Each user has an independent set of contacts, groups, and connections. Multiple users can exist in one app installation. Fields include userId, profile, display name, and optional view password hash for hidden profiles. *See: `../../src/Simplex/Chat/Types.hs` (data User), `Shared/Model/ChatModel.swift`* + +### Contact +A remote party with whom the user has an established E2E encrypted connection. Stores the contact's profile, local alias, connection status, feature preferences, and UI settings. *See: `../../src/Simplex/Chat/Types.hs` (data Contact), `SimpleXChat/ChatTypes.swift`* + +### GroupInfo +Metadata for a group conversation including group profile, member count, preferences, and membership status. Contains the user's own membership record as a GroupMember. *See: `../../src/Simplex/Chat/Types.hs` (data GroupInfo)* + +### GroupMember +A participant in a group conversation. Each member has a role, status, profile, and optionally a direct connection. The user's own membership is also represented as a GroupMember within GroupInfo. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMember)* + +### Connection +A low-level SMP agent connection between two parties. Each connection has a status (new, joined, ready, deleted), an agent connection ID, and is associated with a specific contact or group member. *See: `../../src/Simplex/Chat/Types.hs` (data Connection)* + +### ConnStatus +The lifecycle state of a Connection: ConnNew (created, awaiting join), ConnJoined (joined, handshake in progress), ConnReady (fully established), ConnDeleted (terminated). *See: `../../src/Simplex/Chat/Types.hs` (data ConnStatus)* + +### ContactStatus +The status of a contact record: CSActive (normal), CSDeleted (deleted by contact), CSDeletedByUser (deleted by user). *See: `../../src/Simplex/Chat/Types.hs` (data ContactStatus)* + +### GroupMemberRole +Hierarchical role assigned to a group member. From most to least privileged: GROwner, GRAdmin, GRModerator, GRMember, GRObserver, GRRelay. Roles determine permissions for sending messages, managing members, and moderating content. The `.relay` role is below `.observer` and is used for relay members in channels. *See: `../../src/Simplex/Chat/Types/Shared.hs` (data GroupMemberRole), `SimpleXChat/ChatTypes.swift` L2806* + +### GroupMemberStatus +The lifecycle state of a group member: GSMemRejected, GSMemRemoved, GSMemLeft, GSMemGroupDeleted, GSMemUnknown, GSMemInvited, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator, GSMemPendingReview, GSMemPendingApproval. *See: `../../src/Simplex/Chat/Types.hs` (data GroupMemberStatus)* + +### FileTransfer +Represents an in-progress or completed file transfer. Variants: FTSnd (sending, with metadata and per-recipient transfer records) and FTRcv (receiving). Tracks protocol (SMP inline or XFTP), progress, and encryption parameters. *See: `../../src/Simplex/Chat/Types.hs` (data FileTransfer)* + +### ChatTag +A user-defined label for organizing conversations in the chat list. Each tag has a text label and optional emoji. Chats can have multiple tags, and the chat list can be filtered by tag. *See: `../../src/Simplex/Chat/Types.hs` (data ChatTag), `Shared/Views/ChatList/TagListView.swift`* + +### Channel +A group that uses relay infrastructure for message delivery (`groupInfo.useRelays == true`). Channels decouple the message sender from direct group membership connections, routing messages through relay members instead. Channels display the `antenna.radiowaves.left.and.right` SF Symbol as their icon and render received messages with the group avatar and "channel" role label. *See: [spec/state.md](../spec/state.md) (Relay-Related Data Model), [spec/client/chat-view.md](../spec/client/chat-view.md) (Channel Message Rendering), `SimpleXChat/ChatTypes.swift` (GroupInfo.useRelays, GroupInfo.chatIconName)* + +### RelayStatus +The lifecycle state of a relay member in a channel: `.rsNew` (created), `.rsInvited` (invitation sent), `.rsAccepted` (accepted by relay), `.rsActive` (fully operational). *See: `SimpleXChat/ChatTypes.swift` L2506* + +### GroupRelay +A struct representing a relay instance for a group. Contains the relay's database ID (`groupRelayId`), associated group member ID, user chat relay ID, relay status, and optional relay link (per-group link for subscribers). *See: `SimpleXChat/ChatTypes.swift` L2555* + +### UserChatRelay +A struct representing a user's chat relay configuration. Contains the relay's database ID (`chatRelayId`), SMP server address, name, domains, and flags for preset/tested/enabled/deleted status. *See: `SimpleXChat/ChatTypes.swift` L2513* + +### GroupShortLinkInfo +Information about a group's short link including whether it's a direct link, associated relay hostnames, and shared group identifier. Transient data returned by `APIConnectPreparedGroup` — not persisted on GroupInfo. *See: `Shared/Model/AppAPITypes.swift` L1352* + +### CIDirection.channelRcv +A chat item direction case for messages received via a channel relay, as opposed to `.groupRcv` for standard group messages. *See: `SimpleXChat/ChatTypes.swift` L3529* + +--- + +## Commands & Events + +### ChatCommand +A sum type representing all commands the UI can send to the chat controller. Examples: APISendMessages, APIGetChat, APIConnect, APINewGroup, APIDeleteChatItem. Commands are serialized and dispatched through the FFI bridge. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatCommand)* + +### ChatResponse +A sum type representing synchronous responses from the chat controller to the UI after processing a ChatCommand. Examples: CRActiveUser, CRNewChatItems, CRChatItemUpdated. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatResponse)* + +### ChatEvent +A sum type representing asynchronous events pushed from the chat controller to the UI. These are unsolicited notifications about state changes: incoming messages, connection status changes, call invitations, etc. *See: `../../src/Simplex/Chat/Controller.hs` (data ChatEvent)* + +### ChatError +Error types returned by the chat controller. Variants: ChatError (application-level), ChatErrorAgent (SMP agent errors), ChatErrorStore (database errors), ChatErrorRemoteHost (remote desktop errors). *See: `../../src/Simplex/Chat/Controller.hs` (data ChatError)* + +--- + +## Connection & Identity + +### SimpleX Address +A long-lived contact address that others can use to send connection requests. Unlike one-time invitation links, an address can be reused by multiple contacts. The user can accept or reject each incoming request. *See: `Shared/Views/UserSettings/UserAddressView.swift`, `../../src/Simplex/Chat/Controller.hs` (APICreateMyAddress)* + +### Contact Link +A one-time or reusable URI that initiates a contact connection. When scanned or opened, it triggers the SMP handshake to establish an E2E encrypted channel between two parties. *See: `Shared/Views/NewChat/NewChatView.swift`* + +### Group Link +A shareable URI that allows new members to join a group. The link connects to the group host, who then introduces the new member to existing members. Configurable with a default member role. *See: `Shared/Views/Chat/Group/GroupLinkView.swift`, `../../src/Simplex/Chat/Types.hs` (data GroupLink)* + +### Short Link +A compact version of SimpleX contact or group links, using a shorter URI format for easier sharing. Contains encoded connection parameters with reduced character length. *See: `../../src/Simplex/Chat/Controller.hs`* + +### Incognito Mode +A privacy feature that generates a random profile (display name and avatar) for each new contact connection. The real user profile is never shared with incognito contacts. Can be toggled per-connection at invitation time. *See: `Shared/Views/UserSettings/IncognitoHelp.swift`, `../../src/Simplex/Chat/ProfileGenerator.hs`* + +### Hidden Profile +A user profile protected by a separate password. Hidden profiles do not appear in the user picker or profile list. To access a hidden profile, the user enters its password in the search field of the user picker. *See: `Shared/Views/UserSettings/HiddenProfileView.swift`, `../../src/Simplex/Chat/Controller.hs` (APIHideUser)* + +--- + +## Messaging Features + +### Delivery Receipt +A confirmation that a message was successfully delivered to the recipient's device. Displayed as a double-check indicator on sent messages. Can be enabled or disabled per contact or globally. *See: `Shared/Views/UserSettings/SetDeliveryReceiptsView.swift`, `../../src/Simplex/Chat/Controller.hs`* + +### Read Receipt +An indicator that a recipient has viewed a received message. Currently not implemented as a separate feature; delivery receipts serve as the primary delivery confirmation. *See: `Shared/Views/UserSettings/PrivacySettings.swift`* + +### Timed Message +A message with a configurable time-to-live (TTL). After the TTL expires, the message is automatically deleted from both sender and recipient devices. The TTL is set as a chat feature preference. Also referred to as a disappearing message. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Disappearing Message +Synonym for Timed Message. A message that self-destructs after a configured duration. The timer starts when the message is read by the recipient. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (TimedMessagesPreference)* + +### Message Integrity +Verification that messages are received in order and without gaps. The system detects skipped messages and decryption failures, displaying integrity error indicators in the chat. *See: `Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +### Decryption Error +An error occurring when a received message cannot be decrypted, typically due to ratchet synchronization issues. The UI displays a specific error view with recovery options. *See: `Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`, `../../src/Simplex/Chat/Messages/CIContent.hs`* + +--- + +## Calling & Media + +### CallKit +Apple's framework for integrating VoIP calls with the native iOS call UI. SimpleX Chat uses CallKit to display incoming calls on the lock screen, support call answering from the system UI, and manage audio sessions. *See: `Shared/Views/Call/CallController.swift`, `Shared/Views/Call/CallManager.swift`* + +### WebRTC +The real-time communication framework used for audio/video calls. SimpleX Chat wraps WebRTC in an E2E encrypted layer, with signaling performed through the existing SMP message channel rather than a central server. *See: `Shared/Views/Call/WebRTC.swift`, `Shared/Views/Call/WebRTCClient.swift`* + +### ICE Server +An Interactive Connectivity Establishment server used by WebRTC to discover network paths between call participants. SimpleX Chat supports configuring custom ICE servers. *See: `Shared/Views/UserSettings/RTCServers.swift`, `SimpleXChat/CallTypes.swift`* + +### TURN Server +A Traversal Using Relays around NAT server that relays WebRTC media when direct peer-to-peer connection is not possible. A specific type of ICE server. SimpleX Chat allows configuring custom TURN servers for call relay. *See: `Shared/Views/UserSettings/RTCServers.swift`* + +### RcvCallInvitation +An in-memory data structure representing an incoming call invitation. Contains the calling contact, call type (audio/video), encryption keys, and shared key for the WebRTC session. Not persisted to database. *See: `../../src/Simplex/Chat/Call.hs` (data RcvCallInvitation)* + +--- + +## Notifications & Background + +### Notification Service Extension (NSE) +An iOS app extension that processes incoming push notifications while the main app is not running. The NSE starts a temporary chat controller, decrypts the incoming message, and displays a notification with the message preview. *See: `SimpleX NSE/NotificationService.swift`, `SimpleX NSE/NSEAPITypes.swift`* + +### Background Task +An iOS background execution context used for periodic message fetching when instant notifications are not enabled. Managed by BGManager to check for new messages at system-determined intervals. *See: `Shared/Model/BGManager.swift`* + +--- + +## Application Architecture + +### chat_ctrl +The opaque C pointer to the Haskell chat controller, obtained via FFI initialization. All chat operations are dispatched through this controller handle. The main app and NSE maintain separate chat_ctrl instances. *See: `SimpleXChat/API.swift` (chatController, getChatCtrl)* + +### ComposeState +A Swift struct holding the current state of the message composition area. Tracks the message text, parsed markdown, preview, attached media, editing context, quote context, and voice recording state. *See: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` (struct ComposeState)* + +### ChatModel +The central observable model object for the iOS app. Holds all reactive state: current user, chat list, active chat, call state, app preferences, and navigation state. Published properties drive SwiftUI view updates. *See: `Shared/Model/ChatModel.swift` (class ChatModel)* + +### ItemsModel +An observable model managing the list of ChatItems displayed in a conversation view. Handles item loading, pagination, merging of new items, and secondary chat filtering. *See: `Shared/Model/ChatModel.swift` (class ItemsModel)* + +### AppTheme +An observable object encapsulating the current visual theme: name, base theme, color overrides, app-specific colors, and wallpaper configuration. Shared as an environment object across the SwiftUI view hierarchy. *See: `Shared/Theme/Theme.swift` (class AppTheme)* + +--- + +## Configuration & Preferences + +### FeaturePreference +A type class (Haskell) / protocol pattern representing a user's preference for a specific chat feature (e.g., timed messages, voice messages, calls). Each preference has an allow/enable setting and optional parameters. Feature preferences are negotiated between contacts. *See: `../../src/Simplex/Chat/Types/Preferences.hs` (class FeatureI, type FeaturePreference)* + +### ChatSettings +Per-chat configuration including notification mode (all/mentions/off), send receipts toggle, favorite flag, and tag assignments. Stored per contact and per group. *See: `../../src/Simplex/Chat/Types.hs` (data ChatSettings)* + +### UserDefaults / GroupDefaults +iOS persistent key-value storage for app preferences. GroupDefaults (UserDefaults with the app group suite name) is shared between the main app and the NSE extension. Stores settings like notification mode, appearance preferences, and runtime flags. *See: `SimpleXChat/AppGroup.swift` (groupDefaults)* + +--- + +## Cross-References + +- Product overview: [README.md](README.md) +- Concept index: [concepts.md](concepts.md) +- Haskell core types: `../../src/Simplex/Chat/Types.hs` +- Haskell controller: `../../src/Simplex/Chat/Controller.hs` +- Haskell chat protocol (x-events): `../../src/Simplex/Chat/Protocol.hs` +- Haskell messages: `../../src/Simplex/Chat/Messages.hs` +- Swift model: `Shared/Model/ChatModel.swift` +- Swift API types: `SimpleXChat/APITypes.swift`, `SimpleXChat/ChatTypes.swift` +- simplexmq library (SMP, XFTP, Agent, encryption): [github.com/simplex-chat/simplexmq](https://github.com/simplex-chat/simplexmq) diff --git a/apps/ios/product/rules.md b/apps/ios/product/rules.md new file mode 100644 index 0000000000..0cb3f8e96a --- /dev/null +++ b/apps/ios/product/rules.md @@ -0,0 +1,148 @@ +# SimpleX Chat iOS -- Business Rules + +> Business invariants enforced by the SimpleX Chat iOS app and Haskell core. Each rule states the invariant, where it is enforced, and links to the relevant spec. +> +> **Related spec:** [spec/api.md](../spec/api.md) | [spec/architecture.md](../spec/architecture.md) | [spec/state.md](../spec/state.md) + +--- + +## Security & Privacy + +### RULE-01: No user identifiers +**Rule:** The system MUST NOT assign, generate, or expose any persistent user identifier (phone number, email, username, UUID) that could be used to correlate a user across conversations. +**Enforced by:** SMP protocol design in simplexmq library; each connection uses independent unidirectional queues with no shared identifier. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-02: End-to-end encryption on all messages +**Rule:** All message content MUST be encrypted end-to-end using double-ratchet (with optional post-quantum KEM). The SMP server MUST NOT have access to plaintext. +**Enforced by:** simplexmq library (`Simplex.Messaging.Crypto.Ratchet`); encryption happens before `chat_send_cmd_retry` FFI call. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-03: Database encryption at rest +**Rule:** Both SQLite databases (chat and agent) MUST be encrypted with SQLCipher when the user sets a database passphrase. +**Enforced by:** `chat_migrate_init_key` in Haskell core via SQLCipher; `DatabaseEncryptionView.swift` in UI. +**Spec:** [spec/database.md](../spec/database.md) + +### RULE-04: Local authentication before content access +**Rule:** When app lock is enabled, the app MUST authenticate the user (Face ID, Touch ID, or passcode) before displaying any chat content. +**Enforced by:** `LocalAuthView.swift`, `ContentView.swift` (`contentViewAccessAuthenticated` guard on `ChatModel`). +**Spec:** [spec/architecture.md](../spec/architecture.md) + +### RULE-05: Incognito profiles are per-connection +**Rule:** When incognito mode is used for a connection, the generated random profile MUST be unique to that connection and MUST NOT be reused across connections. +**Enforced by:** `ProfileGenerator.hs` generates fresh profile per connection; stored on the connection entity. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Message Integrity + +### RULE-06: Message order preservation +**Rule:** Messages within a single connection MUST be displayed in the order determined by the SMP agent's sequence numbers, not by local timestamps. +**Enforced by:** `Store/Messages.hs` (`createNewChatItem` uses agent-assigned ordering); `ItemsModel` in `ChatModel.swift` preserves this order. +**Spec:** [spec/state.md](../spec/state.md) + +### RULE-07: Edited messages retain history +**Rule:** When a message is edited, the previous version MUST be preserved in `chat_item_versions` and accessible via the item info view. +**Enforced by:** `Controller.hs` (`APIUpdateChatItem`); `Store/Messages.hs` (`updateChatItem` creates version record); `ChatItemInfoView.swift` displays history. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-08: Deleted messages respect deletion mode +**Rule:** `CIDeleteMode.cidmBroadcast` sends deletion to recipient; `cidmInternal` only deletes locally. Moderation deletion (`cidmInternalMark`) marks the item but retains a placeholder. +**Enforced by:** `Controller.hs` (`APIDeleteChatItem` checks `CIDeleteMode`); `MarkedDeletedItemView.swift` renders moderation placeholders. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-09: Timed messages auto-delete after TTL +**Rule:** Messages with a TTL MUST be automatically deleted from local storage after the configured time-to-live expires. +**Enforced by:** `Controller.hs` (background task scheduling); `Store/Messages.hs` (TTL-based cleanup). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Group Integrity + +### RULE-10: Role hierarchy enforcement +**Rule:** A member can only modify members with strictly lower roles. Owner > Admin > Moderator > Member > Observer. +**Enforced by:** `Controller.hs` (`APIMembersRole` validates role hierarchy); `GroupMemberInfoView.swift` restricts available actions in UI. +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-11: Group creator is always owner +**Rule:** The user who creates a group MUST be assigned the `GROwner` role and cannot be demoted. +**Enforced by:** `Controller.hs` (`APINewGroup`); `Store/Groups.hs` (`createNewGroup` assigns owner role). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-12: Group link role assignment +**Rule:** Members joining via group link MUST receive the role configured on the link (default: `GRMember`). Only admins and owners can create group links. +**Enforced by:** `Controller.hs` (`APICreateGroupLink` takes `memberRole` parameter); `GroupLinkView.swift` UI restricts to admin+. +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## File Transfer + +### RULE-13: File size limits +**Rule:** Files up to 1GB are transferred via XFTP. The system MUST reject files exceeding the configured maximum. +**Enforced by:** Haskell core (`Files.hs` checks file size); XFTP protocol enforces chunk limits. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +### RULE-14: File encryption at rest +**Rule:** When `privacyEncryptLocalFiles` is enabled, downloaded files MUST be encrypted locally using AES with per-file random key/nonce stored in `CryptoFile`. +**Enforced by:** `CryptoFile.swift` (`encryptCryptoFile`, `decryptCryptoFile`); `Library/Commands.hs` uses `CryptoFileArgs` for file encryption. +**Spec:** [spec/services/files.md](../spec/services/files.md) + +--- + +## Notification Delivery + +### RULE-15: Notification preview respects privacy setting +**Rule:** Notification content MUST respect `NotificationPreviewMode`: `.message` shows full content, `.contact` shows sender only, `.hidden` shows generic alert. +**Enforced by:** `Notifications.swift` (notification content creation checks `ntfPreviewModeGroupDefault`); `NotificationService.swift` (NSE content generation). +**Spec:** [spec/services/notifications.md](../spec/services/notifications.md) + +### RULE-16: NSE database coordination +**Rule:** The NSE and main app MUST NOT write to the database simultaneously. File locks coordinate access. +**Enforced by:** `chat_close_store` / `chat_reopen_store` FFI calls; NSE uses short-lived database sessions. +**Spec:** [spec/architecture.md](../spec/architecture.md) + +--- + +## Channel Integrity + +### RULE-19: Channel owner cannot leave own channel +**Rule:** A channel owner (`groupInfo.useRelays && groupInfo.isOwner`) who is the sole owner MUST NOT be able to leave the channel. The leave button is hidden in both swipe actions and context menu. +**Enforced by:** `ChatListNavLink.swift` (swipe/context menu guards), `GroupChatInfoView.swift` (leave button conditional). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) | [spec/client/chat-list.md](../spec/client/chat-list.md) + +### RULE-20: Relay members cannot be removed +**Rule:** Members with role `.relay` MUST NOT be removable through the member info UI. The remove button is hidden for relay members. +**Enforced by:** `GroupMemberInfoView.swift` (`mem.memberRole != .relay` guard on remove button). +**Spec:** [spec/client/chat-view.md](../spec/client/chat-view.md) + +### RULE-21: Relay links cannot be used to connect +**Rule:** SimpleX links with path `/r` (relay addresses) MUST be rejected when users attempt to connect. An explanatory alert is shown instead. +**Enforced by:** `ContentView.swift` (`connectViaUrl_` early return for `/r` path), `NewChatView.swift` (`planAndConnect` guard for `.simplexLink(_, .relay, _, _)`). +**Spec:** [spec/client/navigation.md](../spec/client/navigation.md) + +### RULE-22: Channel subscribers default to observer role +**Rule:** Members joining a channel via its link MUST receive the `.observer` role. The initial role picker is hidden for channels. +**Enforced by:** `AddChannelView.swift` (`groupLinkMemberRole: .observer` hardcoded), `GroupLinkView.swift` (role picker hidden when `isChannel`). +**Spec:** [spec/api.md](../spec/api.md) + +### RULE-23: Channels default to history enabled +**Rule:** Newly created channels MUST have message history enabled by default (`GroupPreference(enable: .on)`). +**Enforced by:** `AddChannelView.swift` (`createChannel()` sets history preference). +**Spec:** [spec/api.md](../spec/api.md) + +--- + +## Call Integrity + +### RULE-17: Call encryption key exchange +**Rule:** WebRTC call encryption keys MUST be negotiated over the existing E2E encrypted SMP channel, not through any external signaling server. +**Enforced by:** `ActiveCallView.swift` sends call signaling via `apiSendCallInvitation`/`apiSendCallAnswer` which use SMP; `Call.hs` defines call protocol. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) + +### RULE-18: CallKit region restriction +**Rule:** CallKit MUST be disabled in regions where it is restricted (China). The app uses in-app call UI as fallback. +**Enforced by:** `CallController.swift` checks `useCallKit()` based on region; `ActiveCallView.swift` provides fallback UI. +**Spec:** [spec/services/calls.md](../spec/services/calls.md) diff --git a/apps/ios/product/views/call.md b/apps/ios/product/views/call.md new file mode 100644 index 0000000000..f32f7ec243 --- /dev/null +++ b/apps/ios/product/views/call.md @@ -0,0 +1,122 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. Supports CallKit integration for native iOS call UI, picture-in-picture for video calls, audio device selection, and collapsible call overlay. + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallView` banner appears at top of screen; or native CallKit UI if enabled +- **Presented by**: `ActiveCallView` is overlaid on the main app view when `chatModel.activeCall` is set +- **Collapsible**: Call view can be collapsed via `chatModel.activeCallViewIsCollapsed` to return to chat while call continues +- **Dismiss**: Call ends when user taps end button or remote party disconnects + +## Page Sections + +### Incoming Call Banner (`IncomingCallView`) + +Displayed as an overlay banner when `CallController.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| Profile avatar | User profile image (shown when multiple profiles exist) | +| Call type icon | `video.fill` (green) for video calls, `phone.fill` (green) for audio | +| Call type text | "Audio call" or "Video call" with caller info | +| Caller profile | `ProfilePreview` showing caller name and image | +| Reject button | Red `phone.down.fill` icon -- ends the invitation | +| Ignore button | Neutral `multiply` icon -- dismisses the banner without rejecting | +| Accept button | Green `checkmark` icon -- accepts the call; if another call is active, ends it first | + +Sound: Ringtone plays via `SoundPlayer.startRingtone()` while banner is visible (unless call view is already showing). + +### Active Call View (`ActiveCallView`) + +Full-screen overlay with black background: + +| Element | Description | +|---|---| +| Remote video | Full-screen `CallViewRemote` showing remote party's camera feed; tap toggles between `scaleAspectFill` and `scaleAspectFit` | +| Local video preview | Small floating `CallViewLocal` in top-right corner (30% width); shows local camera with rounded corners | +| Call overlay | `ActiveCallOverlay` with call controls (hidden when PiP is active for video calls) | +| Screen keep-on | `AppDelegate.keepScreenOn(true)` prevents screen dimming during calls | + +### Call Controls (`ActiveCallOverlay`) + +Bottom bar of the active call: + +| Control | Description | +|---|---| +| Mute toggle | Microphone on/off | +| Speaker toggle | Speaker/receiver switch | +| Camera switch | Front/back camera toggle (video calls) | +| Video toggle | Enable/disable video during call | +| End call | Red phone-down button to terminate | +| Audio device picker | `AudioDevicePicker` / `CallAudioDeviceManager` for selecting output (receiver, speaker, Bluetooth, AirPods) | + +### Picture-in-Picture (PiP) + +- When `pipShown == true` and call has video, the call overlay is hidden +- PiP window shows the remote video feed +- User can interact with the app normally while call continues + +### CallKit Integration + +Managed by `CallController`: + +| Feature | Description | +|---|---| +| Native incoming call UI | iOS system call screen for incoming calls (when CallKit is enabled) | +| Call history | Optionally shown in Phone app recents (`DEFAULT_CALL_KIT_CALLS_IN_RECENTS`) | +| System audio routing | CallKit manages audio session configuration | +| Lock screen answering | Call can be answered from lock screen via system UI | + +When CallKit is not used, the app falls back to `IncomingCallView` banner. + +### WebRTC Client + +| Component | Description | +|---|---| +| `WebRTCClient` | Manages peer connection, ICE candidates, media tracks | +| `WebRTC.swift` | Bridge between native code and WebRTC JavaScript via `WKWebView` | +| `CallViewRenderers` | `CallViewLocal` and `CallViewRemote` SwiftUI wrappers for video renderers | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Permissions required | Prompts for microphone (and camera for video) permissions on first call | +| Connecting | Call overlay shows connecting state; `SoundPlayer` plays connecting tone | +| WebRTC client creation | `createWebRTCClient()` called on appear and when `canConnectCall` changes | +| Call ended | `CallSoundsPlayer.vibrate(long: true)` on disconnect if was connected; audio session reset to `.soloAmbient` | +| Call failed | Call dismissed; WebRTC client cleaned up | +| No call invitation | `IncomingCallView` body is empty when no active invitation | + +## Audio Session Management + +- During call: Audio session configured for voice chat +- Camera permissions: `AVFoundation.AVCaptureDevice` authorization checked +- Audio device management: `CallAudioDeviceManager` handles routing changes and device enumeration +- Post-call cleanup: Audio session reverted to `.soloAmbient` + +## Related Specs + +- `spec/services/calls.md` -- Call service specification +- [Chat](chat.md) -- Call buttons in chat navigation bar +- [Contact Info](contact-info.md) -- Call buttons in contact info action row +- [Settings](settings.md) -- Call settings (CallKit, ICE servers, relay policy) + +## Source Files + +- `Shared/Views/Call/ActiveCallView.swift` -- Main active call view with video renderers and overlay +- `Shared/Views/Call/IncomingCallView.swift` -- Incoming call notification banner +- `Shared/Views/Call/CallController.swift` -- CallKit integration and call lifecycle management +- `Shared/Views/Call/CallManager.swift` -- Call state management and CXProvider delegate +- `Shared/Views/Call/CallAudioDeviceManager.swift` -- Audio device enumeration and routing +- `Shared/Views/Call/AudioDevicePicker.swift` -- Audio output device picker UI +- `Shared/Views/Call/WebRTC.swift` -- WebRTC signaling bridge via WKWebView +- `Shared/Views/Call/WebRTCClient.swift` -- WebRTC peer connection management +- `Shared/Views/Call/CallViewRenderers.swift` -- SwiftUI wrappers for local and remote video views +- `Shared/Views/Call/SoundPlayer.swift` -- Ringtone and call sound playback diff --git a/apps/ios/product/views/chat-list.md b/apps/ios/product/views/chat-list.md new file mode 100644 index 0000000000..04d19bef9e --- /dev/null +++ b/apps/ios/product/views/chat-list.md @@ -0,0 +1,130 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat app. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ContentView` as the default view when `chatModel.chatId == nil` +- **Navigation stack**: `NavStackCompat` wrapping `chatListView` with destination `chatView` +- **UserPicker sheet**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet, which links to `UserPickerSheetView` sub-sheets (address, preferences, profiles, current profile, use from desktop, settings) + +## Page Sections + +### Toolbar + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop) | +| Connection status indicator | Center (`SubsStatusIndicator`) | Shows server subscription status; taps navigate to `ServersSummaryView` | +| New chat button (pencil icon) | Trailing | Opens `NewChatSheet` modal | + +The toolbar supports two layout modes: +- **Standard (top)**: Navigation bar with `.topBarLeading`, `.principal`, `.topBarTrailing` placements +- **One-hand UI (bottom)**: Toolbar items placed in `.bottomBar` with the list vertically flipped via `scaleEffect(y: -1)` + +### Search Bar + +- Text field with magnifying glass icon +- When active, `searchMode = true` hides the navigation bar and shows inline search +- Filters chat list in real-time by contact/group name and message content +- Detects pasted SimpleX links (`searchShowingSimplexLink`) and offers to connect + +### Chat Filter Tabs (Tags) + +Managed by `ChatTagsModel` and `TagListView`: + +| Filter | PresetTag | Description | +|---|---|---| +| All | (none) | No filter, shows all chats | +| Unread | `.unread` | Chats with unread messages | +| Favorites | `.favorites` | User-favorited chats | +| Groups | `.groups` | Group conversations only | +| Contacts | `.contacts` | Direct contacts only | +| Business | `.business` | Business chat conversations | +| Notes | `.notes` | Notes to self | +| Group Reports | `.groupReports` | Moderation reports (non-collapsible) | +| Custom tags | `.userTag(ChatTag)` | User-created tags with custom names | + +### Chat Preview Rows + +Each row rendered by `ChatPreviewView` inside `ChatListNavLink`: + +| Element | Description | +|---|---| +| Avatar | Profile image or colored initials circle; online status indicator for contacts | +| Chat name | Display name (contact, group, or note-to-self) | +| Last message preview | Truncated text of most recent message; supports markdown rendering | +| Timestamp | Relative time of last activity (e.g., "2m", "1h", "Yesterday") | +| Unread badge | Numeric count badge for unread messages; distinct styling for mentions | +| Muted indicator | Bell-slash icon when notifications are muted | +| Pinned indicator | Pin icon for pinned chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +### Channel Adaptations + +When a group has `groupInfo.useRelays == true` (channel): + +| Element | Channel behavior | +|---|---| +| Chat icon | Antenna icon (`antenna.radiowaves.left.and.right.circle.fill`) instead of group icon | +| Swipe "Leave" | Hidden for channel owners (`useRelays && isOwner`) | +| Context menu "Leave" | Hidden for channel owners | +| Delete alert | "Delete channel?" (not "Delete group?") | +| Leave alert title | "Leave channel?" (not "Leave group?") | +| Leave alert message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### Relay URL Handling + +When a relay address link (`/r` path) is opened via URL deep link, `ContentView.connectViaUrl_()` intercepts it and shows an alert: "Relay address" / "This is a chat relay address, it cannot be used to connect." The link is not processed further. + +### Swipe Actions + +- **Trailing swipe**: Mute/unmute, pin/unpin, tag management +- **Leading swipe**: Mark as read/unread +- **Context menu** (long press): Full set of actions including delete, clear chat, toggle favorite + +### Floating Elements + +- **One-hand UI card** (`OneHandUICard`): Dismissible card shown to introduce bottom toolbar mode +- **Address creation card** (`AddressCreationCard`): Prompts user to create a SimpleX address + +### Pull-to-Refresh + +Triggers `reconnectAllServers()` after user confirmation alert ("Reconnect servers?"). Uses additional traffic to force message delivery. + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat database not started | Settings row shows exclamation icon; chat running == false disables interactions | +| No chats | `ChatHelp` view displayed with onboarding guidance | +| Connection in progress | `ConnectProgressManager` overlay with connecting text | +| Search with no results | Empty list with no special empty-state view | + +## Related Specs + +- `spec/client/chat-list.md` -- Chat list feature specification +- `spec/state.md` -- Application state management +- [User Profiles](user-profiles.md) -- Profile switching from UserPicker +- [Settings](settings.md) -- Settings accessed via UserPicker +- [New Chat](new-chat.md) -- New chat sheet triggered from toolbar +- [Chat](chat.md) -- Navigated to when tapping a chat row + +## Source Files + +- `Shared/Views/ChatList/ChatListView.swift` -- Main view, toolbar, search, filter logic +- `Shared/Views/ChatList/ChatPreviewView.swift` -- Individual chat row rendering +- `Shared/Views/ChatList/ChatListNavLink.swift` -- Navigation link wrapper with swipe actions +- `Shared/Views/ChatList/TagListView.swift` -- Filter tab bar (preset + custom tags) +- `Shared/Views/ChatList/UserPicker.swift` -- User profile picker sheet +- `Shared/Views/ChatList/ChatHelp.swift` -- Empty-state help view +- `Shared/Views/ChatList/ContactRequestView.swift` -- Contact request row rendering +- `Shared/Views/ChatList/ContactConnectionView.swift` -- Pending connection row rendering +- `Shared/Views/ChatList/OneHandUICard.swift` -- One-hand UI introduction card +- `Shared/Views/ChatList/ServersSummaryView.swift` -- Server subscription summary diff --git a/apps/ios/product/views/chat.md b/apps/ios/product/views/chat.md new file mode 100644 index 0000000000..bf84bf4feb --- /dev/null +++ b/apps/ios/product/views/chat.md @@ -0,0 +1,174 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) | [spec/client/compose.md](../../spec/client/compose.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` +- **Presented by**: `NavStackCompat` destination from `ChatListView`, bound to `chatModel.chatId` +- **Back navigation**: Dismiss sets `chatModel.chatId = nil`, returning to chat list +- **Sub-navigation**: Info button navigates to `ChatInfoView` (contact) or `GroupChatInfoView` (group); member avatars navigate to `GroupMemberInfoView` + +## Page Sections + +### Navigation Bar + +Custom toolbar overlaying the chat with themed material background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list | +| Contact/Group avatar | Small profile image | +| Chat name | Display name; tappable to open info sheet | +| Encryption badge | Shows PQ (post-quantum) or standard E2E status | +| Call buttons | Audio and video call icons (direct chats only) | +| Search button | Toggles in-chat message search | +| Info button | Opens `ChatInfoView` or `GroupChatInfoView` | + +### Message List + +Rendered by `EndlessScrollView` with lazy loading and pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | Loads more items on scroll to top (`loadingTopItems`) and bottom (`loadingBottomItems`) | +| Merged items | Adjacent messages from the same sender are visually merged via `MergedItems` | +| Floating buttons | Scroll-to-bottom button with unread count; scroll-to-first-unread button | +| Date separators | Sticky date headers between messages from different days | +| Wallpaper | Themed background image with tint and opacity from `theme.wallpaper` | +| Content filter | Filter messages by type: `.images`, `.files`, `.links` | + +### Message Types + +Each type has a dedicated view in `Shared/Views/Chat/ChatItem/`: + +| Type | View | Description | +|---|---|---| +| Text | `MsgContentView` | Rendered with markdown (bold, italic, code, links, mentions) | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `FullScreenMediaView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback | +| Voice | `CIVoiceView` / `FramedCIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions | +| Link preview | `CILinkView` | URL preview card with title, description, image | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message | +| Forward | Opens `forwardedChatItems` sheet to pick destination chat | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode in compose bar (own messages, within edit window) | +| Delete | Delete for self or delete for everyone (with confirmation) | +| React | Opens emoji reaction picker | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward | +| Info | Shows delivery status and timestamps | + +Emoji reactions bar displayed below messages with reaction counts. + +### Compose Bar (`ComposeView`) + +| Element | Description | +|---|---| +| Text input | `NativeTextEditor` with markdown support and auto-growing height | +| Attachment button | Opens picker for images, videos, files, camera | +| Send button | Sends composed message; changes to voice record button when empty | +| Voice record | Hold-to-record with waveform preview; swipe-to-cancel | +| Reply quote | Shows quoted message above input when replying | +| Edit indicator | Shows "editing" label when editing a previous message | +| Link preview | Auto-generated preview card for detected URLs (`ComposeLinkView`) | +| Image/Video preview | Thumbnail strip for selected media (`ComposeImageView`) | +| File preview | File name and size for attached file (`ComposeFileView`) | +| Voice preview | Waveform of recorded voice message (`ComposeVoiceView`) | +| Live message | Real-time typing broadcast (optional, with alert on first use) | +| Context actions | `ContextContactRequestActionsView` for accepting/rejecting contact requests; `ContextPendingMemberActionsView` for pending group member actions | +| Commands menu | `CommandsMenuView` for bot/menu commands in chats with `menuCommands` | +| Group mentions | `GroupMentionsView` autocomplete popup when typing `@` in groups | +| Profile picker | `ContextProfilePickerView` for choosing incognito/main profile | + +### Channel Messages + +In channel conversations (`groupInfo.useRelays == true`), received messages (`.channelRcv` direction) display with: +- The **channel icon** (`antenna.radiowaves.left.and.right`) instead of the standard group icon +- The **channel name** as sender, with "channel" as the role label +- The **group profile image** as the avatar (tapping opens group info, not member info) +- Consecutive channel messages are grouped without repeating the avatar +- Channel messages cannot be moderated per-member (no member identity) + +### Member Support Chat (Groups) + +For groups with member support enabled: +- `MemberSupportView` and `MemberSupportChatToolbar` shown as secondary chat within group +- `SecondaryChatView` for scoped group chat views (reports, member support) +- User knocking state: `userMemberKnockingTitleBar()` shown when user is pending admission + +## Loading / Error States + +| State | Behavior | +|---|---| +| Initial load | Messages load from `ItemsModel` with merged items; `allowLoadMoreItems` throttles pagination | +| Loading more (top) | `loadingTopItems` spinner at top of scroll view | +| Loading more (bottom) | `loadingBottomItems` spinner at bottom | +| Connection in progress | `ConnectProgressManager` shows connecting text below compose bar | +| Connecting text | "connecting..." label shown below message list when chat not yet ready | +| Send disabled | Compose bar shows `disabledText` reason when `userCantSendReason` is set | +| Empty chat | No messages placeholder (implicit -- empty scroll view) | + +## Related Specs + +- `spec/client/chat-view.md` -- Chat view feature specification +- `spec/client/compose.md` -- Compose bar specification +- [Chat List](chat-list.md) -- Parent navigation +- [Contact Info](contact-info.md) -- Info sheet for direct chats +- [Group Info](group-info.md) -- Info sheet for group chats +- [Call](call.md) -- Audio/video calls initiated from toolbar + +## Source Files + +- `Shared/Views/Chat/ChatView.swift` -- Main chat view, message list, navigation, state management +- `Shared/Views/Chat/ChatItemView.swift` -- Individual message item rendering dispatcher +- `Shared/Views/Chat/ComposeMessage/ComposeView.swift` -- Compose bar container +- `Shared/Views/Chat/ComposeMessage/SendMessageView.swift` -- Send button and voice record +- `Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift` -- Text input with markdown +- `Shared/Views/Chat/ComposeMessage/ComposeImageView.swift` -- Image attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeFileView.swift` -- File attachment preview +- `Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift` -- Voice recording preview +- `Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift` -- Link preview generation +- `Shared/Views/Chat/ComposeMessage/ContextItemView.swift` -- Reply/edit context display +- `Shared/Views/Chat/ComposeMessage/ContextContactRequestActionsView.swift` -- Contact request accept/reject +- `Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift` -- Pending member actions +- `Shared/Views/Chat/ComposeMessage/ContextProfilePickerView.swift` -- Profile picker for incognito +- `Shared/Views/Chat/ChatItem/FramedItemView.swift` -- Framed message bubble rendering +- `Shared/Views/Chat/ChatItem/MsgContentView.swift` -- Text message content with markdown +- `Shared/Views/Chat/ChatItem/CIImageView.swift` -- Image message view +- `Shared/Views/Chat/ChatItem/CIVideoView.swift` -- Video message view +- `Shared/Views/Chat/ChatItem/CIVoiceView.swift` -- Voice message view +- `Shared/Views/Chat/ChatItem/CIFileView.swift` -- File message view +- `Shared/Views/Chat/ChatItem/CILinkView.swift` -- Link preview view +- `Shared/Views/Chat/ChatItem/EmojiItemView.swift` -- Large emoji view +- `Shared/Views/Chat/ChatItem/CICallItemView.swift` -- Call event view +- `Shared/Views/Chat/ChatItem/CIEventView.swift` -- Group/system event view +- `Shared/Views/Chat/ChatItem/CIChatFeatureView.swift` -- Feature change notification +- `Shared/Views/Chat/ChatItem/CIMetaView.swift` -- Timestamp and delivery status +- `Shared/Views/Chat/ChatItem/FullScreenMediaView.swift` -- Fullscreen image/video viewer +- `Shared/Views/Chat/ChatItem/AnimatedImageView.swift` -- Animated GIF rendering +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention autocomplete +- `Shared/Views/Chat/Group/MemberSupportView.swift` -- Member support scoped chat +- `Shared/Views/Chat/Group/MemberSupportChatToolbar.swift` -- Support chat toolbar +- `Shared/Views/Chat/Group/SecondaryChatView.swift` -- Secondary scoped chat view diff --git a/apps/ios/product/views/contact-info.md b/apps/ios/product/views/contact-info.md new file mode 100644 index 0000000000..5223bfcae4 --- /dev/null +++ b/apps/ios/product/views/contact-info.md @@ -0,0 +1,154 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings, and perform destructive actions like blocking or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` + - Security code verification -> `VerifyCodeView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar; tappable | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field (`aliasTextFieldFocused`) for setting a local-only name visible only on this device. Not shared with the contact. + +### Action Buttons + +Horizontal row of quick-action buttons (width divided by 4): + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` to search messages in chat | +| Audio call | Initiate audio call (`AudioCallButton`) | +| Video call | Initiate video call (`VideoButton`) | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +Call buttons check `connectionStats` and show alerts if connection state prevents calling. + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito): + +| Element | Description | +|---|---| +| "Your random profile" label | Shows the incognito display name used for this contact | + +### Connection Settings Section + +| Element | Condition | Description | +|---|---|---| +| Verify security code | `connectionCode` available | Navigate to `VerifyCodeView` for QR-based code verification | +| Contact preferences | Always | Navigate to `ContactPreferencesView` | +| Send receipts | Always | Toggle: yes / no / default(yes) / default(no) | +| Synchronize connection | `ratchetSyncAllowed` | Fix encryption ratchet desynchronization | +| Chat theme | Always | Navigate to `ChatWallpaperEditorSheet` | + +All items disabled when `!contact.ready || !contact.active`. + +### Chat TTL Section + +| Element | Description | +|---|---| +| Chat TTL option | `ChatTTLOption` -- auto-delete timer for messages on this device | + +Footer: "Delete chat messages from your device." + +### Encryption Info Section + +Shown when `contact.activeConn` exists: + +| Element | Description | +|---|---| +| E2E encryption | "Quantum resistant" (PQ enabled) or "Standard" | + +### Contact Address Section + +Shown when `contact.contactLink` exists: + +| Element | Description | +|---|---| +| QR code | `SimpleXLinkQRCode` displaying the contact's address | +| Share address | Share button for the contact's SimpleX address link | + +Footer: "You can share this address with your contacts to let them connect with **[name]**." + +### Servers Section + +Shown when `contact.ready && contact.active`: + +| Element | Description | +|---|---| +| Subscription status | `SubStatusRow` showing connection health; tappable for details | +| Change receiving address | Button to switch SMP receiving queue (disabled during switch) | +| Abort changing address | Button to cancel in-progress address switch | +| Receiving via | SMP server hostnames for receiving queues | +| Sending via | SMP server hostnames for sending queues | + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Delete all messages locally (confirmation alert) | +| Delete contact | Remove contact entirely (confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal local display name | +| Database ID | API entity ID | +| Debug delivery | Button to fetch queue info via `apiContactQueueInfo` | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading connection info | `apiContactInfo` and `apiGetContactCode` called on appear; stats and code populated asynchronously | +| Progress indicator | `ProgressView` overlay during TTL changes | +| Contact not ready | Settings section disabled with reduced opacity | +| Contact inactive | Settings section disabled | +| Errors | Alert with localized error title and message | + +## Alerts + +| Alert | Trigger | +|---|---| +| `clearChatAlert` | Tap clear chat | +| `subStatusAlert` | Tap subscription status row | +| `switchAddressAlert` | Tap change receiving address | +| `abortSwitchAddressAlert` | Tap abort address change | +| `syncConnectionForceAlert` | Force ratchet sync | +| `queueInfo` | Debug delivery results | +| `someAlert` | Various sub-component alerts | + +## Related Specs + +- `spec/api.md` -- Contact API commands (info, code verification, preferences, delete) +- [Chat](chat.md) -- Parent chat view +- [Group Info](group-info.md) -- Similar pattern for group info + +## Source Files + +- `Shared/Views/Chat/ChatInfoView.swift` -- Main contact info view with all sections +- `Shared/Views/Chat/ContactPreferencesView.swift` -- Per-contact feature preferences (timed messages, reactions, voice, calls, file transfer, full delete) +- `Shared/Views/Chat/VerifyCodeView.swift` -- Security code verification via QR scan or visual comparison diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md new file mode 100644 index 0000000000..ee0c449c68 --- /dev/null +++ b/apps/ios/product/views/group-info.md @@ -0,0 +1,244 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `NavigationView` sheet from `ChatView` via `showChatInfoSheet` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` + - Add members -> `AddGroupMembersView` + - Group link -> `GroupLinkView` + - Group preferences -> `GroupPreferencesView` (via `GroupPreferencesButton`) + - Welcome message -> `GroupWelcomeView` + - Member info -> `GroupMemberInfoView` + - Chat wallpaper -> `ChatWallpaperEditorSheet` + - Member support -> `MemberSupportView` + - Group reports -> `GroupReportsChatNavLink` + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners) | +| Member count | "N members" label | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Focused via `aliasTextFieldFocused`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearch` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode (`nextNtfMode`) | + +### Group Management Section + +| Element | Condition | Description | +|---|---|---| +| Group link | `canAddMembers` and not business chat | Navigate to `GroupLinkView` to create/manage invitation link | +| Member support | Not business chat, role >= moderator | Navigate to member support chat view | +| Group reports | `canModerate` | Navigate to group reports chat | +| User support chat | Member active, role < moderator or has support chat | Navigate to own support chat with moderators | + +### Group Profile Section + +| Element | Condition | Description | +|---|---|---| +| Edit group | Owner, not business chat | Navigate to `GroupProfileView` for editing name, image, description | +| Welcome message | Has description or is owner (not business) | Navigate to `GroupWelcomeView` for add/edit | +| Group preferences | Always | Navigate to `GroupPreferencesView` -- timed messages, reactions, voice, files, direct messages, history visibility | + +Footer: "Only group owners can change group preferences." (or "Only chat owners can change preferences." for business chats) + +### Chat Settings Section + +| Element | Description | +|---|---| +| Send receipts | Toggle delivery receipts; disabled for groups > 20 current members with explanation | +| Chat theme | Navigate to `ChatWallpaperEditorSheet` | +| Chat TTL | `ChatTTLOption` -- set auto-deletion timer for messages on device | + +Footer: "Delete chat messages from your device." + +### Member List Section + +Header shows total member count (e.g., "25 members"). + +| Element | Description | +|---|---| +| Invite members button | Shown if `canAddMembers`; disabled with tap alert if incognito | +| Search field | Filter members by name (`searchText`) | +| Member rows | Each shows: avatar, display name, role badge (owner/admin/moderator/observer), online status indicator, connection status | +| Member tap | Navigates to `GroupMemberInfoView` | +| Member swipe actions | Block/unblock member, block/unblock for all (moderators) | + +Member list is sorted by role (owners first) and filtered to exclude `memLeft` and `memRemoved` statuses. + +### Danger Zone Section + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages locally (with confirmation alert) | +| Leave group | Leave the group (with confirmation alert) | +| Delete group | Delete entire group -- only for owners (with confirmation alert) | + +### Developer Section + +Shown when `developerTools` is enabled: + +| Element | Description | +|---|---| +| Local name | Internal chat local display name | +| Database ID | API entity ID | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Loading members | Member list populated from `chatModel.groupMembers` | +| Progress indicator | `ProgressView` overlay when `progressIndicator` is true (during TTL changes) | +| Large group receipts | Receipts option disabled with "Disabled for large groups" label and info alert | +| Incognito invite blocked | Alert: "Can't invite contacts when incognito" | +| Errors | Alert with localized title and error description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteGroupAlert` | Tap delete group | +| `clearChatAlert` | Tap clear chat | +| `leaveGroupAlert` | Tap leave group | +| `cantInviteIncognitoAlert` | Tap invite members while incognito | +| `largeGroupReceiptsDisabled` | Tap receipts info on large group | +| `blockMemberAlert` / `unblockMemberAlert` | Block/unblock member actions | +| `blockForAllAlert` / `unblockForAllAlert` | Moderator block/unblock for all members | + +## Channel Adaptations + +When `groupInfo.useRelays == true`, the group info view adapts to channel semantics. All sections below describe differences from the standard group behavior above. + +### Channel Info Layout + +The top section splits into a channel-specific branch: + +| Element | Owner | Non-owner | +|---|---|---| +| Channel link | NavigationLink "Channel link" to `GroupLinkView` | Inline QR code (`SimpleXLinkQRCode`) + "Share link" button (if `groupProfile.publicGroup?.groupLink` exists) | +| Members | NavigationLink "Owners & subscribers" to `ChannelMembersView` | NavigationLink "Owners" to `ChannelMembersView` | +| Relays | NavigationLink "Chat relays" to `ChannelRelaysView` | NavigationLink "Chat relays" to `ChannelRelaysView` | + +### Channel Action Bar + +| Button | Channel behavior | +|---|---| +| Link button | Replaces "Add members" for channel owners; navigates to `GroupLinkView` | +| Add members | Hidden for channels | + +### Hidden Sections for Channels + +The following are hidden when `groupInfo.useRelays == true`: + +- Group preferences button and footer +- Send receipts toggle +- Member list section (replaced by ChannelMembersView navigation) +- Non-admin block section (in GroupMemberInfoView) + +### Channel Leave/Delete Rules + +- Sole channel owner cannot leave (button hidden when `isOwner && no other owners`) +- "Leave group" -> "Leave channel"; "Delete group" -> "Delete channel"; "Edit group profile" -> "Edit channel profile" +- `deleteGroupAlert`: "Delete channel?" / "Channel will be deleted for all subscribers - this cannot be undone!" (current member) or "Channel will be deleted for you - this cannot be undone!" (non-current member) +- `leaveGroupAlert`: "Leave channel?" / "You will stop receiving messages from this channel. Chat history will be preserved." +- `showRemoveMemberAlert`: "Remove subscriber?" / "Subscriber will be removed from channel - this cannot be undone!" + +### Channel Members View (`ChannelMembersView`) + +New view accessible from channel info, showing: + +| Section | Content | Visibility | +|---|---|---| +| Owners | Members with role >= `.owner`, plus current user if owner | Always | +| Subscribers | Members with role < `.owner` and != `.relay` | Owner only | + +- Excludes `memLeft`, `memRemoved`, and current user from member list +- Each row: profile image, verified badge, name; taps navigate to `GroupMemberInfoView` +- Empty state: "No subscribers" when subscriber list is empty + +### Channel Relays View (`ChannelRelaysView`) + +New view accessible from channel info, showing relay members (role == `.relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `.relay` role | +| Relay row | Profile image, relay display name, status text (`RelayStatus` or connection status) | +| Relay tap | NavigationLink to `GroupMemberInfoView` with `groupRelay:` parameter | +| Empty state | "No chat relays" | +| Footer | "Chat relays forward messages to channel subscribers." | + +Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection status only. + +### Channel Link View (`GroupLinkView` with `isChannel: true`) + +| Change | Channel behavior | +|---|---| +| Title | "Channel link" (not "Group link") | +| Description | "Anybody will be able to join the channel" (omits "You won't lose members...") | +| Initial role picker | Hidden | +| Upgrade link button | Hidden | +| Delete link button | Hidden (channel link deletion only via channel deletion) | +| Short/full link toggle | Hidden | +| Share button | Shares directly (no upgrade-and-share alert) | + +### Channel Member Info (`GroupMemberInfoView` adaptations) + +| Change | Channel behavior | +|---|---| +| Section header | "Relay" / "Owner" / "Subscriber" (based on member role) instead of "Member" | +| Group label | "Channel" instead of "Group" / "Chat" | +| Action buttons | Hidden (message/audio/video/search) | +| Role change picker | Hidden | +| Verify code button | Hidden for relay members | +| Block section | Hidden for non-moderator users | +| Remove button | Hidden for relay members | +| "Remove member" label | "Remove subscriber" | +| "Block for all?" alert | "Block subscriber for all?" | +| "Unblock for all?" alert | "Unblock subscriber for all?" | +| Relay link info row | Shown when `member.relayLink` exists, displays `hostFromRelayLink(link)` | +| Relay address info row | Shown when `groupRelay?.userChatRelay.address` exists, with "Share relay address" button | +| Relay footer | Owner: "Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel." Non-owner: "You connected to the channel via this relay link." | + +## Related Specs + +- `spec/api.md` -- Group API commands (create, update, add/remove members, roles, links) +- [Chat](chat.md) -- Parent chat view +- [Contact Info](contact-info.md) -- Similar pattern for direct contact info + +## Source Files + +- `Shared/Views/Chat/Group/GroupChatInfoView.swift` -- Main group info view with all sections +- `Shared/Views/Chat/Group/GroupProfileView.swift` -- Edit group name, image, description +- `Shared/Views/Chat/Group/AddGroupMembersView.swift` -- Member invitation view +- `Shared/Views/Chat/Group/GroupLinkView.swift` -- Group link creation and management +- `Shared/Views/Chat/Group/GroupPreferencesView.swift` -- Group feature preferences +- `Shared/Views/Chat/Group/GroupWelcomeView.swift` -- Welcome message editor +- `Shared/Views/Chat/Group/MemberAdmissionView.swift` -- Member admission policy settings +- `Shared/Views/Chat/Group/GroupMemberInfoView.swift` -- Individual member info and actions +- `Shared/Views/Chat/Group/GroupMentions.swift` -- @mention support in groups +- `Shared/Views/Chat/Group/ChannelMembersView.swift` -- Channel owners/subscribers list +- `Shared/Views/Chat/Group/ChannelRelaysView.swift` -- Channel relay status list diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md new file mode 100644 index 0000000000..2ab5f9ba8f --- /dev/null +++ b/apps/ios/product/views/new-chat.md @@ -0,0 +1,144 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary onramp for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar +- **Presented by**: `NewChatSheet` modal from `ChatListView` +- **Internal navigation**: `NewChatMenuButton` provides a dropdown with options: + - "New chat" -- opens `NewChatView` + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: Segmented picker toggles between `.invite` (1-time link) and `.connect` (connect via link) +- **Swipe gesture**: Left/right swipe switches between invite and connect tabs +- **Dismiss behavior**: On dismiss, `showKeepInvitationAlert()` asks whether to keep an unused invitation link or delete it + +## Page Sections + +### Segmented Picker + +| Tab | Icon | Description | +|---|---|---| +| 1-time link | `link` | Generate and share a one-time invitation link | +| Connect via link | `qrcode` | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) + +Displayed when `selection == .invite`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share sheet for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `creatingLinkProgressView` spinner while `creatingConnReq` is true | +| Retry button | Shown if link creation fails | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. + +### Connect Tab (Connect via Link) + +Displayed when `selection == .connect`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based `CodeScanner` view for scanning SimpleX QR codes | +| Paste link field | Text input for pasting a SimpleX link manually | +| Connect button | Initiates connection via the pasted/scanned link | + +Handled by `ConnectView` sub-view with `showQRCodeScanner` state. + +### Info Sheet + +Toolbar trailing button opens `AddContactLearnMore` info sheet explaining how SimpleX connections work. + +### Add Group + +Accessed via `NewChatMenuButton` dropdown: + +| Element | Description | +|---|---| +| Group name | Required text field | +| Group image | Optional profile image picker | +| Incognito option | Create group with random profile | +| Create button | Creates group via API and navigates to group chat | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Creating invitation | `ProgressView` spinner shown; buttons disabled | +| Link creation failure | Retry button displayed | +| Invalid link pasted | Alert shown via `NewChatViewAlert.newChatSomeAlert` | +| Connection in progress | Chat list shows pending connection entry | +| Unused invitation on dismiss | Alert: "Keep unused invitation?" with Keep/Delete options | + +## Create Channel (`AddChannelView`) + +Accessed via `NewChatMenuButton` dropdown: "Create channel (BETA)" with antenna icon (`antenna.radiowaves.left.and.right.circle.fill`). + +### Three-Step Channel Creation Wizard + +| Step | View | Description | +|---|---|---| +| 1. Profile | `profileStepView()` | Channel name input with validation, optional profile image. "Configure relays" link navigates to `NetworkAndServers`. Warning footer if no relays enabled. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress: circular indicator (active/total), expandable relay list with status indicators (green=active, orange=invited/accepted, red=new). Cancel button deletes channel. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` showing the channel link for sharing. | + +### Channel Creation Defaults + +- History preference auto-enabled (`GroupPreference(enable: .on)`) +- Group link member role hardcoded to `.observer` +- Up to 3 random enabled relays selected from user's configured relays + +### Channel Creation API + +Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)` which returns `publicGroupCreated` response with group info, link, and relay list. On cancel, `apiDeleteChat` deletes the channel. + +### Relay Validation + +- `checkHasRelays()`: validates at least one enabled, non-deleted relay exists +- Warning footer: "Enable at least one chat relay in Network & Servers." +- `getEnabledRelays()`: filters enabled/non-deleted relays from user's server config + +## Channel-Specific Connection Behavior + +### Relay Link Blocking + +When `planAndConnect` encounters a `.simplexLink(_, .relay, _, _)`, it shows a "Relay address" alert: "This is a chat relay address, it cannot be used to connect." Connection is blocked. + +### Channel Prepare/Join Alerts + +| Context | Channel behavior | Group behavior | +|---|---|---| +| Prepare alert icon | `antenna.radiowaves.left.and.right.circle.fill` | `person.2.circle.fill` | +| Prepare alert title | "Open new channel" | "Open new group" | +| Error text | "Error opening channel" | "Error opening group" | +| Own-link confirm | "This is your link for channel" with only "Open channel" + "Cancel" (no incognito/profile options) | Full incognito/profile selection | +| Known group alert | "Open channel" / "Open new channel" | "Open group" / "Open new group" | + +### Pre-Join Relay Info + +When preparing a channel link, `groupShortLinkInfo.groupRelays` (hostnames) are stored in `ChatModel.shared.channelRelayHostnames` for display in the subscriber relay bar before joining. + +## Related Specs + +- `spec/api.md` -- API commands: `APIAddContact`, `APIConnect`, `APICreateUserAddress` +- `spec/client/navigation.md` -- Navigation architecture for channel creation flow +- [Chat List](chat-list.md) -- Parent view that presents this sheet +- [Chat](chat.md) -- Navigated to after successful connection + +## Source Files + +- `Shared/Views/NewChat/NewChatView.swift` -- Main view with invite/connect tabs, link generation +- `Shared/Views/NewChat/NewChatMenuButton.swift` -- Dropdown menu (new chat, create group, create channel) +- `Shared/Views/NewChat/QRCode.swift` -- QR code generation and display +- `Shared/Views/NewChat/AddGroupView.swift` -- Group creation form +- `Shared/Views/NewChat/AddChannelView.swift` -- Channel creation wizard (3 steps) +- `Shared/Views/NewChat/AddContactLearnMore.swift` -- Info sheet explaining connection process diff --git a/apps/ios/product/views/onboarding.md b/apps/ios/product/views/onboarding.md new file mode 100644 index 0000000000..a283c25a19 --- /dev/null +++ b/apps/ios/product/views/onboarding.md @@ -0,0 +1,147 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, server operator conditions acceptance, and notification configuration. Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStageDefault` is not `.onboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression; back navigation hidden on later steps (`.navigationBarBackButtonHidden(true)`) +- **Completion**: Sets `onboardingStageDefault` to `.onboardingComplete` and updates `chatModel.onboardingStage` + +## Onboarding Steps + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | SimpleX Chat logo (light/dark variant based on color scheme) | +| "The future of messaging" | Info button opening `HowItWorks` sheet | +| Privacy redefined | "No user identifiers." with privacy icon | +| Immune to spam | "You decide who can connect." with shield icon | +| Decentralized | "Anybody can host servers." with decentralized icon | +| **Create your profile** button | Primary action; navigates to `CreateFirstProfile` | +| **Migrate from another device** button | Secondary action; opens `MigrateToDevice` sheet | + +The "How it works" sheet (`HowItWorks`) explains SimpleX's privacy model with an option to proceed to profile creation. + +### Step 2: Create Profile (`CreateFirstProfile`) + +**Stage**: `step2_CreateProfile` (deprecated -- now part of step 1 flow) + +| Element | Description | +|---|---| +| Display name field | Required; auto-focused after 1 second delay | +| Validation | `mkValidName` check; alerts for invalid/duplicate names | +| Create button | Calls profile creation API; advances to next step | + +Profile is stored locally and only shared with contacts. Footer explains this privacy property. + +### Step 3: Server Operator Conditions (`OnboardingConditionsView`) + +**Stage**: `step3_ChooseServerOperators` (changed to simplified conditions view) + +| Element | Description | +|---|---| +| "Conditions of use" title | Large title header | +| Privacy explanation | "Private chats, groups and your contacts are not accessible to server operators." | +| Operator selection | Toggle operators (with `selectedOperatorIds`) | +| Show conditions | Sheet to view full conditions (`ConditionsWebView`) | +| Configure operators | Sheet to customize operator settings | +| **Accept** button | Accepts conditions and advances to notifications step | + +Previous deprecated step `step3_CreateSimpleXAddress` (`CreateSimpleXAddress`) is no longer in the active flow. + +### Step 4: Set Notification Mode (`SetNotificationsMode`) + +**Stage**: `step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| "Push notifications" title | Large title header | +| Info text | Explanation of notification modes | +| Mode selector | `NtfModeSelector` for each `NotificationsMode.values` | +| **Enable notifications** / **Use chat** button | Sets notification mode and completes onboarding | +| Info sheet | `NotificationsInfoView` accessible for detailed explanation | + +Notification modes: + +| Mode | Description | +|---|---| +| Instant | Background connection maintained; real-time notifications | +| Periodic | Checks every 10 minutes; battery-friendly | +| Off | No push notifications; messages received only when app is open | + +On completion, `onboardingStageDefault.set(.onboardingComplete)` is called. + +### Completion + +**Stage**: `onboardingComplete` + +`OnboardingView` renders `EmptyView()` and the app proceeds to `ChatListView`. + +## Optional Paths + +### Migrate from Another Device + +- Triggered from Step 1 via "Migrate from another device" button +- Sets `chatModel.migrationState = .pasteOrScanLink` +- Opens `MigrateToDevice` in a sheet within `NavigationView` +- User pastes or scans a migration link from the source device +- Imports database and settings from the linked device + +### What's New (`WhatsNewView`) + +- Not part of the linear onboarding flow +- Shown when `DEFAULT_WHATS_NEW_VERSION` differs from current version +- Accessible later from Settings > Help > What's new +- Displays changelog with feature descriptions + +## Onboarding Stage Enum + +``` +enum OnboardingStage: String { + case step1_SimpleXInfo + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // conditions acceptance + case step4_SetNotificationsMode + case onboardingComplete +} +``` + +Persisted via `DEFAULT_ONBOARDING_STAGE` in `UserDefaults`. + +## Loading / Error States + +| State | Behavior | +|---|---| +| No device token | Alert "No device token!" if trying to set notification mode without token | +| Profile creation error | Alert with error description | +| Migration failure | Error handling within `MigrateToDevice` flow | +| Conditions loading | Async fetch of operator conditions | + +## Related Specs + +- `spec/architecture.md` -- App architecture and initialization flow +- [Chat List](chat-list.md) -- Destination after onboarding completes +- [User Profiles](user-profiles.md) -- Profile created during onboarding; additional profiles later +- [Settings](settings.md) -- Notification and server settings revisitable after onboarding + +## Source Files + +- `Shared/Views/Onboarding/OnboardingView.swift` -- Step router and `OnboardingStage` enum definition +- `Shared/Views/Onboarding/SimpleXInfo.swift` -- Step 1: Welcome screen with privacy highlights and migration entry +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared between onboarding and user profiles) +- `Shared/Views/Onboarding/CreateSimpleXAddress.swift` -- Deprecated step 3: SimpleX address creation +- `Shared/Views/Onboarding/ChooseServerOperators.swift` -- Step 3: Server operator conditions and selection +- `Shared/Views/Onboarding/SetNotificationsMode.swift` -- Step 4: Push notification mode selection +- `Shared/Views/Onboarding/HowItWorks.swift` -- "How it works" info sheet from step 1 +- `Shared/Views/Onboarding/WhatsNewView.swift` -- Changelog / what's new display +- `Shared/Views/Onboarding/AddressCreationCard.swift` -- Address creation prompt card diff --git a/apps/ios/product/views/settings.md b/apps/ios/product/views/settings.md new file mode 100644 index 0000000000..3cc4da5d2b --- /dev/null +++ b/apps/ios/product/views/settings.md @@ -0,0 +1,201 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker sheet on the chat list. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option +- **Presented by**: `UserPickerSheetView(sheet: .settings)` wrapping `SettingsView` in a `NavigationView` +- **Navigation title**: "Your settings" +- **Sub-navigation**: Each settings row is a `NavigationLink` to a dedicated settings view + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `bolt` (color varies by token status) | `NotificationsView` | Push notification mode and preview settings | +| Network & servers | `externaldrive.connected.to.line.below` | `NetworkAndServers` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `video` | `CallSettings` | WebRTC relay policy, ICE servers, CallKit options | +| Privacy & security | `lock` | `PrivacySettings` | SimpleX Lock, screen protection, delivery receipts, auto-accept | +| Appearance | `sun.max` | `AppearanceSettings` | Theme, language, wallpapers, chat bubbles, toolbar opacity | + +All rows disabled when `chatModel.chatRunning != true`. Appearance row only shown when `UIApplication.shared.supportsAlternateIcons`. + +#### Notifications (`NotificationsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background connection) / Periodic (every 10 min) / Off | +| Notification preview | Hidden / Contact name only / Message preview | +| Token status indicator | Icon color reflects: new, registered, confirmed (yellow), active (green), expired, invalid | + +#### Network & Servers (`NetworkAndServers`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | +| Show sent via proxy | Toggle to show proxy indicator on sent messages | +| Show subscription % | Toggle to show server subscription percentage | + +Sub-files: `NetworkAndServers.swift`, `ProtocolServersView.swift`, `ProtocolServerView.swift`, `NewServerView.swift`, `ScanProtocolServer.swift`, `AdvancedNetworkSettings.swift`, `OperatorView.swift`, `ConditionsWebView.swift`, `ChatRelayView.swift` + +##### Chat Relays + +Chat relays forward messages to channel subscribers. They appear in two locations: + +- **Operator View** (`OperatorView`): "Chat relays" section lists relays for each operator with `ChatRelayViewLink` rows. Footer: "Chat relays forward messages in channels you create." +- **Your Servers** (`YourServersView` in `ProtocolServersView`): "Chat relays" section for non-operator relays. "Add server" dialog includes a "Chat relay" option. + +Each relay is managed via `ChatRelayView`: + +| Element | Preset relay | Custom relay | +|---|---|---| +| Name | Read-only display | Editable text field | +| Address | Read-only display | Editable text field (validates as `.simplexLink(_, .relay, _, _)`) | +| Test button | "Test relay" (shows "Not implemented" alert) | Same | +| Enable toggle | "Use for new channels" | Same | +| Delete | Not available | "Delete relay" button | + +Adding a relay: `NewChatRelayView` form with name, address, test, and enable toggle. Back-button validates name/address and shows alerts for invalid input. + +##### Server Warnings + +`ServersWarningView` displays an orange exclamation triangle with warning text when `UserServersWarning.noChatRelays` is detected. Appears in: +- Network & Servers footer (`globalServersWarning`) +- Operator view footer +- Your servers footer + +Server validation (`validateServers_`) now returns both errors and warnings. + +#### Privacy & Security (`PrivacySettings`) + +| Setting | Description | +|---|---| +| SimpleX Lock | Enable biometric (Face ID / Touch ID) or passcode lock | +| Lock mode | System biometric or custom passcode | +| Lock timeout | Delay before lock activates (0s to 30min) | +| Self-destruct | Optional self-destruct passcode that wipes all data | +| Screen protection | Hide app content in app switcher | +| Encrypt local files | Encrypt media and files stored on device | +| Auto-accept images | Automatically download received images | +| Link previews | Generate link previews for sent URLs | +| SimpleX link mode | Description / Full link / Via browser | +| Chat previews | Show message previews in chat list | +| Save last draft | Remember unsent message drafts | +| Delivery receipts | Enable/disable read receipts globally | +| Media blur radius | Blur level for received media before tapping | + +#### Appearance (`AppearanceSettings`) + +| Setting | Description | +|---|---| +| App icon | Alternative app icon selection | +| Language | Interface language | +| Theme | System / Light / Dark | +| Dark theme variant | Dark / SimpleX / Black | +| Active theme colors | Accent color, chat bubble colors, text colors | +| Wallpapers | Chat background wallpaper selection and customization | +| Profile image corner radius | Adjust avatar roundness | +| Chat bubble roundness | Adjust message bubble corner radius | +| Chat bubble tail | Toggle message bubble tail/pointer | +| Toolbar opacity | `ToolbarMaterial` transparency setting | +| One-hand UI | Bottom toolbar layout for reachability | + +#### Audio & Video Calls (`CallSettings`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / Allow direct | +| ICE servers | Custom STUN/TURN server configuration | +| CallKit integration | Enable/disable native iOS call UI | +| Calls in recents | Show/hide calls in Phone app history | +| Lock screen calls | Show/accept on lock screen options | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `internaldrive` (orange if unencrypted) | `DatabaseView` | Passphrase management, export/import database, file storage stats | +| Migrate to another device | `tray.and.arrow.up` | `MigrateFromDevice` | Export database and generate migration link | + +Database row shows exclamation octagon icon in red when `chatRunning == false`. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use it | `questionmark` | `ChatHelp` | Usage guide with user's display name | +| What's new | `plus` | `WhatsNewView` | Changelog and new features | +| About SimpleX Chat | `info` | `SimpleXInfo` | About page with privacy explanation | +| Send questions and ideas | `number` | Opens SimpleX team chat link | Direct contact with developers | +| Send us email | `envelope` | `mailto:chat@simplex.chat` | Email link | + +### Support SimpleX Chat Section + +| Row | Icon | Action | +|---|---|---| +| Contribute | `keyboard` | Opens GitHub contribution guide | +| Rate the app | `star` | `SKStoreReviewController.requestReview` | +| Star on GitHub | GitHub icon | Opens GitHub repository | + +### Develop Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Developer tools | `chevron.left.forwardslash.chevron.right` | `DeveloperView` | Chat console/terminal, log level, confirm DB upgrades | +| App version | (none) | `VersionView` | Shows "v{version} ({build})" | + +## Loading / Error States + +| State | Behavior | +|---|---| +| Chat not running | Most navigation links disabled; database row shows warning | +| Database not encrypted | Database icon shown in orange | +| Migration in progress | `showProgress` overlays `ProgressView` on entire settings view | +| Terminal cleanup | On disappear: `chatModel.showingTerminal = false`, terminal items cleared | + +## App Defaults + +Key `UserDefaults` / `AppStorage` keys managed by settings: +- `DEFAULT_PERFORM_LA`, `DEFAULT_LA_MODE`, `DEFAULT_LA_LOCK_DELAY`, `DEFAULT_LA_SELF_DESTRUCT` +- `DEFAULT_PRIVACY_ACCEPT_IMAGES`, `DEFAULT_PRIVACY_LINK_PREVIEWS`, `DEFAULT_PRIVACY_PROTECT_SCREEN` +- `DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS`, `DEFAULT_PRIVACY_SAVE_LAST_DRAFT` +- `DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET`, `DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS` +- `DEFAULT_WEBRTC_POLICY_RELAY`, `DEFAULT_WEBRTC_ICE_SERVERS`, `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` +- `DEFAULT_CURRENT_THEME`, `DEFAULT_SYSTEM_DARK_THEME`, `DEFAULT_THEME_OVERRIDES` +- `DEFAULT_PROFILE_IMAGE_CORNER_RADIUS`, `DEFAULT_CHAT_ITEM_ROUNDNESS`, `DEFAULT_CHAT_ITEM_TAIL` +- `DEFAULT_TOOLBAR_MATERIAL`, `DEFAULT_ONE_HAND_UI_CARD_SHOWN` +- `DEFAULT_DEVELOPER_TOOLS`, `DEFAULT_SHOW_SENT_VIA_RPOXY`, `DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE` + +## Related Specs + +- `spec/architecture.md` -- App architecture overview +- `spec/services/theme.md` -- Theme system specification +- [Chat List](chat-list.md) -- Parent view via UserPicker +- [User Profiles](user-profiles.md) -- Profile management (separate UserPicker option) + +## Source Files + +- `Shared/Views/UserSettings/SettingsView.swift` -- Main settings view, section layout, app defaults definitions +- `Shared/Views/UserSettings/NotificationsView.swift` -- Notification mode and preview settings +- `Shared/Views/UserSettings/AppearanceSettings.swift` -- Theme, wallpaper, UI customization +- `Shared/Views/UserSettings/PrivacySettings.swift` -- Privacy and security settings +- `Shared/Views/UserSettings/NetworkAndServers/NetworkAndServers.swift` -- Server and network configuration +- `Shared/Views/UserSettings/NetworkAndServers/AdvancedNetworkSettings.swift` -- TCP/timeout settings +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServersView.swift` -- SMP/XFTP server list +- `Shared/Views/UserSettings/NetworkAndServers/ProtocolServerView.swift` -- Individual server edit +- `Shared/Views/UserSettings/NetworkAndServers/NewServerView.swift` -- Add new server +- `Shared/Views/UserSettings/NetworkAndServers/ScanProtocolServer.swift` -- Scan server QR code +- `Shared/Views/UserSettings/NetworkAndServers/OperatorView.swift` -- Server operator configuration +- `Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` -- Chat relay detail/edit/add views +- `Shared/Views/UserSettings/NetworkAndServers/ConditionsWebView.swift` -- Operator conditions display diff --git a/apps/ios/product/views/user-profiles.md b/apps/ios/product/views/user-profiles.md new file mode 100644 index 0000000000..5a38db1816 --- /dev/null +++ b/apps/ios/product/views/user-profiles.md @@ -0,0 +1,137 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/state.md](../../spec/state.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password and support a self-destruct password option. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserPickerSheetView(sheet: .chatProfiles)` wrapping `UserProfilesView` in a `NavigationView` +- **Navigation title**: "Your chat profiles" +- **Sub-navigation**: + - Create profile -> `CreateProfile` + - Edit profile -> profile detail view (via `selectedUser`) + - User address -> `UserAddressView` (via UserPicker `.address` sheet) + +## Page Sections + +### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against profile names and hidden profile passwords + +### Profile List + +Each row rendered by `userView()`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark or highlighted state for the current active profile | +| Profile image | Avatar circle with profile image or colored initials | +| Display name | Profile's display name | +| Unread count | Badge showing unread message count across all chats for this profile | +| Muted indicator | Bell-slash icon if profile notifications are muted | +| Hidden indicator | Lock icon for hidden profiles (only shown when revealed via password) | + +### Profile Actions + +Available via tap on a profile row: + +| Action | Condition | Description | +|---|---|---| +| Switch active | Different from current | Activates the selected profile; all chats switch context | +| Mute / Unmute | Any profile | Toggle notification muting for the profile; shows alert on first mute (`showMuteProfileAlert`) | +| Hide / Unhide | Non-active profile | Hide with password or reveal a hidden profile | +| Delete | Non-active profile | Delete with confirmation; option to delete data from servers | + +### Add Profile Button + +| Element | Description | +|---|---| +| "Add profile" label | `Label("Add profile", systemImage: "plus")` | +| Navigation | `NavigationLink` to `CreateProfile` view | +| Auth required | Requires local authentication before creating | + +Only shown when `trimmedSearchTextOrPassword` is empty (not searching/entering password). + +### Hidden Profile Banner + +Shown when `profileHidden` is true (a profile was just hidden): + +| Element | Description | +|---|---| +| Lock icon | `lock.open` system image | +| Message | "Enter password above to show!" | +| Tap action | Dismisses the banner with animation | + +### Create Profile (`CreateProfile`) + +| Field | Description | +|---|---| +| Display name | Required text field with validation (`mkValidName`) | +| Bio | Optional bio text (max 160 bytes) | +| Create button | Disabled until valid name entered and bio within limit | + +Validation alerts: `duplicateUserError`, `invalidDisplayNameError`, `createUserError`, `invalidNameError`. + +## Profile Visibility + +| Visibility | Description | +|---|---| +| Public | Normal profile, always visible in the list | +| Hidden | Protected by password; not shown unless password entered in search field | +| Muted | Notifications suppressed; visual indicator in profile list | + +### Hidden Profile Password Management + +- Set password when hiding a profile +- Password verified when entering in the search/password field +- `UserProfileAction.unhideUser` requires password entry +- Self-destruct password: Optional secondary password (`DEFAULT_LA_SELF_DESTRUCT`) that wipes all app data when entered + +### Delete Profile + +Two-stage confirmation: + +1. `confirmDeleteUser()` shows initial confirmation +2. `UserProfilesAlert.deleteUser(user:, delSMPQueues:)` with option to delete queues from servers +3. Requires local authentication (`withAuth`) before proceeding + +## Loading / Error States + +| State | Behavior | +|---|---| +| Authentication required | `authorized` state; prompts biometric/passcode before profile operations | +| Profile switch | Async operation; profile switch errors shown via `activateUserError` alert | +| Delete in progress | Profile removed from list; server queue deletion is async | +| Errors | Alert with localized error title and description | + +## Alerts + +| Alert | Trigger | +|---|---| +| `deleteUser` | Confirm profile deletion | +| `hiddenProfilesNotice` | First-time hidden profiles explanation (`showHiddenProfilesNotice`) | +| `muteProfileAlert` | First-time mute explanation (`showMuteProfileAlert`) | +| `activateUserError` | Profile switch failure | +| `error` | General error display | + +## Related Specs + +- `spec/api.md` -- User management API commands (create user, delete user, activate user, hide user) +- `spec/state.md` -- Application state: `chatModel.users`, `chatModel.currentUser` +- [Chat List](chat-list.md) -- Reflects active profile's chats +- [Settings](settings.md) -- Accessed from same UserPicker menu +- [Onboarding](onboarding.md) -- Initial profile creation during first launch + +## Source Files + +- `Shared/Views/UserSettings/UserProfilesView.swift` -- Main profiles list, search/password, profile actions, delete confirmation +- `Shared/Views/Onboarding/CreateProfile.swift` -- Profile creation form (shared with onboarding and profiles view) +- `Shared/Views/UserSettings/UserAddressView.swift` -- User's SimpleX address management (create, share, delete) +- `Shared/Views/ChatList/UserPicker.swift` -- Profile switcher sheet that navigates to this view diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index 0826bca4a3..87a47ec2ab 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -164,7 +164,7 @@ "%d file(s) were not downloaded." = "%d файлов не было загружено."; /* time interval */ -"%d hours" = "%d час."; +"%d hours" = "%d ч."; /* alert title */ "%d messages not forwarded" = "%d сообщений не переслано"; @@ -729,7 +729,7 @@ swipe action */ "attempts" = "попытки"; /* No comment provided by engineer. */ -"Audio & video calls" = "Аудио- и видеозвонки"; +"Audio & video calls" = "Аудио и видеозвонки"; /* No comment provided by engineer. */ "Audio and video calls" = "Аудио и видео звонки"; @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Удалить сообщение?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Удалить сообщения"; /* No comment provided by engineer. */ @@ -2238,6 +2239,9 @@ chat item action */ /* alert message */ "Error connecting to forwarding server %@. Please try later." = "Ошибка подключения к пересылающему серверу %@. Попробуйте позже."; +/* subscription status explanation */ +"Error connecting to the server used to receive messages from this connection: %@" = "Ошибка подключения к серверу, используемому для получения сообщений от этого соединения: %@"; + /* No comment provided by engineer. */ "Error creating address" = "Ошибка при создании адреса"; @@ -3305,10 +3309,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль члена будет изменена на \"%@\". Будет отправлено новое приглашение."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Член будет удален из разговора - это действие нельзя отменить!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Член группы будет удален - это действие нельзя отменить!"; /* alert message */ @@ -3710,6 +3714,9 @@ snd error text */ /* servers error */ "No servers to send files." = "Нет серверов для отправки файлов."; +/* No comment provided by engineer. */ +"no subscription" = "нет подписки"; + /* copied message info in history */ "no text" = "нет текста"; @@ -4374,7 +4381,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Relay сервер защищает Ваш IP адрес, но может отслеживать продолжительность звонка."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Удалить"; /* No comment provided by engineer. */ @@ -4389,7 +4396,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Удалить члена группы"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Удалить члена группы?"; /* No comment provided by engineer. */ @@ -5545,7 +5552,7 @@ report reason */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Чтобы показать Ваш скрытый профиль, введите его пароль в поле поиска на странице **Ваши профили чата**."; /* No comment provided by engineer. */ -"To send" = "Для оправки"; +"To send" = "Для отправки"; /* alert message */ "To send commands you must be connected." = "Вы должны быть соединены, чтобы отправлять команды."; @@ -5583,6 +5590,9 @@ report reason */ /* No comment provided by engineer. */ "Transport sessions" = "Транспортные сессии"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Попытка подключиться к серверу, используемому для получения сообщений от этого соединения."; + /* No comment provided by engineer. */ "Turkish interface" = "Турецкий интерфейс"; @@ -6075,9 +6085,15 @@ report reason */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Вы уже вступаете в группу!\nПовторить запрос на вступление?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Вы подключены к серверу, используемому для приема сообщений от этого соединения."; + /* No comment provided by engineer. */ "You are invited to group" = "Вы приглашены в группу"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Вы не подключены к серверу, используемому для получения сообщений по этому соединению (нет подписки)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Вы не подключены к этим серверам. Для доставки сообщений на них используется конфиденциальная доставка."; diff --git a/apps/ios/spec/README.md b/apps/ios/spec/README.md new file mode 100644 index 0000000000..eca6103582 --- /dev/null +++ b/apps/ios/spec/README.md @@ -0,0 +1,74 @@ +# SimpleX Chat iOS -- Specification Overview + +> Technical specification suite for the SimpleX Chat iOS application. Each document provides bidirectional links to product documentation and source code. + +## Executive Summary + +The SimpleX Chat iOS app is a native SwiftUI frontend that communicates with a Haskell core library via C FFI. All chat logic, encryption, protocol handling, and database operations happen in the Haskell core (`chat_ctrl`). The iOS layer handles UI rendering, system integration (CallKit, Push Notifications, Background Tasks), local preferences, and theming. The app shares its database with a Notification Service Extension (NSE) for decrypting push payloads while the main app is inactive. + +## Dependency Graph + +``` +SimpleXApp (root entry point) +├── ChatModel (ObservableObject state) <-> SimpleXAPI (FFI bridge) <-> Haskell Core (chat_ctrl) +├── Views (SwiftUI) +│ ├── ChatListView -> ChatView -> ComposeView +│ ├── ChatItemView (renders individual messages) +│ ├── Settings, UserProfiles, Onboarding +│ └── ActiveCallView (WebRTC + CallKit) +├── Models +│ ├── ChatModel (global app state -- singleton) +│ ├── ItemsModel (per-chat message list state -- singleton + secondary instances) +│ ├── ChatTagsModel (tag filtering state) +│ └── Chat (per-conversation observable state) +├── Services +│ ├── NtfManager (push notification coordination) +│ ├── BGManager (background task scheduling) +│ ├── CallController (CallKit + VoIP push) +│ └── ThemeManager (theme resolution engine) +└── Extensions + ├── SimpleX NSE (Notification Service Extension -- decrypts push payloads) + └── SimpleX SE (Share Extension) +``` + +## Specification Documents + +| Document | Description | +|----------|-------------| +| [Architecture](architecture.md) | System architecture, FFI bridge, app lifecycle, extension model | +| [Chat API Reference](api.md) | Complete ChatCommand, ChatResponse, ChatEvent, ChatError type reference | +| [State Management](state.md) | ChatModel, ItemsModel, Chat, ChatInfo, preference storage | +| [Database & Storage](database.md) | SQLite databases, encryption, file storage, export/import | +| [Chat View](client/chat-view.md) | Message rendering, chat item types, context menu actions | +| [Chat List](client/chat-list.md) | Conversation list, filtering, search, swipe actions | +| [Message Composition](client/compose.md) | Compose bar, attachments, reply/edit/forward modes, voice recording | +| [Navigation](client/navigation.md) | Navigation stack, deep linking, sheet presentation, call overlay | +| [Push Notifications](services/notifications.md) | NtfManager, NSE, notification modes, token lifecycle | +| [WebRTC Calling](services/calls.md) | CallController, WebRTCClient, CallKit, signaling via SMP | +| [File Transfer](services/files.md) | Inline/XFTP transfer, auto-receive, CryptoFile, file constants | +| [Theme Engine](services/theme.md) | ThemeManager, default themes, customization layers, wallpapers | +| [Impact Graph](impact.md) | Source file → product concept mapping, risk levels | + +## Related Product Documentation + +- [Product Overview](../product/README.md) +- [Concept Index](../product/concepts.md) +- [Business Rules](../product/rules.md) +- [Known Gaps](../product/gaps.md) +- [Glossary](../product/glossary.md) +- [Chat List View](../product/views/chat-list.md) +- [Chat View](../product/views/chat.md) + +## Source Code Entry Points + +| File | Role | +|------|------| +| `Shared/SimpleXApp.swift` | App entry point, Haskell init, lifecycle management | +| `Shared/AppDelegate.swift` | UIApplicationDelegate for push token registration | +| `Shared/ContentView.swift` | Root view -- authentication gate, call overlay, navigation | +| `Shared/Model/ChatModel.swift` | Primary observable state (ChatModel, ItemsModel, Chat) | +| `Shared/Model/SimpleXAPI.swift` | FFI bridge -- chatSendCmd, chatApiSendCmd, sendSimpleXCmd | +| `Shared/Model/AppAPITypes.swift` | ChatCommand, ChatResponse, ChatEvent enums (iOS app layer) | +| `SimpleXChat/APITypes.swift` | APIResult, ChatError, ChatCmdProtocol (shared framework) | +| `SimpleXChat/ChatTypes.swift` | User, ChatInfo, Contact, GroupInfo, ChatItem data types | +| `SimpleXChat/SimpleX.h` | C header for Haskell FFI functions | diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md new file mode 100644 index 0000000000..45a06c371f --- /dev/null +++ b/apps/ios/spec/api.md @@ -0,0 +1,609 @@ +# SimpleX Chat iOS -- Chat API Reference + +> Complete specification of the ChatCommand, ChatResponse, ChatEvent, and ChatError types that form the API between the Swift UI layer and the Haskell core. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +**Source:** [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | [`APITypes.swift`](../SimpleXChat/APITypes.swift) | [`API.swift`](../SimpleXChat/API.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories (ChatCommand)](#2-command-categories) +3. [Response Types (ChatResponse)](#3-response-types) +4. [Event Types (ChatEvent)](#4-event-types) +5. [Error Types (ChatError)](#5-error-types) +6. [FFI Bridge Functions](#6-ffi-bridge-functions) +7. [Result Type (APIResult)](#7-result-type) + +--- + +## 1. Overview + +The iOS app communicates with the Haskell core exclusively through a command/response protocol: + +1. Swift constructs a `ChatCommand` enum value +2. The command's `cmdString` property serializes it to a text command +3. The FFI bridge sends the string to Haskell via `chat_send_cmd_retry` +4. Haskell returns a JSON response, decoded as `APIResult` +5. Async events arrive separately via `chat_recv_msg_wait`, decoded as `ChatEvent` + +**Source files**: +- [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift) -- `ChatCommand` ([L15](../Shared/Model/AppAPITypes.swift#L15)), `ChatResponse0` ([L657](../Shared/Model/AppAPITypes.swift#L657)), `ChatResponse1` ([L779](../Shared/Model/AppAPITypes.swift#L779)), `ChatResponse2` ([L919](../Shared/Model/AppAPITypes.swift#L919)), `ChatEvent` ([L1069](../Shared/Model/AppAPITypes.swift#L1069)) enums +- [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift) -- `APIResult` ([L27](../SimpleXChat/APITypes.swift#L27)), `ChatAPIResult` ([L65](../SimpleXChat/APITypes.swift#L65)), `ChatError` ([L699](../SimpleXChat/APITypes.swift#L699)) +- [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) -- FFI bridge functions (`chatSendCmd` [L121](../Shared/Model/SimpleXAPI.swift#L121), `chatRecvMsg` [L237](../Shared/Model/SimpleXAPI.swift#L237)) +- [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) -- Low-level FFI (`sendSimpleXCmd` [L115](../SimpleXChat/API.swift#L115), `recvSimpleXMsg` [L137](../SimpleXChat/API.swift#L137)) +- `SimpleXChat/ChatTypes.swift` -- Data types used in commands/responses (User, Contact, GroupInfo, ChatItem, etc.) +- `../../src/Simplex/Chat/Controller.hs` -- Haskell controller (function `chat_send_cmd_retry`, `chat_recv_msg_wait`) + +--- + +## 2. Command Categories + +The `ChatCommand` enum ([`AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15)) contains all commands the iOS app can send to the Haskell core. Commands are organized below by functional area. + +### 2.1 User Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showActiveUser` | -- | Get current active user | [L16](../Shared/Model/AppAPITypes.swift#L16) | +| `createActiveUser` | `profile: Profile?, pastTimestamp: Bool` | Create new user profile | [L17](../Shared/Model/AppAPITypes.swift#L17) | +| `listUsers` | -- | List all user profiles | [L18](../Shared/Model/AppAPITypes.swift#L18) | +| `apiSetActiveUser` | `userId: Int64, viewPwd: String?` | Switch active user | [L19](../Shared/Model/AppAPITypes.swift#L19) | +| `apiHideUser` | `userId: Int64, viewPwd: String` | Hide user behind password | [L24](../Shared/Model/AppAPITypes.swift#L24) | +| `apiUnhideUser` | `userId: Int64, viewPwd: String` | Unhide hidden user | [L25](../Shared/Model/AppAPITypes.swift#L25) | +| `apiMuteUser` | `userId: Int64` | Mute notifications for user | [L26](../Shared/Model/AppAPITypes.swift#L26) | +| `apiUnmuteUser` | `userId: Int64` | Unmute notifications for user | [L27](../Shared/Model/AppAPITypes.swift#L27) | +| `apiDeleteUser` | `userId: Int64, delSMPQueues: Bool, viewPwd: String?` | Delete user profile | [L28](../Shared/Model/AppAPITypes.swift#L28) | +| `apiUpdateProfile` | `userId: Int64, profile: Profile` | Update user display name/image | [L141](../Shared/Model/AppAPITypes.swift#L141) | +| `setAllContactReceipts` | `enable: Bool` | Set delivery receipts for all contacts | [L20](../Shared/Model/AppAPITypes.swift#L20) | +| `apiSetUserContactReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user contact receipt settings | [L21](../Shared/Model/AppAPITypes.swift#L21) | +| `apiSetUserGroupReceipts` | `userId: Int64, userMsgReceiptSettings` | Per-user group receipt settings | [L22](../Shared/Model/AppAPITypes.swift#L22) | +| `apiSetUserAutoAcceptMemberContacts` | `userId: Int64, enable: Bool` | Auto-accept group member contacts | [L23](../Shared/Model/AppAPITypes.swift#L23) | + +### 2.2 Chat Lifecycle Control + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `startChat` | `mainApp: Bool, enableSndFiles: Bool` | Start chat engine | [L29](../Shared/Model/AppAPITypes.swift#L29) | +| `checkChatRunning` | -- | Check if chat is running | [L30](../Shared/Model/AppAPITypes.swift#L30) | +| `apiStopChat` | -- | Stop chat engine | [L31](../Shared/Model/AppAPITypes.swift#L31) | +| `apiActivateChat` | `restoreChat: Bool` | Resume from background | [L32](../Shared/Model/AppAPITypes.swift#L32) | +| `apiSuspendChat` | `timeoutMicroseconds: Int` | Suspend for background | [L33](../Shared/Model/AppAPITypes.swift#L33) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder` | Set file storage paths | [L34](../Shared/Model/AppAPITypes.swift#L34) | +| `apiSetEncryptLocalFiles` | `enable: Bool` | Toggle local file encryption | [L35](../Shared/Model/AppAPITypes.swift#L35) | + +### 2.3 Chat & Message Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChats` | `userId: Int64` | Get all chat previews for user | [L44](../Shared/Model/AppAPITypes.swift#L44) | +| `apiGetChat` | `chatId, scope, contentTag, pagination, search` | Get messages for a chat | [L45](../Shared/Model/AppAPITypes.swift#L45) | +| `apiGetChatContentTypes` | `chatId, scope` | Get content type counts for a chat | [L46](../Shared/Model/AppAPITypes.swift#L46) | +| `apiGetChatItemInfo` | `type, id, scope, itemId` | Get detailed info for a message | [L47](../Shared/Model/AppAPITypes.swift#L47) | +| `apiSendMessages` | `type, id, scope, sendAsGroup, live, ttl, composedMessages` | Send one or more messages; `sendAsGroup` sends as channel owner | [L48](../Shared/Model/AppAPITypes.swift#L48) | +| `apiCreateChatItems` | `noteFolderId, composedMessages` | Create items in notes folder | [L54](../Shared/Model/AppAPITypes.swift#L54) | +| `apiUpdateChatItem` | `type, id, scope, itemId, updatedMessage, live` | Edit a sent message | [L56](../Shared/Model/AppAPITypes.swift#L56) | +| `apiDeleteChatItem` | `type, id, scope, itemIds, mode` | Delete messages | [L57](../Shared/Model/AppAPITypes.swift#L57) | +| `apiDeleteMemberChatItem` | `groupId, itemIds` | Moderate group messages | [L58](../Shared/Model/AppAPITypes.swift#L58) | +| `apiChatItemReaction` | `type, id, scope, itemId, add, reaction` | Add/remove emoji reaction | [L61](../Shared/Model/AppAPITypes.swift#L61) | +| `apiGetReactionMembers` | `userId, groupId, itemId, reaction` | Get who reacted | [L62](../Shared/Model/AppAPITypes.swift#L62) | +| `apiPlanForwardChatItems` | `fromChatType, fromChatId, fromScope, itemIds` | Plan message forwarding | [L63](../Shared/Model/AppAPITypes.swift#L63) | +| `apiForwardChatItems` | `toChatType, toChatId, toScope, sendAsGroup, from..., itemIds, ttl` | Forward messages; `sendAsGroup` forwards as channel owner | [L64](../Shared/Model/AppAPITypes.swift#L64) | +| `apiReportMessage` | `groupId, chatItemId, reportReason, reportText` | Report group message | [L55](../Shared/Model/AppAPITypes.swift#L55) | +| `apiChatRead` | `type, id, scope` | Mark entire chat as read | [L166](../Shared/Model/AppAPITypes.swift#L166) | +| `apiChatItemsRead` | `type, id, scope, itemIds` | Mark specific items as read | [L167](../Shared/Model/AppAPITypes.swift#L167) | +| `apiChatUnread` | `type, id, unreadChat` | Toggle unread badge | [L168](../Shared/Model/AppAPITypes.swift#L168) | + +### 2.4 Contact Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiAddContact` | `userId, incognito` | Create invitation link | [L126](../Shared/Model/AppAPITypes.swift#L126) | +| `apiConnect` | `userId, incognito, connLink` | Connect via link | [L136](../Shared/Model/AppAPITypes.swift#L136) | +| `apiConnectPlan` | `userId, connLink` | Plan connection (preview) | [L129](../Shared/Model/AppAPITypes.swift#L129) | +| `apiPrepareContact` | `userId, connLink, contactShortLinkData` | Prepare contact from link | [L130](../Shared/Model/AppAPITypes.swift#L130) | +| `apiPrepareGroup` | `userId, connLink, directLink, groupShortLinkData` | Prepare group from link; `directLink` (required, no default) indicates whether link is a direct (non-relay) group link | [L131](../Shared/Model/AppAPITypes.swift#L131) | +| `apiConnectPreparedContact` | `contactId, incognito, msg` | Connect prepared contact | [L134](../Shared/Model/AppAPITypes.swift#L134) | +| `apiConnectPreparedGroup` | `groupId, incognito, msg` | Connect to a prepared group/channel; returns `(GroupInfo, [RelayConnectionResult])?` | [L135](../Shared/Model/AppAPITypes.swift#L135) | +| `apiConnectContactViaAddress` | `userId, incognito, contactId` | Connect via address | [L137](../Shared/Model/AppAPITypes.swift#L137) | +| `apiAcceptContact` | `incognito, contactReqId` | Accept contact request | [L154](../Shared/Model/AppAPITypes.swift#L154) | +| `apiRejectContact` | `contactReqId` | Reject contact request | [L155](../Shared/Model/AppAPITypes.swift#L155) | +| `apiDeleteChat` | `type, id, chatDeleteMode` | Delete conversation | [L138](../Shared/Model/AppAPITypes.swift#L138) | +| `apiClearChat` | `type, id` | Clear conversation history | [L139](../Shared/Model/AppAPITypes.swift#L139) | +| `apiListContacts` | `userId` | List all contacts | [L140](../Shared/Model/AppAPITypes.swift#L140) | +| `apiSetContactPrefs` | `contactId, preferences` | Set contact preferences | [L142](../Shared/Model/AppAPITypes.swift#L142) | +| `apiSetContactAlias` | `contactId, localAlias` | Set local alias | [L143](../Shared/Model/AppAPITypes.swift#L143) | +| `apiSetConnectionAlias` | `connId, localAlias` | Set pending connection alias | [L145](../Shared/Model/AppAPITypes.swift#L145) | +| `apiContactInfo` | `contactId` | Get contact info + connection stats | [L112](../Shared/Model/AppAPITypes.swift#L112) | +| `apiSetConnectionIncognito` | `connId, incognito` | Toggle incognito on pending connection | [L127](../Shared/Model/AppAPITypes.swift#L127) | + +### 2.5 Group Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiNewGroup` | `userId, incognito, groupProfile` | Create new group | [L72](../Shared/Model/AppAPITypes.swift#L72) | +| `apiNewPublicGroup` | `userId, incognito, relayIds, groupProfile` | Create new public group (channel) with chat relays | [L73](../Shared/Model/AppAPITypes.swift#L73) | +| `apiGetGroupRelays` | `groupId` | Get group relay list with status (owner only) | [L74](../Shared/Model/AppAPITypes.swift#L74) | +| `apiAddMember` | `groupId, contactId, memberRole` | Invite contact to group | [L75](../Shared/Model/AppAPITypes.swift#L75) | +| `apiJoinGroup` | `groupId` | Accept group invitation | [L76](../Shared/Model/AppAPITypes.swift#L76) | +| `apiAcceptMember` | `groupId, groupMemberId, memberRole` | Accept member (knocking) | [L77](../Shared/Model/AppAPITypes.swift#L77) | +| `apiRemoveMembers` | `groupId, memberIds, withMessages` | Remove members | [L81](../Shared/Model/AppAPITypes.swift#L81) | +| `apiLeaveGroup` | `groupId` | Leave group | [L82](../Shared/Model/AppAPITypes.swift#L82) | +| `apiListMembers` | `groupId` | List group members | [L83](../Shared/Model/AppAPITypes.swift#L83) | +| `apiUpdateGroupProfile` | `groupId, groupProfile` | Update group name/image/description | [L84](../Shared/Model/AppAPITypes.swift#L84) | +| `apiMembersRole` | `groupId, memberIds, memberRole` | Change member roles | [L79](../Shared/Model/AppAPITypes.swift#L79) | +| `apiBlockMembersForAll` | `groupId, memberIds, blocked` | Block members for all | [L80](../Shared/Model/AppAPITypes.swift#L80) | +| `apiCreateGroupLink` | `groupId, memberRole` | Create shareable group link | [L85](../Shared/Model/AppAPITypes.swift#L85) | +| `apiGroupLinkMemberRole` | `groupId, memberRole` | Change group link default role | [L86](../Shared/Model/AppAPITypes.swift#L86) | +| `apiDeleteGroupLink` | `groupId` | Delete group link | [L87](../Shared/Model/AppAPITypes.swift#L87) | +| `apiGetGroupLink` | `groupId` | Get existing group link | [L88](../Shared/Model/AppAPITypes.swift#L88) | +| `apiAddGroupShortLink` | `groupId` | Add short link to group | [L89](../Shared/Model/AppAPITypes.swift#L89) | +| `apiCreateMemberContact` | `groupId, groupMemberId` | Create direct contact from group member | [L90](../Shared/Model/AppAPITypes.swift#L90) | +| `apiSendMemberContactInvitation` | `contactId, msg` | Send contact invitation to member | [L91](../Shared/Model/AppAPITypes.swift#L91) | +| `apiGroupMemberInfo` | `groupId, groupMemberId` | Get member info + connection stats | [L113](../Shared/Model/AppAPITypes.swift#L113) | +| `apiDeleteMemberSupportChat` | `groupId, groupMemberId` | Delete member support chat | [L78](../Shared/Model/AppAPITypes.swift#L78) | +| `apiSetMemberSettings` | `groupId, groupMemberId, memberSettings` | Set per-member settings | [L111](../Shared/Model/AppAPITypes.swift#L111) | +| `apiSetGroupAlias` | `groupId, localAlias` | Set local group alias | [L144](../Shared/Model/AppAPITypes.swift#L144) | + +### 2.6 Chat Tags + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetChatTags` | `userId` | Get all user tags | [L43](../Shared/Model/AppAPITypes.swift#L43) | +| `apiCreateChatTag` | `tag: ChatTagData` | Create a new tag | [L49](../Shared/Model/AppAPITypes.swift#L49) | +| `apiSetChatTags` | `type, id, tagIds` | Assign tags to a chat | [L50](../Shared/Model/AppAPITypes.swift#L50) | +| `apiDeleteChatTag` | `tagId` | Delete a tag | [L51](../Shared/Model/AppAPITypes.swift#L51) | +| `apiUpdateChatTag` | `tagId, tagData` | Update tag name/emoji | [L52](../Shared/Model/AppAPITypes.swift#L52) | +| `apiReorderChatTags` | `tagIds` | Reorder tags | [L53](../Shared/Model/AppAPITypes.swift#L53) | + +### 2.7 File Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `receiveFile` | `fileId, userApprovedRelays, encrypted, inline` | Accept and download file | [L169](../Shared/Model/AppAPITypes.swift#L169) | +| `setFileToReceive` | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive | [L170](../Shared/Model/AppAPITypes.swift#L170) | +| `cancelFile` | `fileId` | Cancel file transfer | [L171](../Shared/Model/AppAPITypes.swift#L171) | +| `apiUploadStandaloneFile` | `userId, file: CryptoFile` | Upload file to XFTP (no chat) | [L181](../Shared/Model/AppAPITypes.swift#L181) | +| `apiDownloadStandaloneFile` | `userId, url, file: CryptoFile` | Download from XFTP URL | [L182](../Shared/Model/AppAPITypes.swift#L182) | +| `apiStandaloneFileInfo` | `url` | Get file metadata from XFTP URL | [L183](../Shared/Model/AppAPITypes.swift#L183) | + +### 2.8 WebRTC Call Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSendCallInvitation` | `contact, callType` | Initiate call | [L157](../Shared/Model/AppAPITypes.swift#L157) | +| `apiRejectCall` | `contact` | Reject incoming call | [L158](../Shared/Model/AppAPITypes.swift#L158) | +| `apiSendCallOffer` | `contact, callOffer: WebRTCCallOffer` | Send SDP offer | [L159](../Shared/Model/AppAPITypes.swift#L159) | +| `apiSendCallAnswer` | `contact, answer: WebRTCSession` | Send SDP answer | [L160](../Shared/Model/AppAPITypes.swift#L160) | +| `apiSendCallExtraInfo` | `contact, extraInfo: WebRTCExtraInfo` | Send ICE candidates | [L161](../Shared/Model/AppAPITypes.swift#L161) | +| `apiEndCall` | `contact` | End active call | [L162](../Shared/Model/AppAPITypes.swift#L162) | +| `apiGetCallInvitations` | -- | Get pending call invitations | [L163](../Shared/Model/AppAPITypes.swift#L163) | +| `apiCallStatus` | `contact, callStatus` | Report call status change | [L164](../Shared/Model/AppAPITypes.swift#L164) | + +### 2.9 Push Notifications + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetNtfToken` | -- | Get current notification token | [L65](../Shared/Model/AppAPITypes.swift#L65) | +| `apiRegisterToken` | `token, notificationMode` | Register device token with server | [L66](../Shared/Model/AppAPITypes.swift#L66) | +| `apiVerifyToken` | `token, nonce, code` | Verify token registration | [L67](../Shared/Model/AppAPITypes.swift#L67) | +| `apiCheckToken` | `token` | Check token status | [L68](../Shared/Model/AppAPITypes.swift#L68) | +| `apiDeleteToken` | `token` | Unregister token | [L69](../Shared/Model/AppAPITypes.swift#L69) | +| `apiGetNtfConns` | `nonce, encNtfInfo` | Get notification connections (NSE) | [L70](../Shared/Model/AppAPITypes.swift#L70) | +| `apiGetConnNtfMessages` | `connMsgReqs` | Get notification messages (NSE) | [L71](../Shared/Model/AppAPITypes.swift#L71) | + +### 2.10 Settings & Configuration + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSaveSettings` | `settings: AppSettings` | Save app settings to core | [L41](../Shared/Model/AppAPITypes.swift#L41) | +| `apiGetSettings` | `settings: AppSettings` | Get settings from core | [L42](../Shared/Model/AppAPITypes.swift#L42) | +| `apiSetChatSettings` | `type, id, chatSettings` | Per-chat notification settings | [L110](../Shared/Model/AppAPITypes.swift#L110) | +| `apiSetChatItemTTL` | `userId, seconds` | Set global message TTL | [L102](../Shared/Model/AppAPITypes.swift#L102) | +| `apiGetChatItemTTL` | `userId` | Get global message TTL | [L103](../Shared/Model/AppAPITypes.swift#L103) | +| `apiSetChatTTL` | `userId, type, id, seconds` | Per-chat message TTL | [L104](../Shared/Model/AppAPITypes.swift#L104) | +| `apiSetNetworkConfig` | `networkConfig: NetCfg` | Set network configuration | [L105](../Shared/Model/AppAPITypes.swift#L105) | +| `apiGetNetworkConfig` | -- | Get network configuration | [L106](../Shared/Model/AppAPITypes.swift#L106) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Set network type/status | [L107](../Shared/Model/AppAPITypes.swift#L107) | +| `reconnectAllServers` | -- | Force reconnect all servers | [L108](../Shared/Model/AppAPITypes.swift#L108) | +| `reconnectServer` | `userId, smpServer` | Reconnect specific server | [L109](../Shared/Model/AppAPITypes.swift#L109) | + +### 2.11 Database & Storage + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiStorageEncryption` | `config: DBEncryptionConfig` | Set/change database encryption | [L39](../Shared/Model/AppAPITypes.swift#L39) | +| `testStorageEncryption` | `key: String` | Test encryption key | [L40](../Shared/Model/AppAPITypes.swift#L40) | +| `apiExportArchive` | `config: ArchiveConfig` | Export database archive | [L36](../Shared/Model/AppAPITypes.swift#L36) | +| `apiImportArchive` | `config: ArchiveConfig` | Import database archive | [L37](../Shared/Model/AppAPITypes.swift#L37) | +| `apiDeleteStorage` | -- | Delete all storage | [L38](../Shared/Model/AppAPITypes.swift#L38) | + +### 2.12 Server Operations + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetServerOperators` | -- | Get server operators | [L94](../Shared/Model/AppAPITypes.swift#L94) | +| `apiSetServerOperators` | `operators` | Set server operators | [L95](../Shared/Model/AppAPITypes.swift#L95) | +| `apiGetUserServers` | `userId` | Get user's configured servers | [L96](../Shared/Model/AppAPITypes.swift#L96) | +| `apiSetUserServers` | `userId, userServers` | Set user's servers | [L97](../Shared/Model/AppAPITypes.swift#L97) | +| `apiValidateServers` | `userId, userServers` | Validate server configuration; returns errors and warnings | [L98](../Shared/Model/AppAPITypes.swift#L98) | +| `apiGetUsageConditions` | -- | Get usage conditions | [L99](../Shared/Model/AppAPITypes.swift#L99) | +| `apiAcceptConditions` | `conditionsId, operatorIds` | Accept usage conditions | [L101](../Shared/Model/AppAPITypes.swift#L101) | +| `apiTestProtoServer` | `userId, server` | Test server connectivity | [L93](../Shared/Model/AppAPITypes.swift#L93) | + +### 2.13 Theme & UI + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiSetUserUIThemes` | `userId, themes: ThemeModeOverrides?` | Set per-user theme | [L146](../Shared/Model/AppAPITypes.swift#L146) | +| `apiSetChatUIThemes` | `chatId, themes: ThemeModeOverrides?` | Set per-chat theme | [L147](../Shared/Model/AppAPITypes.swift#L147) | + +### 2.14 Remote Desktop + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `setLocalDeviceName` | `displayName` | Set device name for pairing | [L173](../Shared/Model/AppAPITypes.swift#L173) | +| `connectRemoteCtrl` | `xrcpInvitation` | Connect to desktop via QR code | [L174](../Shared/Model/AppAPITypes.swift#L174) | +| `findKnownRemoteCtrl` | -- | Find previously paired desktops | [L175](../Shared/Model/AppAPITypes.swift#L175) | +| `confirmRemoteCtrl` | `remoteCtrlId` | Confirm known remote controller | [L176](../Shared/Model/AppAPITypes.swift#L176) | +| `verifyRemoteCtrlSession` | `sessionCode` | Verify session code | [L177](../Shared/Model/AppAPITypes.swift#L177) | +| `listRemoteCtrls` | -- | List known remote controllers | [L178](../Shared/Model/AppAPITypes.swift#L178) | +| `stopRemoteCtrl` | -- | Stop remote session | [L179](../Shared/Model/AppAPITypes.swift#L179) | +| `deleteRemoteCtrl` | `remoteCtrlId` | Delete known controller | [L180](../Shared/Model/AppAPITypes.swift#L180) | + +### 2.15 Diagnostics + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `showVersion` | -- | Get core version info | [L185](../Shared/Model/AppAPITypes.swift#L185) | +| `getAgentSubsTotal` | `userId` | Get total SMP subscriptions | [L186](../Shared/Model/AppAPITypes.swift#L186) | +| `getAgentServersSummary` | `userId` | Get server summary stats | [L187](../Shared/Model/AppAPITypes.swift#L187) | +| `resetAgentServersStats` | -- | Reset server statistics | [L188](../Shared/Model/AppAPITypes.swift#L188) | + +### 2.16 Address Management + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiCreateMyAddress` | `userId` | Create SimpleX address | [L148](../Shared/Model/AppAPITypes.swift#L148) | +| `apiDeleteMyAddress` | `userId` | Delete SimpleX address | [L149](../Shared/Model/AppAPITypes.swift#L149) | +| `apiShowMyAddress` | `userId` | Show current address | [L150](../Shared/Model/AppAPITypes.swift#L150) | +| `apiAddMyAddressShortLink` | `userId` | Add short link to address | [L151](../Shared/Model/AppAPITypes.swift#L151) | +| `apiSetProfileAddress` | `userId, on: Bool` | Toggle address in profile | [L152](../Shared/Model/AppAPITypes.swift#L152) | +| `apiSetAddressSettings` | `userId, addressSettings` | Configure address settings | [L153](../Shared/Model/AppAPITypes.swift#L153) | + +### 2.17 Connection Security + +| Command | Parameters | Description | Source | +|---------|-----------|-------------|--------| +| `apiGetContactCode` | `contactId` | Get verification code | [L122](../Shared/Model/AppAPITypes.swift#L122) | +| `apiGetGroupMemberCode` | `groupId, groupMemberId` | Get member verification code | [L123](../Shared/Model/AppAPITypes.swift#L123) | +| `apiVerifyContact` | `contactId, connectionCode` | Verify contact identity | [L124](../Shared/Model/AppAPITypes.swift#L124) | +| `apiVerifyGroupMember` | `groupId, groupMemberId, connectionCode` | Verify group member identity | [L125](../Shared/Model/AppAPITypes.swift#L125) | +| `apiSwitchContact` | `contactId` | Switch contact connection (key rotation) | [L116](../Shared/Model/AppAPITypes.swift#L116) | +| `apiSwitchGroupMember` | `groupId, groupMemberId` | Switch group member connection | [L117](../Shared/Model/AppAPITypes.swift#L117) | +| `apiAbortSwitchContact` | `contactId` | Abort contact switch | [L118](../Shared/Model/AppAPITypes.swift#L118) | +| `apiAbortSwitchGroupMember` | `groupId, groupMemberId` | Abort member switch | [L119](../Shared/Model/AppAPITypes.swift#L119) | +| `apiSyncContactRatchet` | `contactId, force` | Sync double-ratchet state | [L120](../Shared/Model/AppAPITypes.swift#L120) | +| `apiSyncGroupMemberRatchet` | `groupId, groupMemberId, force` | Sync member ratchet | [L121](../Shared/Model/AppAPITypes.swift#L121) | + +--- + +## 3. Response Types + +Responses are split across three enums due to Swift enum size limitations: + +### ChatResponse0 + +Synchronous query responses ([`AppAPITypes.swift` L657](../Shared/Model/AppAPITypes.swift#L657)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `activeUser` | `user: User` | Current active user | [L658](../Shared/Model/AppAPITypes.swift#L658) | +| `usersList` | `users: [UserInfo]` | All user profiles | [L659](../Shared/Model/AppAPITypes.swift#L659) | +| `chatStarted` | -- | Chat engine started | [L660](../Shared/Model/AppAPITypes.swift#L660) | +| `chatRunning` | -- | Chat is already running | [L661](../Shared/Model/AppAPITypes.swift#L661) | +| `chatStopped` | -- | Chat engine stopped | [L662](../Shared/Model/AppAPITypes.swift#L662) | +| `apiChats` | `user, chats: [ChatData]` | All chat previews | [L663](../Shared/Model/AppAPITypes.swift#L663) | +| `apiChat` | `user, chat: ChatData, navInfo` | Single chat with messages | [L664](../Shared/Model/AppAPITypes.swift#L664) | +| `chatTags` | `user, userTags: [ChatTag]` | User's chat tags | [L666](../Shared/Model/AppAPITypes.swift#L666) | +| `chatItemInfo` | `user, chatItem, chatItemInfo` | Message detail info | [L667](../Shared/Model/AppAPITypes.swift#L667) | +| `serverTestResult` | `user, testServer, testFailure` | Server test result | [L668](../Shared/Model/AppAPITypes.swift#L668) | +| `userServersValidation` | `user, serverErrors: [UserServersError], serverWarnings: [UserServersWarning]` | Server validation result with errors and warnings | [L671](../Shared/Model/AppAPITypes.swift#L671) | +| `networkConfig` | `networkConfig: NetCfg` | Current network config | [L674](../Shared/Model/AppAPITypes.swift#L674) | +| `contactInfo` | `user, contact, connectionStats, customUserProfile` | Contact details | [L675](../Shared/Model/AppAPITypes.swift#L675) | +| `groupMemberInfo` | `user, groupInfo, member, connectionStats` | Member details | [L676](../Shared/Model/AppAPITypes.swift#L676) | +| `connectionVerified` | `verified, expectedCode` | Verification result | [L686](../Shared/Model/AppAPITypes.swift#L686) | +| `tagsUpdated` | `user, userTags, chatTags` | Tags changed | [L687](../Shared/Model/AppAPITypes.swift#L687) | + +### ChatResponse1 + +Contact, message, and profile responses ([`AppAPITypes.swift` L779](../Shared/Model/AppAPITypes.swift#L779)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `invitation` | `user, connLinkInvitation, connection` | Created invitation link | [L780](../Shared/Model/AppAPITypes.swift#L780) | +| `connectionPlan` | `user, connLink, connectionPlan` | Connection plan preview | [L783](../Shared/Model/AppAPITypes.swift#L783) | +| `newPreparedChat` | `user, chat: ChatData` | Prepared contact/group | [L784](../Shared/Model/AppAPITypes.swift#L784) | +| `startedConnectionToGroup` | `user, groupInfo, relayResults: [RelayConnectionResult]` | Group/channel join initiated; relay results indicate per-relay connection success/failure | [L790](../Shared/Model/AppAPITypes.swift#L790) | +| `contactDeleted` | `user, contact` | Contact deleted | [L793](../Shared/Model/AppAPITypes.swift#L793) | +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages sent/received | [L811](../Shared/Model/AppAPITypes.swift#L811) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited | [L814](../Shared/Model/AppAPITypes.swift#L814) | +| `chatItemReaction` | `user, added, reaction` | Reaction change | [L816](../Shared/Model/AppAPITypes.swift#L816) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L818](../Shared/Model/AppAPITypes.swift#L818) | +| `contactsList` | `user, contacts: [Contact]` | All contacts list | [L819](../Shared/Model/AppAPITypes.swift#L819) | +| `userProfileUpdated` | `user, fromProfile, toProfile` | Profile changed | [L799](../Shared/Model/AppAPITypes.swift#L799) | +| `userContactLinkCreated` | `user, connLinkContact` | Address created | [L807](../Shared/Model/AppAPITypes.swift#L807) | +| `forwardPlan` | `user, chatItemIds, forwardConfirmation` | Forward plan result | [L813](../Shared/Model/AppAPITypes.swift#L813) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L812](../Shared/Model/AppAPITypes.swift#L812) | + +### ChatResponse2 + +Group, file, call, notification, and misc responses ([`AppAPITypes.swift` L919](../Shared/Model/AppAPITypes.swift#L919)): + +| Response | Key Fields | Description | Source | +|----------|-----------|-------------|--------| +| `groupCreated` | `user, groupInfo` | New group created | [L921](../Shared/Model/AppAPITypes.swift#L921) | +| `publicGroupCreated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | New public group (channel) created with relay info | [L922](../Shared/Model/AppAPITypes.swift#L922) | +| `groupRelays` | `user, groupInfo, groupRelays: [GroupRelay]` | Group relay list (owner API response) | [L923](../Shared/Model/AppAPITypes.swift#L923) | +| `sentGroupInvitation` | `user, groupInfo, contact, member` | Group invitation sent | [L924](../Shared/Model/AppAPITypes.swift#L924) | +| `groupMembers` | `user, group: Group` | Group member list | [L928](../Shared/Model/AppAPITypes.swift#L928) | +| `membersRoleUser` | `user, groupInfo, members, toRole` | Role changed | [L932](../Shared/Model/AppAPITypes.swift#L932) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile updated | [L934](../Shared/Model/AppAPITypes.swift#L934) | +| `groupLinkCreated` | `user, groupInfo, groupLink` | Group link created | [L935](../Shared/Model/AppAPITypes.swift#L935) | +| `rcvFileAccepted` | `user, chatItem` | File download started | [L942](../Shared/Model/AppAPITypes.swift#L942) | +| `callInvitations` | `callInvitations: [RcvCallInvitation]` | Pending calls | [L951](../Shared/Model/AppAPITypes.swift#L951) | +| `ntfToken` | `token, status, ntfMode, ntfServer` | Notification token info | [L954](../Shared/Model/AppAPITypes.swift#L954) | +| `versionInfo` | `versionInfo, chatMigrations, agentMigrations` | Core version | [L962](../Shared/Model/AppAPITypes.swift#L962) | +| `cmdOk` | `user_` | Generic success | [L963](../Shared/Model/AppAPITypes.swift#L963) | +| `archiveExported` | `archiveErrors: [ArchiveError]` | Export result | [L967](../Shared/Model/AppAPITypes.swift#L967) | +| `archiveImported` | `archiveErrors: [ArchiveError]` | Import result | [L968](../Shared/Model/AppAPITypes.swift#L968) | +| `appSettings` | `appSettings: AppSettings` | Retrieved settings | [L969](../Shared/Model/AppAPITypes.swift#L969) | + +--- + +## 4. Event Types + +The `ChatEvent` enum ([`AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069)) represents async events from the Haskell core. These arrive via `chat_recv_msg_wait` polling, not as responses to commands. + +Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2282) in `SimpleXAPI.swift`. + +### Connection Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactConnected` | `user, contact, userCustomProfile` | Contact connection established | [L1076](../Shared/Model/AppAPITypes.swift#L1076) | +| `contactConnecting` | `user, contact` | Contact connecting in progress | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `contactSndReady` | `user, contact` | Ready to send to contact | [L1078](../Shared/Model/AppAPITypes.swift#L1078) | +| `contactDeletedByContact` | `user, contact` | Contact deleted by other party | [L1075](../Shared/Model/AppAPITypes.swift#L1075) | +| `contactUpdated` | `user, toContact` | Contact profile updated | [L1080](../Shared/Model/AppAPITypes.swift#L1080) | +| `receivedContactRequest` | `user, contactRequest, chat_` | Incoming contact request | [L1079](../Shared/Model/AppAPITypes.swift#L1079) | +| `subscriptionStatus` | `subscriptionStatus, connections` | Connection subscription change | [L1082](../Shared/Model/AppAPITypes.swift#L1082) | + +### Message Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `newChatItems` | `user, chatItems: [AChatItem]` | New messages received | [L1084](../Shared/Model/AppAPITypes.swift#L1084) | +| `chatItemUpdated` | `user, chatItem: AChatItem` | Message edited remotely | [L1086](../Shared/Model/AppAPITypes.swift#L1086) | +| `chatItemReaction` | `user, added, reaction: ACIReaction` | Reaction added/removed | [L1087](../Shared/Model/AppAPITypes.swift#L1087) | +| `chatItemsDeleted` | `user, chatItemDeletions, byUser` | Messages deleted | [L1088](../Shared/Model/AppAPITypes.swift#L1088) | +| `chatItemsStatusesUpdated` | `user, chatItems: [AChatItem]` | Delivery status changed | [L1085](../Shared/Model/AppAPITypes.swift#L1085) | +| `groupChatItemsDeleted` | `user, groupInfo, chatItemIDs, byUser, member_` | Group items deleted | [L1090](../Shared/Model/AppAPITypes.swift#L1090) | +| `chatInfoUpdated` | `user, chatInfo` | Chat metadata changed | [L1083](../Shared/Model/AppAPITypes.swift#L1083) | + +### Group Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `receivedGroupInvitation` | `user, groupInfo, contact, memberRole` | Group invitation received | [L1091](../Shared/Model/AppAPITypes.swift#L1091) | +| `userAcceptedGroupSent` | `user, groupInfo, hostContact` | Joined group | [L1092](../Shared/Model/AppAPITypes.swift#L1092) | +| `groupLinkConnecting` | `user, groupInfo, hostMember` | Connecting via group link | [L1093](../Shared/Model/AppAPITypes.swift#L1093) | +| `joinedGroupMemberConnecting` | `user, groupInfo, hostMember, member` | Member joining | [L1095](../Shared/Model/AppAPITypes.swift#L1095) | +| `memberRole` | `user, groupInfo, byMember, member, fromRole, toRole` | Role changed | [L1097](../Shared/Model/AppAPITypes.swift#L1097) | +| `memberBlockedForAll` | `user, groupInfo, byMember, member, blocked` | Member blocked | [L1098](../Shared/Model/AppAPITypes.swift#L1098) | +| `deletedMemberUser` | `user, groupInfo, member, withMessages` | Current user removed | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `deletedMember` | `user, groupInfo, byMember, deletedMember` | Member removed | [L1100](../Shared/Model/AppAPITypes.swift#L1100) | +| `leftMember` | `user, groupInfo, member` | Member left | [L1101](../Shared/Model/AppAPITypes.swift#L1101) | +| `groupDeleted` | `user, groupInfo, member` | Group deleted | [L1102](../Shared/Model/AppAPITypes.swift#L1102) | +| `userJoinedGroup` | `user, groupInfo, hostMember` | Successfully joined; `hostMember` is upserted into group members | [L1103](../Shared/Model/AppAPITypes.swift#L1103) | +| `joinedGroupMember` | `user, groupInfo, member` | New member joined | [L1104](../Shared/Model/AppAPITypes.swift#L1104) | +| `connectedToGroupMember` | `user, groupInfo, member, memberContact` | E2E session established with member | [L1105](../Shared/Model/AppAPITypes.swift#L1105) | +| `groupUpdated` | `user, toGroup: GroupInfo` | Group profile changed | [L1106](../Shared/Model/AppAPITypes.swift#L1106) | +| `groupLinkRelaysUpdated` | `user, groupInfo, groupLink, groupRelays: [GroupRelay]` | Channel relay configuration changed | [L1107](../Shared/Model/AppAPITypes.swift#L1107) | +| `groupMemberUpdated` | `user, groupInfo, fromMember, toMember` | Member info updated | [L1081](../Shared/Model/AppAPITypes.swift#L1081) | + +### File Transfer Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `rcvFileStart` | `user, chatItem` | Download started | [L1112](../Shared/Model/AppAPITypes.swift#L1112) | +| `rcvFileProgressXFTP` | `user, chatItem_, receivedSize, totalSize` | Download progress | [L1113](../Shared/Model/AppAPITypes.swift#L1113) | +| `rcvFileComplete` | `user, chatItem` | Download complete | [L1114](../Shared/Model/AppAPITypes.swift#L1114) | +| `rcvFileSndCancelled` | `user, chatItem, rcvFileTransfer` | Sender cancelled | [L1116](../Shared/Model/AppAPITypes.swift#L1116) | +| `rcvFileError` | `user, chatItem_, agentError, rcvFileTransfer` | Download error | [L1117](../Shared/Model/AppAPITypes.swift#L1117) | +| `sndFileStart` | `user, chatItem, sndFileTransfer` | Upload started | [L1120](../Shared/Model/AppAPITypes.swift#L1120) | +| `sndFileComplete` | `user, chatItem, sndFileTransfer` | Upload complete (inline) | [L1121](../Shared/Model/AppAPITypes.swift#L1121) | +| `sndFileProgressXFTP` | `user, chatItem_, fileTransferMeta, sentSize, totalSize` | Upload progress | [L1123](../Shared/Model/AppAPITypes.swift#L1123) | +| `sndFileCompleteXFTP` | `user, chatItem, fileTransferMeta` | XFTP upload complete | [L1125](../Shared/Model/AppAPITypes.swift#L1125) | +| `sndFileError` | `user, chatItem_, fileTransferMeta, errorMessage` | Upload error | [L1127](../Shared/Model/AppAPITypes.swift#L1127) | + +### Call Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `callInvitation` | `callInvitation: RcvCallInvitation` | Incoming call | [L1130](../Shared/Model/AppAPITypes.swift#L1130) | +| `callOffer` | `user, contact, callType, offer, sharedKey, askConfirmation` | SDP offer received | [L1131](../Shared/Model/AppAPITypes.swift#L1131) | +| `callAnswer` | `user, contact, answer` | SDP answer received | [L1132](../Shared/Model/AppAPITypes.swift#L1132) | +| `callExtraInfo` | `user, contact, extraInfo` | ICE candidates received | [L1133](../Shared/Model/AppAPITypes.swift#L1133) | +| `callEnded` | `user, contact` | Call ended by remote | [L1134](../Shared/Model/AppAPITypes.swift#L1134) | + +### Connection Security Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `contactSwitch` | `user, contact, switchProgress` | Key rotation progress | [L1071](../Shared/Model/AppAPITypes.swift#L1071) | +| `groupMemberSwitch` | `user, groupInfo, member, switchProgress` | Member key rotation | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `contactRatchetSync` | `user, contact, ratchetSyncProgress` | Ratchet sync progress | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `groupMemberRatchetSync` | `user, groupInfo, member, ratchetSyncProgress` | Member ratchet sync | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | + +### System Events + +| Event | Key Fields | Description | Source | +|-------|-----------|-------------|--------| +| `chatSuspended` | -- | Core suspended | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | + +--- + +## 5. Error Types + +Defined in [`SimpleXChat/APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699): + +```swift +public enum ChatError: Decodable, Hashable, Error { + case error(errorType: ChatErrorType) + case errorAgent(agentError: AgentErrorType) + case errorStore(storeError: StoreError) + case errorDatabase(databaseError: DatabaseError) + case errorRemoteCtrl(remoteCtrlError: RemoteCtrlError) + case invalidJSON(json: Data?) + case unexpectedResult(type: String) +} +``` + +### Error Categories + +| Category | Enum | Description | Source | +|----------|------|-------------|--------| +| Chat logic | `ChatErrorType` | Business logic errors (e.g., invalid state, permission denied, `chatRelayExists`) | [`APITypes.swift` L722](../SimpleXChat/APITypes.swift#L722) | +| SMP Agent | `AgentErrorType` | Protocol/network errors from the SMP agent layer | [`APITypes.swift` L884](../SimpleXChat/APITypes.swift#L884) | +| Database store | `StoreError` | SQLite query/constraint errors (includes relay-related: `relayUserNotFound`, `duplicateMemberId`, `userChatRelayNotFound`, `groupRelayNotFound`, `groupRelayNotFoundByMemberId`) | [`APITypes.swift` L802](../SimpleXChat/APITypes.swift#L802) | +| Database engine | `DatabaseError` | DB open/migration/encryption errors | [`APITypes.swift` L871](../SimpleXChat/APITypes.swift#L871) | +| Remote control | `RemoteCtrlError` | Remote desktop session errors | [`APITypes.swift` L1054](../SimpleXChat/APITypes.swift#L1054) | +| Parse failure | `invalidJSON` | Failed to decode response JSON | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | +| Unexpected | `unexpectedResult` | Response type does not match expected | [`APITypes.swift` L699](../SimpleXChat/APITypes.swift#L699) | + +--- + +## 6. FFI Bridge Functions + +Defined in [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift): + +### Synchronous (blocking current thread) + +```swift +// Throws on error, returns typed result +func chatSendCmdSync( // SimpleXAPI.swift L93 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) throws -> R + +// Returns APIResult (caller handles error) +func chatApiSendCmdSync( // SimpleXAPI.swift L99 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + retryNum: Int32 = 0, + log: Bool = true +) -> APIResult +``` + +### Asynchronous (Swift concurrency) + +```swift +// Throws on error, returns typed result +func chatSendCmd( // SimpleXAPI.swift L121 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + ctrl: chat_ctrl? = nil, + log: Bool = true +) async throws -> R + +// Returns APIResult with optional retry on network errors +func chatApiSendCmdWithRetry( // SimpleXAPI.swift L127 + _ cmd: ChatCommand, + bgTask: Bool = true, + bgDelay: Double? = nil, + inProgress: BoxedValue? = nil, + retryNum: Int32 = 0 +) async -> APIResult? +``` + +### Low-Level FFI + +```swift +// Direct C FFI call -- serializes cmd.cmdString, calls chat_send_cmd_retry, decodes response +public func sendSimpleXCmd( // API.swift L115 + _ cmd: ChatCmdProtocol, + _ ctrl: chat_ctrl?, + retryNum: Int32 = 0 +) -> APIResult +``` + +### Event Receiver + +```swift +// Polls for async events from the Haskell core +func chatRecvMsg( // SimpleXAPI.swift L237 + _ ctrl: chat_ctrl? = nil +) async -> APIResult? + +// Processes a received event and updates app state +func processReceivedMsg( // SimpleXAPI.swift L2282 + _ res: ChatEvent +) async +``` + +--- + +## 7. Result Type + +Defined in [`SimpleXChat/APITypes.swift` L27](../SimpleXChat/APITypes.swift#L27): + +```swift +public enum APIResult: Decodable where R: Decodable, R: ChatAPIResult { + case result(R) // Successful response + case error(ChatError) // Error response from core + case invalid(type: String, json: Data) // Undecodable response + + public var responseType: String { ... } + public var unexpected: ChatError { ... } +} + +public protocol ChatAPIResult: Decodable { // APITypes.swift L65 + var responseType: String { get } + var details: String { get } + static func fallbackResult(_ type: String, _ json: NSDictionary) -> Self? +} +``` + +The `decodeAPIResult` function ([`APITypes.swift` L86](../SimpleXChat/APITypes.swift#L86)) handles JSON decoding with fallback logic: +1. Try standard `JSONDecoder.decode(APIResult.self, from: data)` +2. If that fails, try manual JSON parsing via `JSONSerialization` +3. Check for `"error"` key -- return `.error` +4. Check for `"result"` key -- try `R.fallbackResult` or return `.invalid` +5. Last resort: return `.invalid(type: "invalid", json: ...)` + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatCommand enum | [`Shared/Model/AppAPITypes.swift` L15](../Shared/Model/AppAPITypes.swift#L15) | +| ChatResponse0/1/2 enums | [`Shared/Model/AppAPITypes.swift` L657, L779, L919](../Shared/Model/AppAPITypes.swift#L657) | +| ChatEvent enum | [`Shared/Model/AppAPITypes.swift` L1069](../Shared/Model/AppAPITypes.swift#L1069) | +| APIResult, ChatError | [`SimpleXChat/APITypes.swift` L27, L699](../SimpleXChat/APITypes.swift#L27) | +| FFI bridge functions | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift) | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift) | +| Data types | `SimpleXChat/ChatTypes.swift` | +| C header | `SimpleXChat/SimpleX.h` | +| Haskell controller | `../../src/Simplex/Chat/Controller.hs` | diff --git a/apps/ios/spec/architecture.md b/apps/ios/spec/architecture.md new file mode 100644 index 0000000000..9ab3eb1fd2 --- /dev/null +++ b/apps/ios/spec/architecture.md @@ -0,0 +1,347 @@ +# SimpleX Chat iOS -- System Architecture + +> Technical specification for the iOS app's layered architecture, FFI bridge, event system, and extension model. +> +> Related specs: [README](README.md) | [API Reference](api.md) | [State Management](state.md) | [Database](database.md) +> Related product: [Product Overview](../product/README.md) + +**Source:** [`SimpleXApp.swift`](../Shared/SimpleXApp.swift#L1-L183) | [`AppDelegate.swift`](../Shared/AppDelegate.swift#L1-L209) | [`ContentView.swift`](../Shared/ContentView.swift#L1-L513) | [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1373) | [`SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L1-L2915) | [`AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L1-L2357) | [`APITypes.swift`](../SimpleXChat/APITypes.swift#L1-L1071) | [`API.swift`](../SimpleXChat/API.swift#L1-L388) + +--- + +## Table of Contents + +1. [Layered Architecture](#1-layered-architecture) +2. [FFI Bridge](#2-ffi-bridge) +3. [Event Streaming](#3-event-streaming) +4. [Database Architecture](#4-database-architecture) +5. [App Lifecycle](#5-app-lifecycle) +6. [Extension Architecture](#6-extension-architecture) +7. [Remote Desktop Control](#7-remote-desktop-control) + +--- + +## [1. Layered Architecture](../Shared/SimpleXApp.swift#L17-L184) + +The app follows a strict layered model where each layer communicates only with its immediate neighbor: + +``` +┌─────────────────────────────────────────┐ +│ SwiftUI Views │ Rendering, user interaction +│ (ChatListView, ChatView, ComposeView) │ +├─────────────────────────────────────────┤ +│ ChatModel (ObservableObject) │ App state, @Published properties +│ ItemsModel, Chat, ChatTagsModel │ Per-chat state, tag filtering +├─────────────────────────────────────────┤ +│ SimpleXAPI (FFI Bridge) │ chatSendCmd/chatApiSendCmd +│ AppAPITypes (ChatCommand/Response) │ JSON serialization/deserialization +├─────────────────────────────────────────┤ +│ C FFI Layer │ chat_send_cmd_retry, chat_recv_msg_wait +│ (SimpleX.h, libsimplex.a) │ Compiled Haskell via GHC cross-compiler +├─────────────────────────────────────────┤ +│ Haskell Core (chat_ctrl) │ Chat logic, chat protocol (x-events), +│ (Simplex.Chat.Controller) │ database operations, file management +├─────────────────────────────────────────┤ +│ simplexmq library (external) │ SMP/XFTP protocols, SMP Agent, +│ (github.com/simplex-chat/simplexmq) │ double-ratchet (PQDR), transport (TLS) +└─────────────────────────────────────────┘ +``` + +**Key invariant**: No SwiftUI view directly calls FFI functions. All communication flows through `ChatModel` or dedicated API functions in `SimpleXAPI.swift`. + +### Source Files + +| Layer | File | Role | Line | +|-------|------|------|------| +| Views | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift) | Chat list rendering | | +| Views | [`Shared/Views/Chat/ChatView.swift`](../Shared/Views/Chat/ChatView.swift) | Conversation rendering | | +| State | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | `ChatModel`, `ItemsModel`, `Chat` classes | L337, L74, L1271 | +| API | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | FFI bridge functions | L93 | +| API | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | `ChatCommand`, `ChatResponse`, `ChatEvent` enums | L15, L649, L1055 | +| FFI | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | C header declaring Haskell exports | | +| FFI | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | `APIResult`, `ChatError`, `ChatCmdProtocol` | L27, L699, L17 | +| Core | `../../src/Simplex/Chat/Controller.hs` | Haskell command processor — see `processCommand` in `Controller.hs` | | + +--- + +## [2. FFI Bridge](../SimpleXChat/SimpleX.h#L1-L49) + +### [C Functions (SimpleX.h)](../SimpleXChat/SimpleX.h#L1-L49) + +The Haskell core exposes these C functions, declared in `SimpleXChat/SimpleX.h`: + +```c +typedef void* chat_ctrl; + +// Initialize database, apply migrations, return controller +char *chat_migrate_init_key(char *path, char *key, int keepKey, char *confirm, + int backgroundMode, chat_ctrl *ctrl); + +// Send command string, return JSON response string +char *chat_send_cmd_retry(chat_ctrl ctl, char *cmd, int retryNum); + +// Block until next async event arrives (or timeout) +char *chat_recv_msg_wait(chat_ctrl ctl, int wait); + +// Close/reopen database store +char *chat_close_store(chat_ctrl ctl); +char *chat_reopen_store(chat_ctrl ctl); + +// Utility: markdown parsing, server validation, password hashing +char *chat_parse_markdown(char *str); +char *chat_parse_server(char *str); +char *chat_password_hash(char *pwd, char *salt); + +// File encryption/decryption +char *chat_write_file(chat_ctrl ctl, char *path, char *data, int len); +char *chat_read_file(char *path, char *key, char *nonce); +char *chat_encrypt_file(chat_ctrl ctl, char *fromPath, char *toPath); +char *chat_decrypt_file(char *fromPath, char *key, char *nonce, char *toPath); +``` + +### [Swift Bridge Functions (SimpleXAPI.swift)](../Shared/Model/SimpleXAPI.swift#L93-L221) + +```swift +// Synchronous send -- blocks calling thread +func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) throws -> R // L91 + +// Async send -- dispatches to background +func chatApiSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, + bgDelay: Double? = nil, ctrl: chat_ctrl? = nil) async -> APIResult // L215 + +// Low-level FFI call -- serializes command to string, calls chat_send_cmd_retry, decodes JSON +func sendSimpleXCmd(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl?, + retryNum: Int32 = 0) -> APIResult // SimpleXChat/API.swift L114 +``` + +### Data Flow + +1. Swift constructs a `ChatCommand` enum value (e.g., `.apiSendMessages(type:id:scope:live:ttl:composedMessages:)`) +2. [`ChatCommand.cmdString`](../Shared/Model/AppAPITypes.swift#L15) serializes it to a command string (e.g., `"/_send @1 json {...}"`) +3. [`sendSimpleXCmd`](../SimpleXChat/API.swift#L115) passes the string to `chat_send_cmd_retry` via C FFI +4. Haskell core processes the command, returns JSON response string +5. Swift decodes JSON into [`APIResult`](../SimpleXChat/APITypes.swift#L27) where `R: ChatAPIResult` +6. Result is either `.result(R)`, `.error(ChatError)`, or `.invalid(type, json)` + +### [Background Task Protection](../Shared/Model/SimpleXAPI.swift#L54-L79) + +All FFI calls are wrapped in [`beginBGTask()`](../Shared/Model/SimpleXAPI.swift#L54) / `endBackgroundTask()` to prevent iOS from killing the app mid-operation. The `maxTaskDuration` is 15 seconds. + +--- + +## [3. Event Streaming](../Shared/Model/SimpleXAPI.swift#L2220-L2916) + +The Haskell core emits async events (new messages, connection status changes, file progress, etc.) that are not direct responses to commands. These are received via polling: + +``` +Haskell Core --[chat_recv_msg_wait]--> Swift event loop --> ChatModel update --> SwiftUI re-render +``` + +The event loop is implemented in [`ChatReceiver`](../Shared/Model/SimpleXAPI.swift#L2220-L2263), and events are dispatched by [`processReceivedMsg`](../Shared/Model/SimpleXAPI.swift#L2266). + +### [Event Types (ChatEvent enum)](../Shared/Model/AppAPITypes.swift#L1055-L1129) + +Key async events delivered from core to UI: + +| Event | Description | Line | +|-------|-------------|------| +| `newChatItems` | New messages received | [L1070](../Shared/Model/AppAPITypes.swift#L1070) | +| `chatItemUpdated` | Message edited by sender | [L1072](../Shared/Model/AppAPITypes.swift#L1072) | +| `chatItemsDeleted` | Messages deleted | [L1074](../Shared/Model/AppAPITypes.swift#L1074) | +| `chatItemReaction` | Reaction added/removed | [L1073](../Shared/Model/AppAPITypes.swift#L1073) | +| `contactConnected` | New contact connected | [L1062](../Shared/Model/AppAPITypes.swift#L1062) | +| `contactUpdated` | Contact profile changed | [L1066](../Shared/Model/AppAPITypes.swift#L1066) | +| `receivedGroupInvitation` | Group invitation received | [L1077](../Shared/Model/AppAPITypes.swift#L1077) | +| `groupMemberUpdated` | Group member info changed | [L1067](../Shared/Model/AppAPITypes.swift#L1067) | +| `callInvitation` | Incoming call | [L1115](../Shared/Model/AppAPITypes.swift#L1115) | +| `chatSuspended` | Core suspended (background) | [L1056](../Shared/Model/AppAPITypes.swift#L1056) | +| `rcvFileComplete` | File download finished | [L1099](../Shared/Model/AppAPITypes.swift#L1099) | +| `sndFileCompleteXFTP` | File upload finished | [L1110](../Shared/Model/AppAPITypes.swift#L1110) | + +Events are decoded as [`ChatEvent`](../Shared/Model/AppAPITypes.swift#L1055) enum in `Shared/Model/AppAPITypes.swift` and dispatched to update `ChatModel` / `ItemsModel` properties, triggering SwiftUI view re-renders via `@Published` property observation. + +--- + +## [4. Database Architecture](../SimpleXChat/FileUtils.swift#L70-L294) + +Two SQLite databases in the app group container (shared with NSE): + +| Database | File | Contents | +|----------|------|----------| +| Chat DB | `simplex_v1_chat.db` | Messages, contacts, groups, profiles, files, tags, preferences | +| Agent DB | `simplex_v1_agent.db` | SMP connections, keys, queues, server info | + +Both databases use the `DB_FILE_PREFIX = "simplex_v1"` prefix. The database path is resolved via [`getAppDatabasePath()`](../SimpleXChat/FileUtils.swift#L70) in `SimpleXChat/FileUtils.swift`, which checks `dbContainerGroupDefault` to determine whether to use the app group container or legacy documents directory. + +See [Database & Storage specification](database.md) for full details. + +--- + +## [5. App Lifecycle](../Shared/SimpleXApp.swift#L17-L184) + +### [Initialization Sequence (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L17-L38) + +```swift +// SimpleXApp.init() +1. haskell_init() // Initialize Haskell RTS (background queue, sync) +2. UserDefaults.register(defaults:) // Register app preference defaults +3. setGroupDefaults() // Sync preferences to app group container +4. setDbContainer() // Set database path L122 +5. BGManager.shared.register() // Register background task handlers +6. NtfManager.shared.registerCategories() // Register notification action categories +``` + +### State Transitions + +``` + ┌──────────┐ + │ Launched │ + └─────┬─────┘ + │ initChatAndMigrate() + v + ┌──────────┐ + │ DB Setup │ chat_migrate_init_key() + └─────┬─────┘ + │ startChat() SimpleXAPI.swift L2098 + v + ┌──────────┐ + │ Active │ apiActivateChat() SimpleXAPI.swift L358 + └─────┬─────┘ + │ scenePhase == .background + v + ┌──────────┐ + │Background │ apiSuspendChat(timeoutMicroseconds:) SimpleXAPI.swift L368 + └─────┬─────┘ + │ scenePhase == .active + v + ┌──────────┐ + │ Active │ startChatAndActivate() + └──────────┘ +``` + +### [Scene Phase Handling (SimpleXApp.swift)](../Shared/SimpleXApp.swift#L38-L123) + +- **`.active`**: Calls `startChatAndActivate()`, processes pending notification responses, refreshes chat list and call invitations +- **`.background`**: Records authentication timestamp, calls `suspendChat()` (unless CallKit call active), schedules `BGManager` background refresh, updates badge count +- **`.inactive`**: No explicit handling (transitional state) + +### CallKit Exception + +When a CallKit call is active during backgrounding, chat suspension is deferred (`CallController.shared.shouldSuspendChat = true`) until the call ends, to maintain the WebRTC session. + +--- + +## [6. Extension Architecture](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +### [Notification Service Extension (NSE)](../SimpleX%20NSE/NotificationService.swift#L1-L1228) + +The NSE ([`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228)) is a separate process that: + +1. Receives encrypted push notification payload from APNs +2. Initializes its own Haskell core instance (`chat_ctrl`) with shared database access +3. Decrypts the push payload using stored keys +4. Generates a visible `UNMutableNotificationContent` with the decrypted message preview +5. Delivers the notification to the user + +**Database sharing**: Both main app and NSE access the same database files in the app group container (`APP_GROUP_NAME`). Coordination uses file locks to prevent concurrent write conflicts. + +**Lifecycle**: The NSE has a ~30-second execution window per notification. It must initialize Haskell RTS, open the database, decrypt, and deliver within this window. + +### Share Extension (SE) + +The Share Extension (`SimpleX SE/`) allows sharing content (text, images, files) from other apps into SimpleX conversations. + +--- + +## [7. Remote Desktop Control](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545) + +Optional desktop pairing allows controlling the mobile app from a desktop client: + +- **Pairing**: Encrypted QR code scanned by desktop client establishes a session +- **Commands**: [`connectRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1613), [`findKnownRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1620), [`confirmRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1624), [`verifyRemoteCtrlSession`](../Shared/Model/SimpleXAPI.swift#L1630), [`listRemoteCtrls`](../Shared/Model/SimpleXAPI.swift#L1636), [`stopRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1642), [`deleteRemoteCtrl`](../Shared/Model/SimpleXAPI.swift#L1646) +- **State**: [`ChatModel.remoteCtrlSession`](../Shared/Model/ChatModel.swift#L395)`: RemoteCtrlSession?` tracks the active session +- **Transport**: Encrypted reverse HTTP transport between mobile and desktop +- **Source**: [`Shared/Views/RemoteAccess/ConnectDesktopView.swift`](../Shared/Views/RemoteAccess/ConnectDesktopView.swift#L1-L545), see `Remote.hs` in `../../src/Simplex/Chat/` + +--- + +## 8. Chat Relay Management + +### Overview + +Chat relays are SMP servers that forward messages to channel subscribers. They are configured in the Network & Servers settings and selected during channel creation. + +### Data Model + +| Type | Location | Description | +|------|----------|-------------| +| `UserChatRelay` | `ChatTypes.swift` | Relay server config: chatRelayId, address, name, domains, preset, tested, enabled, deleted | +| `UserOperatorServers.chatRelays` | `AppAPITypes.swift` | Array of `UserChatRelay` per operator | +| `UserServersWarning` | `AppAPITypes.swift` | Enum with `.noChatRelays(user:)` case | +| `ServerSettings.serverWarnings` | `ChatListView.swift` | `[UserServersWarning]` field on `ServerSettings` struct (exposed via `SaveableSettings.servers`) | + +### Relay Management Views + +| View | File | Description | +|------|------|-------------| +| `ChatRelayView` | `ChatRelayView.swift` | Edit/view relay: name, address, test, enable toggle, delete | +| `ChatRelayViewLink` | `ChatRelayView.swift` | NavigationLink row showing relay status icon + display name | +| `NewChatRelayView` | `ChatRelayView.swift` | Form to add new relay (name + address + test + enable toggle) | +| `ServersWarningView` | `NetworkAndServers.swift` | Orange exclamation triangle + warning text | + +### Key Functions + +| Function | File | Description | +|----------|------|-------------| +| `addChatRelay(...)` | `ChatRelayView.swift` | Validates name/address, appends to `userServers[nil operator].chatRelays`, calls `validateServers_` | +| `deleteChatRelay(...)` | `ProtocolServersView.swift` | Marks relay as deleted or removes if no `chatRelayId` | +| `validRelayName(_:)` | `ChatRelayView.swift` | Non-empty + valid display name check | +| `validRelayAddress(_:)` | `ChatRelayView.swift` | Parses via `parseSimpleXMarkdown`, validates `.simplexLink(_, .relay, _, _)` | +| `showRelayTestStatus(relay:)` | `ChatRelayView.swift` | ViewBuilder returning checkmark/multiply/clear icons | +| `validateServers_` | `NetworkAndServers.swift` | Extended signature: now accepts optional `Binding<[UserServersWarning]>?`; calls `validateServers` which returns `([UserServersError], [UserServersWarning])` tuple | +| `globalServersWarning(_:)` | `NetworkAndServers.swift` | Extracts `.noChatRelays` warning text for display | +| `bindingForChatRelays(_:_:)` | `NetworkAndServers.swift` | Creates binding for `chatRelays` at operator index | + +### Relay Sections in Settings + +"Chat relays" sections appear in: +- `OperatorView`: lists relays for the operator, with header and footer +- `YourServersView` (in `ProtocolServersView`): lists relays for non-operator servers, with delete support and "Add server" -> "Chat relay" option + +### serverWarnings Plumbing + +`Binding<[UserServersWarning]>` is threaded through: `NetworkAndServers` -> `OperatorView` -> `ProtocolServersView` -> `ProtocolServerView` / `NewServerView` / `ScanProtocolServer`. All `validateServers_` calls pass the warnings binding. + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| App entry point | [`Shared/SimpleXApp.swift`](../Shared/SimpleXApp.swift#L17) | L17 | +| App delegate | [`Shared/AppDelegate.swift`](../Shared/AppDelegate.swift#L15) | L15 | +| Root view | [`Shared/ContentView.swift`](../Shared/ContentView.swift#L24) | L24 | +| FFI bridge | [`Shared/Model/SimpleXAPI.swift`](../Shared/Model/SimpleXAPI.swift#L93) | L93 | +| Low-level FFI | [`SimpleXChat/API.swift`](../SimpleXChat/API.swift#L115) | L115 | +| App state | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L337) | L337 | +| API types | [`Shared/Model/AppAPITypes.swift`](../Shared/Model/AppAPITypes.swift#L15) | L15 | +| Shared types | [`SimpleXChat/APITypes.swift`](../SimpleXChat/APITypes.swift#L27) | L27 | +| C header | [`SimpleXChat/SimpleX.h`](../SimpleXChat/SimpleX.h#L1-L49) | | +| NSE | [`SimpleX NSE/NotificationService.swift`](../SimpleX%20NSE/NotificationService.swift#L1-L1228) | | +| Haskell core | `../../src/Simplex/Chat/Controller.hs` — see `processCommand` in `Controller.hs` | | +| Chat protocol (x-events, message envelopes) | `../../src/Simplex/Chat/Protocol.hs` | | + +### External: simplexmq Library + +The lower-level protocol and encryption layers are in the separate [simplexmq](https://github.com/simplex-chat/simplexmq) library: + +| Component | Spec | Implementation | +|-----------|------|----------------| +| SMP protocol | `simplexmq/protocol/simplex-messaging.md` | `simplexmq/src/Simplex/Messaging/Protocol.hs` | +| XFTP protocol | `simplexmq/protocol/xftp.md` | `simplexmq/src/Simplex/FileTransfer/Protocol.hs` | +| SMP Agent (duplex connections) | `simplexmq/protocol/agent-protocol.md` | `simplexmq/src/Simplex/Messaging/Agent.hs` | +| Double ratchet (PQDR) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/Ratchet.hs` | +| Post-quantum KEM (sntrup761) | `simplexmq/protocol/pqdr.md` | `simplexmq/src/Simplex/Messaging/Crypto/SNTRUP761.hs` | +| TLS transport | — | `simplexmq/src/Simplex/Messaging/Transport.hs` | +| File encryption | — | `simplexmq/src/Simplex/Messaging/Crypto/File.hs` | diff --git a/apps/ios/spec/client/chat-list.md b/apps/ios/spec/client/chat-list.md new file mode 100644 index 0000000000..d35de1f80a --- /dev/null +++ b/apps/ios/spec/client/chat-list.md @@ -0,0 +1,296 @@ +# SimpleX Chat iOS -- Chat List Module + +> Technical specification for the conversation list, filtering, search, swipe actions, and user picker. +> +> Related specs: [Chat View](chat-view.md) | [Navigation](navigation.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Chat List View](../../product/views/chat-list.md) + +**Source:** [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView](#2-chatlistview) +3. [ChatPreviewView](#3-chatpreviewview) +4. [ChatListNavLink](#4-chatlistnavlink) +5. [Filtering & Tags](#5-filtering--tags) +6. [Search](#6-search) +7. [Swipe Actions](#7-swipe-actions) +8. [UserPicker](#8-userpicker) +9. [Floating Action Button](#9-floating-action-button) + +--- + +## 1. Overview + +The chat list is the main screen of the app, displaying all conversations for the current user. It provides: + +- Conversation previews with unread badges +- Filter tabs (All, Unread, Favorites, Groups, Contacts, Business, user-defined tags) +- Search across chat names and message content +- Swipe actions for quick operations +- User profile switcher +- Floating action button for new conversations + +``` +ChatListView +├── Navigation Bar +│ ├── User avatar (tap → UserPicker) +│ └── Filter tabs (TagListView) +├── Search bar (on pull-down or tap) +├── Chat List (List/LazyVStack) +│ └── ChatListNavLink (per conversation) +│ └── ChatPreviewView +│ ├── Avatar +│ ├── Chat name + last message preview +│ ├── Timestamp +│ └── Unread badge +├── FAB (New Chat button) +└── Pending connection cards +``` + +--- + +## 2. [`ChatListView`](../../Shared/Views/ChatList/ChatListView.swift#L142) {#2-chatlistview} + +**File**: `Shared/Views/ChatList/ChatListView.swift` + +The root list view. Key responsibilities: + +### Data Source +- Reads `ChatModel.shared.chats` (all conversations) +- Applies active filter from `ChatTagsModel.shared.activeFilter` +- Applies search query filtering via [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) +- Sorts by last activity (most recent first), with pinned chats at top + +### Layout +- Uses SwiftUI `List` with `ForEach` over filtered chats +- Each row is a `ChatListNavLink` wrapping a `ChatPreviewView` +- Pull-to-refresh triggers `updateChats()` API call +- Empty state: `ChatHelp` view with getting-started guidance + +### Connection Cards +- Pending contact connections (`ChatInfo.contactConnection`) shown as cards +- Contact requests (`ChatInfo.contactRequest`) shown with accept/reject UI via `ContactRequestView` + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/ChatList/ChatListView.swift#L168) | 163 | Main view body | +| [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) | 472 | Applies active filter and search to chat list | +| [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) | 514 | Normalizes search text for comparison | +| [`unreadBadge()`](../../Shared/Views/ChatList/ChatListView.swift#L454) | 448 | Renders unread count circle badge | +| [`stopAudioPlayer()`](../../Shared/Views/ChatList/ChatListView.swift#L474) | 467 | Stops any playing voice message | + +--- + +## 3. [`ChatPreviewView`](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) {#3-chatpreviewview} + +**File**: `Shared/Views/ChatList/ChatPreviewView.swift` + +Renders a single row in the chat list. Shows: + +| Element | Source | Description | +|---------|--------|-------------| +| Avatar | `chatInfo.image` | Profile image or default icon | +| Chat name | `chatInfo.displayName` | Contact name, group name, or connection label | +| Last message | `chat.chatItems.last` | Preview text of most recent message | +| Timestamp | `chat.chatItems.last?.timestampText` | Relative time of last message | +| Unread badge | `chat.chatStats.unreadCount` | Circular badge with unread count | +| Mute icon | `chatInfo.chatSettings?.enableNtfs` | Bell-slash icon if notifications muted | +| Pin icon | -- | Pin indicator for pinned chats | +| Incognito icon | Contact.contactConnIncognito | Incognito mode indicator | +| Delivery status | Last sent item's `meta.itemStatus` | Check marks for delivery confirmation | + +### Preview Text Rendering +- Text messages: first line of message content +- Images: camera icon + caption (if any) +- Files: paperclip icon + filename +- Voice: microphone icon + duration +- Calls: phone icon + call status +- Group events: system event description +- Encrypted/deleted: placeholder text + +--- + +## 4. [`ChatListNavLink`](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) {#4-chatlistnavlink} + +**File**: `Shared/Views/ChatList/ChatListNavLink.swift` + +Wraps `ChatPreviewView` in a navigation link with tap and swipe behavior: + +### Tap Behavior +- Direct chat: navigates to `ChatView` via `ItemsModel.loadOpenChat(chatId)` -- [`contactNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L95) L93 +- Group chat: navigates to `ChatView` -- [`groupNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L217) L214 +- Contact request: shows `ContactRequestView` with accept/reject -- [`contactRequestNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L495) L486 +- Contact connection: shows `ContactConnectionInfo` -- [`contactConnectionNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L530) L520 +- Notes folder: navigates to `ChatView` -- [`noteFolderNavLink()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L302) L298 + +### Navigation +- Uses `NavigationLink` (iOS 15) or programmatic navigation (iOS 16+) +- Sets `ChatModel.chatId` to trigger navigation +- `ItemsModel.loadOpenChat()` loads messages with a 250ms navigation delay for smooth animation + +### Channel Adaptations in ChatListNavLink + +When `groupInfo.useRelays == true`: + +| Change | Behavior | +|--------|----------| +| Swipe "Leave" | Hidden when `useRelays && isOwner` | +| Context menu "Leave" | Hidden under same condition | +| `deleteGroupAlert` label | "Delete channel?" | +| `leaveGroupAlert` title | "Leave channel?" | +| `leaveGroupAlert` message | "You will stop receiving messages from this channel. Chat history will be preserved." | + +### ServerSettings + +`ServerSettings` struct (defined in `ChatListView.swift`) includes `serverWarnings: [UserServersWarning]` field, initialized to `[]`. This field stores validation warnings from `validateServers` and is consumed by NetworkAndServers views. + +--- + +## 5. Filtering & Tags + +### Filter Tabs ([`TagListView`](../../Shared/Views/ChatList/TagListView.swift#L20)) + +**File**: `Shared/Views/ChatList/TagListView.swift` + +Horizontal scrolling tab bar below the navigation bar. Tabs: + +| Tab | Filter | Shows | +|-----|--------|-------| +| All | `nil` | All conversations | +| Unread | `.unread` | Conversations with unread messages | +| Favorites | `.presetTag(.favorites)` | Favorited conversations | +| Groups | `.presetTag(.groups)` | Group conversations | +| Contacts | `.presetTag(.contacts)` | Direct conversations | +| Business | `.presetTag(.business)` | Business conversations | +| Group Reports | `.presetTag(.groupReports)` | Groups with pending reports | +| User tags | `.userTag(ChatTag)` | User-defined custom tags | + +Filter matching is handled by [`presetTagMatchesChat()`](../../Shared/Views/ChatList/ChatListView.swift#L910) (L910) and the in-view [`TagsView`](../../Shared/Views/ChatList/ChatListView.swift#L705) struct (L705). + +### ChatTagsModel State + +Filtering state is managed by [`ChatTagsModel`](../../Shared/Model/ChatModel.swift#L189) (`ChatModel.swift` L183): + +```swift +class ChatTagsModel: ObservableObject { + @Published var userTags: [ChatTag] = [] + @Published var activeFilter: ActiveFilter? = nil + @Published var presetTags: [PresetTag: Int] = [:] // count per preset tag + @Published var unreadTags: [Int64: Int] = [:] // unread count per user tag +} +``` + +- `presetTags` counts are updated whenever `chats` changes via [`updateChatTags()`](../../Shared/Model/ChatModel.swift#L197) (L197) +- Tags with zero matching chats are auto-hidden +- Active filter is auto-cleared when its tag has no matching chats + +### Supporting Types + +| Type | File | Line | Description | +|------|------|------|-------------| +| [`PresetTag`](../../Shared/Views/ChatList/ChatListView.swift#L36) | ChatListView.swift | 34 | Enum of built-in filter categories | +| [`ActiveFilter`](../../Shared/Views/ChatList/ChatListView.swift#L52) | ChatListView.swift | 49 | Enum wrapping preset, user-tag, or unread filter | +| [`setActiveFilter()`](../../Shared/Views/ChatList/ChatListView.swift#L889) | ChatListView.swift | 878 | Applies a filter and persists selection | + +### Tag Management Commands +- `apiCreateChatTag(tag: ChatTagData)` -- create tag +- `apiSetChatTags(type:, id:, tagIds:)` -- assign tags to a chat +- `apiDeleteChatTag(tagId:)` -- delete tag +- `apiUpdateChatTag(tagId:, tagData:)` -- rename tag +- `apiReorderChatTags(tagIds:)` -- reorder tags + +--- + +## 6. Search + +Search is available via pull-down gesture or search button in the navigation bar. + +**Search bar UI:** [`ChatListSearchBar`](../../Shared/Views/ChatList/ChatListView.swift#L587) (ChatListView.swift L578) + +### Filtering Logic +- Filters `ChatModel.chats` by matching search text against: + - `chatInfo.displayName` (contact/group name) + - `chatInfo.localAlias` (local alias) + - `chatInfo.fullName` (full name) +- For deeper message content search, uses `apiGetChat(chatId:, search:)` parameter +- Core logic in [`filteredChats()`](../../Shared/Views/ChatList/ChatListView.swift#L480) (L480) and [`searchString()`](../../Shared/Views/ChatList/ChatListView.swift#L523) (L523) + +### Search Results +- Matching chats are displayed in the same list format +- Results update as the user types (debounced) +- Clearing search restores the full filtered list + +--- + +## 7. Swipe Actions + +`ChatListNavLink` provides swipe actions on each row: + +### Leading Swipe (left-to-right) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Pin / Unpin | pin | [`toggleFavoriteButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L353) | 347 | `apiSetChatSettings` (favorite) | Always | +| Read / Unread | envelope | [`markReadButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L333) | 328 | `apiChatRead` / `apiChatUnread` | Always | + +### Trailing Swipe (right-to-left) + +| Action | Icon | Handler | Line | API | Condition | +|--------|------|---------|------|-----|-----------| +| Mute / Unmute | bell.slash | [`toggleNtfsButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L372) | 365 | `apiSetChatSettings` (enableNtfs) | Always | +| Clear | trash | [`clearChatButton()`](../../Shared/Views/ChatList/ChatListNavLink.swift#L393) | 385 | `apiClearChat` | Has messages | +| Delete | trash.fill | -- | -- | `apiDeleteChat` | Not active chat | +| Tag | tag | -- | -- | `apiSetChatTags` | Always | + +--- + +## 8. [`UserPicker`](../../Shared/Views/ChatList/UserPicker.swift#L10) {#8-userpicker} + +**File**: `Shared/Views/ChatList/UserPicker.swift` + +Triggered by tapping the user avatar in the navigation bar. Presented as a sheet with: + +| Section | Contents | +|---------|----------| +| User list | All non-hidden users with unread counts | +| Active user | Highlighted with checkmark | +| Actions | Settings, Your SimpleX address, User profiles | + +### User Switching +- Tapping a different user calls `apiSetActiveUser(userId:)` +- Triggers `apiGetChats` for the new user +- `ChatModel.currentUser` updates, causing full UI refresh +- Hidden users are not shown (require password entry via settings) + +--- + +## 9. Floating Action Button + +The FAB (floating action button) in the bottom-right corner opens the new chat flow: + +- Tap: opens `NewChatView` sheet for creating a new contact connection or group +- Shows options: Create link, Scan QR code, Paste link, Create group + +--- + +## Source Files + +| File | Path | Key struct | Line | +|------|------|------------|------| +| Chat list view | [`ChatListView.swift`](../../Shared/Views/ChatList/ChatListView.swift) | `ChatListView` | [138](../../Shared/Views/ChatList/ChatListView.swift#L142) | +| Chat preview row | [`ChatPreviewView.swift`](../../Shared/Views/ChatList/ChatPreviewView.swift) | `ChatPreviewView` | [12](../../Shared/Views/ChatList/ChatPreviewView.swift#L13) | +| Navigation link wrapper | [`ChatListNavLink.swift`](../../Shared/Views/ChatList/ChatListNavLink.swift) | `ChatListNavLink` | [43](../../Shared/Views/ChatList/ChatListNavLink.swift#L44) | +| Tag filter tabs | [`TagListView.swift`](../../Shared/Views/ChatList/TagListView.swift) | `TagListView` | [19](../../Shared/Views/ChatList/TagListView.swift#L20) | +| User picker sheet | [`UserPicker.swift`](../../Shared/Views/ChatList/UserPicker.swift) | `UserPicker` | [9](../../Shared/Views/ChatList/UserPicker.swift#L10) | +| Getting started help | [`ChatHelp.swift`](../../Shared/Views/ChatList/ChatHelp.swift) | | | +| Contact request view | [`ContactRequestView.swift`](../../Shared/Views/ChatList/ContactRequestView.swift) | | | +| Contact connection info | [`ContactConnectionInfo.swift`](../../Shared/Views/ChatList/ContactConnectionInfo.swift) | | | +| Contact connection view | [`ContactConnectionView.swift`](../../Shared/Views/ChatList/ContactConnectionView.swift) | | | +| Server summary | [`ServersSummaryView.swift`](../../Shared/Views/ChatList/ServersSummaryView.swift) | | | +| One-hand UI card | [`OneHandUICard.swift`](../../Shared/Views/ChatList/OneHandUICard.swift) | | | diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md new file mode 100644 index 0000000000..182e7b7ce9 --- /dev/null +++ b/apps/ios/spec/client/chat-view.md @@ -0,0 +1,391 @@ +# SimpleX Chat iOS -- Chat View Module + +> Technical specification for the message rendering, chat item types, and context menu actions in the conversation view. +> +> Related specs: [Compose Module](compose.md) | [State Management](../state.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [`ChatInfoView.swift`](../../Shared/Views/Chat/ChatInfoView.swift) | [`GroupChatInfoView.swift`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift) | [`ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [`ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView](#2-chatview) +3. [ChatItemView -- Message Routing](#3-chatitemview) +4. [Message Renderers](#4-message-renderers) +5. [Media Views](#5-media-views) +6. [Metadata & Info](#6-metadata--info) +7. [Context Menu Actions](#7-context-menu-actions) +8. [Selection Mode](#8-selection-mode) + +--- + +## 1. Overview + +The chat view module renders individual conversations. It consists of: + +- **ChatView** -- The main conversation screen with message list, compose bar, and navigation +- **ChatItemView** -- Router that dispatches each chat item to the appropriate renderer +- **Specialized renderers** -- FramedItemView (standard messages), EmojiItemView (emoji-only), CICallItemView (calls), event views, etc. +- **Media views** -- CIImageView, CIVideoView, CIVoiceView, CIFileView for attachments + +``` +ChatView +├── Message List (ScrollView / LazyVStack) +│ ├── ChatItemView (per message) +│ │ ├── FramedItemView (text/media bubbles) +│ │ │ ├── MsgContentView (text with markdown) +│ │ │ ├── CIImageView / CIVideoView / CIVoiceView +│ │ │ └── CIMetaView (timestamp, status) +│ │ ├── EmojiItemView (emoji-only messages) +│ │ ├── CICallItemView (call events) +│ │ ├── CIEventView (system events) +│ │ ├── CIGroupInvitationView (group invitations) +│ │ ├── DeletedItemView / MarkedDeletedItemView +│ │ └── CIInvalidJSONView (decode errors) +│ └── ... (more items) +├── ComposeView (message input) +└── Navigation bar (contact/group info) +``` + +--- + +## [2. ChatView](../../Shared/Views/Chat/ChatView.swift#L18-L3210) + +**File**: [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) + +The main conversation view. Key responsibilities: + +### State +- Uses `ItemsModel.shared.reversedChatItems` for the primary message list +- `ChatModel.shared.chatId` identifies the active conversation +- Manages compose state, scroll position, keyboard visibility +- Tracks selection mode for multi-message actions + +### Message List +- Renders messages in a `ScrollViewReader` with `LazyVStack` +- Items are in reverse chronological order (newest at bottom) +- Supports infinite scroll: preloads older messages when scrolling up via `ItemsModel.preloadState` +- Handles pagination splits (`chatState.splits`) for non-contiguous loaded ranges + +### Navigation Bar +- Title: contact name / group name with connection status indicator +- Trailing button: navigates to [`ChatInfoView`](../../Shared/Views/Chat/ChatInfoView.swift#L93) (direct) or [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) (group) +- Search button: toggles in-chat message search + +### Scroll Behavior +- Auto-scrolls to bottom on new sent/received messages (if already near bottom) +- "Scroll to bottom" floating button when scrolled up +- `openAroundItemId` support: scrolls to a specific message (e.g., from search or notification) + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ChatView.swift#L75) | L75 | Main view body | +| [`initChatView()`](../../Shared/Views/Chat/ChatView.swift#L660) | L660 | Initializes chat view state on appear | +| [`chatItemsList()`](../../Shared/Views/Chat/ChatView.swift#L817) | L817 | Builds the scrollable message list | +| [`scrollToItem(_:)`](../../Shared/Views/Chat/ChatView.swift#L731) | L731 | Scrolls to a specific message by ID | +| [`searchToolbar()`](../../Shared/Views/Chat/ChatView.swift#L765) | L765 | In-chat search toolbar UI | +| [`searchTextChanged(_:)`](../../Shared/Views/Chat/ChatView.swift#L1095) | L1095 | Handles search query changes | +| [`loadChatItems(_:_:)`](../../Shared/Views/Chat/ChatView.swift#L1531) | L1531 | Loads chat items with pagination | +| [`filtered(_:)`](../../Shared/Views/Chat/ChatView.swift#L803) | L803 | Filters items by content type | +| [`callButton(_:_:imageName:)`](../../Shared/Views/Chat/ChatView.swift#L1273) | L1273 | Audio/video call toolbar button | +| [`searchButton()`](../../Shared/Views/Chat/ChatView.swift#L1293) | L1293 | Search toggle toolbar button | +| [`addMembersButton()`](../../Shared/Views/Chat/ChatView.swift#L1361) | L1361 | Group add-members toolbar button | +| [`forwardSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1420) | L1420 | Forwards batch-selected messages | +| [`deletedSelectedMessages()`](../../Shared/Views/Chat/ChatView.swift#L1411) | L1411 | Deletes batch-selected messages | +| [`onChatItemsUpdated()`](../../Shared/Views/Chat/ChatView.swift#L1572) | L1572 | Reacts to chat items model changes | +| [`contentFilterMenu(withLabel:)`](../../Shared/Views/Chat/ChatView.swift#L1301) | L1301 | Content filter dropdown menu | + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) | L1600 | Wraps each chat item with context menu | +| [`FloatingButtonModel`](../../Shared/Views/Chat/ChatView.swift#L2787) | L2787 | Manages scroll-to-bottom button state | +| [`ReactionContextMenu`](../../Shared/Views/Chat/ChatView.swift#L2974) | L2974 | Reaction picker context menu | +| [`ToggleNtfsButton`](../../Shared/Views/Chat/ChatView.swift#L3072) | L3072 | Mute/unmute notifications button | +| [`ContentFilter`](../../Shared/Views/Chat/ChatView.swift#L3124) | L3124 | Enum for message content filter types | +| [`deleteMessages()`](../../Shared/Views/Chat/ChatView.swift#L2870) | L2870 | Deletes messages with confirmation | +| [`archiveReports()`](../../Shared/Views/Chat/ChatView.swift#L2917) | L2917 | Archives report messages | + +--- + +## [3. ChatItemView](../../Shared/Views/Chat/ChatItemView.swift#L42) + +**File**: [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) + +Routes each `ChatItem` to the appropriate renderer based on its `CIContent` type: + +### Content Types (CIContent enum) + +| Content Type | Renderer | Line | Description | +|-------------|----------|------|-------------| +| `sndMsgContent` / `rcvMsgContent` | [`FramedItemView`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | L14 | Standard sent/received text+media message | +| `sndDeleted` / `rcvDeleted` | [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | L14 | Locally deleted message placeholder | +| `sndCall` / `rcvCall` | [`CICallItemView`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | L13 | Call event (missed, ended, duration) | +| `rcvIntegrityError` | [`IntegrityErrorItemView`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | L14 | Message integrity error | +| `rcvDecryptionError` | [`CIRcvDecryptionError`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | L16 | Decryption failure | +| `sndGroupInvitation` / `rcvGroupInvitation` | [`CIGroupInvitationView`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | L14 | Group invite | +| `sndGroupEvent` / `rcvGroupEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Group system event | +| `rcvConnEvent` / `sndConnEvent` | [`CIEventView`](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | L14 | Connection event | +| `rcvChatFeature` / `sndChatFeature` | [`CIChatFeatureView`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | L14 | Feature toggle event | +| `rcvChatPreference` / `sndChatPreference` | [`CIFeaturePreferenceView`](../../Shared/Views/Chat/ChatItem/CIFeaturePreferenceView.swift#L14) | L14 | Preference change | +| `invalidJSON` | [`CIInvalidJSONView`](../../Shared/Views/Chat/ChatItem/CIInvalidJSONView.swift#L14) | L14 | Failed to decode | + +### Bubble Direction +- Sent messages: aligned right, sender-colored bubble +- Received messages: aligned left, receiver-colored bubble +- Events/system messages: centered, no bubble + +### Appearance Dependencies +Each [`ChatItemWithMenu`](../../Shared/Views/Chat/ChatView.swift#L1600) may depend on the previous and next items for visual decisions: +- Whether to show the sender name (group messages, different sender than previous) +- Whether to show the tail on the bubble (last consecutive message from same sender) +- Date separator between messages on different days + +`ChatItemDummyModel.shared.sendUpdate()` forces a re-render of all items when global appearance changes. + +### Channel Message Rendering (`.channelRcv`) + +Channel messages (`CIDirection.channelRcv`) are rendered with the group avatar and group name as sender, with "channel" as the role label. This mirrors the `.groupRcv` path's `showGroupAsSender` visual but uses a dedicated code branch in [`chatItemListView()`](../../Shared/Views/Chat/ChatView.swift#L1846). + +Key differences from `.groupRcv`: +- No `prevMember`/`memCount` logic — channels have no per-member identity +- Always shows group avatar (via `ProfileImage` with `groupInfo.image` / `groupInfo.chatIconName`) +- Tapping avatar opens `showChatInfoSheet` (not member info) +- [`shouldShowAvatar()`](../../Shared/Views/Chat/ChatView.swift#L1670) treats consecutive `.channelRcv` items as same sender +- [`getItemSeparation()`](../../Shared/Views/Chat/ChatView.swift#L1649) treats consecutive `.channelRcv` items as `sameMemberAndDirection` +- [`showMemberImage()`](../../Shared/Views/Chat/ChatView.swift#L2116) returns `true` when previous item is `.channelRcv` (different sender type) +- [`memberToModerate()`](../../SimpleXChat/ChatTypes.swift#L3297) returns `nil` for `.channelRcv` (no per-member moderation) + +--- + +## 4. Message Renderers + +### [FramedItemView](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) + +The standard message bubble. Renders: +- Quote/reply preview (if replying to another message) +- Forwarded indicator +- Sender name (in groups) +- Message content (`MsgContentView` with markdown) +- Attached media (image, video, voice, file, link preview) +- Reaction summary bar +- Metadata line (`CIMetaView`) + +### [EmojiItemView](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) + +Renders emoji-only messages (messages containing only emoji characters) in a larger font without a bubble background. + +### [MsgContentView](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) + +**File**: [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) + +Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). + +### DeletedItemView / MarkedDeletedItemView + +**Files**: [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) + +- [`DeletedItemView`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14): Placeholder for locally deleted messages +- [`MarkedDeletedItemView`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14): Shows "message deleted" with optional moderation info (who deleted, when) + +### [CIEventView](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) + +Centered system event text for group events (member joined, left, role changed) and connection events. + +### [CIGroupInvitationView](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) + +Renders group invitation with accept/reject buttons. + +--- + +## 5. Media Views + +### [CIImageView](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) + +Renders inline images. Tapping opens `FullScreenMediaView` for zooming/panning. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [CIVideoView](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) + +Renders video thumbnails with play button. Tapping opens video player. Videos above auto-receive threshold require manual download. + +### CIVoiceView / FramedCIVoiceView + +**Files**: [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) + +Renders voice messages with waveform visualization, play/pause control, and duration. [`FramedCIVoiceView`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) is the version inside a message bubble with additional context. + +### [CIFileView](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) + +Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download. + +### [CILinkView](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) + +Renders link preview cards with OpenGraph metadata (title, description, image). + +### [AnimatedImageView](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) + +**File**: [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) + +Renders animated GIF images. + +### [FullScreenMediaView](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) + +**File**: [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) + +Full-screen media viewer with zoom, pan, and share actions. Supports images and videos. + +--- + +## 6. Metadata & Info + +### [CIMetaView](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) + +**File**: [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) + +Displays message metadata inline at the bottom of the bubble: +- Timestamp (sent time) +- Delivery status icon (sending, sent, delivered, read, error) +- Edit indicator (pencil icon if message was edited) +- Disappearing message timer (if timed message) + +### [ChatItemInfoView](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) + +**File**: [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) + +Detailed message information sheet (accessed via long-press menu "Info"): +- Full delivery history (per-member delivery status in groups) +- Edit history (all previous versions of edited messages) +- Forward chain info +- Message timestamps (created, updated, deleted) + +--- + +## 7. Context Menu Actions + +Long-pressing a message shows a context menu with actions based on message type and ownership: + +| Action | Available For | API Command | +|--------|--------------|-------------| +| Reply | All messages | Sets compose state to `.replying` | +| Forward | Sent/received content messages | `apiForwardChatItems` | +| Copy | Text messages | Copies to clipboard | +| Edit | Own sent messages (within edit window) | `apiUpdateChatItem` | +| Delete for me | All messages | `apiDeleteChatItem(mode: .cidmInternal)` | +| Delete for everyone | Own sent messages | `apiDeleteChatItem(mode: .cidmBroadcast)` | +| Moderate | Group admin/owner for others' messages | `apiDeleteMemberChatItem` | +| React | Content messages (if reactions enabled) | `apiChatItemReaction` | +| Select | All messages | Enters multi-select mode | +| Info | All messages | Opens [`ChatItemInfoView`](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| Save | Media messages | Saves to photo library / files | +| Share | Content messages | iOS share sheet | + +--- + +## 8. Selection Mode + +Multi-selection mode allows batch operations on messages: + +- Enter via long-press "Select" action +- Toggle individual messages with tap +- Toolbar appears with batch actions: Delete, Forward +- Exit via cancel button or completing batch action + +--- + +## GroupChatInfoView — Channel Adaptations + +When `groupInfo.useRelays == true`, [`GroupChatInfoView`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L16) adapts its sections: + +### Section Structure (Channel) + +| Section | Owner | Subscriber | +|---------|-------|-----------| +| 1. Links & Members | Channel link (manage via GroupLinkView), Owners & subscribers | Channel link (read-only QR from `groupProfile.publicGroup?.groupLink`), Owners | +| 2. Profile & Welcome | Edit channel profile, Welcome message | Welcome message (if exists) | +| 3. Theme & TTL | Chat theme, Delete messages after | Chat theme, Delete messages after | +| 4. Actions | Chat relays, Clear chat, Delete channel | Chat relays, Clear chat, Leave channel | + +**Hidden for channels:** Member support, group reports, user support chat, send receipts, inline members list, group preferences. + +### Label Replacements + +All "group" labels are replaced with "channel" equivalents via `groupInfo.useRelays ? "Channel..." :` ternary prepended before existing `businessChat` ternary. Affected: delete/leave buttons, delete/leave alerts, remove member alert, edit profile button, group link nav title. Channel link button uses a separate `channelLinkButton()` with hardcoded "Channel link" label. + +### [`channelMembersButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L627) → [`ChannelMembersView`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) + +Navigates to a dedicated members view with two sections: +- **Owners**: current user (if owner) + members with `memberRole >= .owner` +- **Subscribers** (admin+ only): members with `memberRole < .owner` + +Member rows show profile image, display name (with verified shield), connection status, and role badge. Non-user rows link to `GroupMemberInfoView`. + +### Channel Link + +Owner sees [`channelLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L605) (navigates to `GroupLinkView` for full link management), guarded by `groupInfo.isOwner && groupLink != nil` — channel links can only be created during channel creation, not from the info view. A TODO marks the need for protocol changes to allow other owners to manage the same channel link. Non-owner sees read-only QR code displaying `groupProfile.publicGroup?.groupLink` via `SimpleXLinkQRCode`. `apiGetGroupLink` is skipped in `onAppear` for non-owner channels. + +Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L593) which supports both "Create group link" and "Group link" labels. + +### [`channelRelaysButton()`](../../Shared/Views/Chat/Group/GroupChatInfoView.swift#L639) → [`ChannelRelaysView`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) + +Navigates to relay list view with role-based branches: +- **Owner**: loads `[GroupRelay]` via [`apiGetGroupRelays`](../../Shared/Model/SimpleXAPI.swift#L1839) (owner-only API, guarded by `assertUserGroupRole GROwner` on backend). Joins with `chatModel.groupMembers` by `groupMemberId` for display names. Shows status indicators (colored circle + `RelayStatus.text`). +- **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). + +### Leave Button Logic + +Sole channel owner cannot leave (only delete). Guard: `members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0`. + +--- + +## Source Files + +| File | Path | Line | +|------|------|------| +| Chat view | [`Shared/Views/Chat/ChatView.swift`](../../Shared/Views/Chat/ChatView.swift) | [L18](../../Shared/Views/Chat/ChatView.swift#L18) | +| Item router | [`Shared/Views/Chat/ChatItemView.swift`](../../Shared/Views/Chat/ChatItemView.swift) | [L42](../../Shared/Views/Chat/ChatItemView.swift#L42) | +| Framed bubble | [`Shared/Views/Chat/ChatItem/FramedItemView.swift`](../../Shared/Views/Chat/ChatItem/FramedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/FramedItemView.swift#L14) | +| Emoji message | [`Shared/Views/Chat/ChatItem/EmojiItemView.swift`](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/EmojiItemView.swift#L14) | +| Image view | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIImageView.swift#L14) | +| Video view | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIVideoView.swift#L16) | +| Voice view | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift#L14) | +| File view | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIFileView.swift#L14) | +| Link preview | [`Shared/Views/Chat/ChatItem/CILinkView.swift`](../../Shared/Views/Chat/ChatItem/CILinkView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CILinkView.swift#L14) | +| Call event | [`Shared/Views/Chat/ChatItem/CICallItemView.swift`](../../Shared/Views/Chat/ChatItem/CICallItemView.swift) | [L13](../../Shared/Views/Chat/ChatItem/CICallItemView.swift#L13) | +| Metadata | [`Shared/Views/Chat/ChatItem/CIMetaView.swift`](../../Shared/Views/Chat/ChatItem/CIMetaView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMetaView.swift#L14) | +| Message info | [`Shared/Views/Chat/ChatItemInfoView.swift`](../../Shared/Views/Chat/ChatItemInfoView.swift) | [L13](../../Shared/Views/Chat/ChatItemInfoView.swift#L13) | +| System event | [`Shared/Views/Chat/ChatItem/CIEventView.swift`](../../Shared/Views/Chat/ChatItem/CIEventView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIEventView.swift#L14) | +| Deleted placeholder | [`Shared/Views/Chat/ChatItem/DeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/DeletedItemView.swift#L14) | +| Moderated placeholder | [`Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift`](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift#L14) | +| Text content | [`Shared/Views/Chat/ChatItem/MsgContentView.swift`](../../Shared/Views/Chat/ChatItem/MsgContentView.swift) | [L28](../../Shared/Views/Chat/ChatItem/MsgContentView.swift#L28) | +| Group invitation | [`Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift`](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift#L14) | +| Feature event | [`Shared/Views/Chat/ChatItem/CIChatFeatureView.swift`](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIChatFeatureView.swift#L14) | +| Decryption error | [`Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift`](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift) | [L16](../../Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift#L16) | +| Integrity error | [`Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift`](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift) | [L14](../../Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift#L14) | +| Full-screen media | [`Shared/Views/Chat/ChatItem/FullScreenMediaView.swift`](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FullScreenMediaView.swift#L16) | +| Animated image | [`Shared/Views/Chat/ChatItem/AnimatedImageView.swift`](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift) | [L11](../../Shared/Views/Chat/ChatItem/AnimatedImageView.swift#L11) | +| Framed voice | [`Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift) | [L16](../../Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift#L16) | +| Member contact | [`Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift`](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift) | [L14](../../Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift#L14) | +| Channel members | [`Shared/Views/Chat/Group/ChannelMembersView.swift`](../../Shared/Views/Chat/Group/ChannelMembersView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelMembersView.swift#L12) | +| Channel relays | [`Shared/Views/Chat/Group/ChannelRelaysView.swift`](../../Shared/Views/Chat/Group/ChannelRelaysView.swift) | [L12](../../Shared/Views/Chat/Group/ChannelRelaysView.swift#L12) | diff --git a/apps/ios/spec/client/compose.md b/apps/ios/spec/client/compose.md new file mode 100644 index 0000000000..f86e323ade --- /dev/null +++ b/apps/ios/spec/client/compose.md @@ -0,0 +1,372 @@ +# SimpleX Chat iOS -- Message Composition Module + +> Technical specification for the compose bar, attachment types, reply/edit/forward modes, voice recording, and mentions. +> +> Related specs: [Chat View](chat-view.md) | [File Transfer](../services/files.md) | [API Reference](../api.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeView](#2-composeview) +3. [ComposeState Machine](#3-composestate-machine) +4. [Attachment Types](#4-attachment-types) +5. [Reply Mode](#5-reply-mode) +6. [Edit Mode](#6-edit-mode) +7. [Forward Mode](#7-forward-mode) +8. [Live Messages](#8-live-messages) +9. [Voice Recording](#9-voice-recording) +10. [Link Previews](#10-link-previews) +11. [Mentions](#11-mentions) + +--- + +## 1. Overview + +The compose module handles all message creation, editing, and forwarding. It sits at the bottom of `ChatView` and adapts its UI based on the current compose state. + +``` +ComposeView +├── Context banner (reply quote / edit indicator / forward indicator) +├── Attachment preview (image / video / file / voice waveform) +├── Text input (NativeTextEditor with markdown support) +├── Action buttons +│ ├── Attachment menu (camera, photo library, file picker) +│ ├── Voice record button (hold or toggle) +│ └── Send button (or live message indicator) +└── Link preview (auto-generated when URL detected) +``` + +--- + +## 2. [ComposeView](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) (`struct ComposeView: View`) + +**File**: `Shared/Views/Chat/ComposeMessage/ComposeView.swift` + +### Layout +- Fixed at the bottom of ChatView +- Expands vertically as text input grows (up to a maximum height) +- Context banner appears above the text field when in reply/edit/forward mode +- Attachment preview appears between context banner and text field + +### Key Properties +- Reads `ChatModel.shared.draft` / `draftChatId` for persisted drafts +- Manages its own internal compose state +- Coordinates with `ChatView` for scroll-to-bottom behavior on send + +### Send Flow +1. User taps send button +2. ComposeView constructs `[ComposedMessage]` from current state +3. Calls `apiSendMessages(type:, id:, scope:, live:, ttl:, composedMessages:)` +4. On success: clears compose state, scrolls to bottom +5. On failure: shows error alert, preserves compose state + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`body`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L371) | L371 | Main view body | +| [`sendMessageView()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L870) | L870 | Builds the send-message UI | +| [`sendMessage(ttl:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1286) | L1286 | Entry point: initiates send | +| [`sendMessageAsync()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1295) | L1295 | Async send implementation | +| [`clearState(live:)`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1649) | L1649 | Resets compose state after send | +| [`addMediaContent()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1073) | L1073 | Adds media attachment | +| [`connectCheckLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1046) | L1046 | Checks link preview before connect | +| [`commandsButton()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L931) | L931 | Builds commands menu button | + +### Draft Persistence + +| Function | Line | Description | +|----------|------|-------------| +| [`saveCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1663) | L1663 | Saves compose state to `ChatModel.draft` | +| [`clearCurrentDraft()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1669) | L1669 | Clears persisted draft | + +- When navigating away from a chat, compose state is saved to `ChatModel.draft` / `ChatModel.draftChatId` +- When returning to the same chat, draft is restored +- Drafts are not persisted across app restarts + +--- + +## 3. [ComposeState](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) Machine (`struct ComposeState`) + +The compose bar operates as a state machine with these primary states: + +``` + ┌──────────┐ + │ .empty │ ← initial / after send + └─────┬────┘ + │ user types / attaches / quotes + v + ┌─────────────────────────────────────┐ + │ │ + ┌────▼────┐ ┌──────────────┐ ┌──────────▼───┐ + │ .text │ │ .mediaPending │ │ .voiceRecording │ + └─────────┘ └──────────────┘ └───────────────┘ + │ │ + │ long-press reply│ tap edit + v v + ┌──────────┐ ┌──────────┐ ┌───────────┐ + │ .replying │ │ .editing │ │ .forwarding│ + └──────────┘ └──────────┘ └───────────┘ +``` + +### Supporting Types + +| Type | Line | Description | +|------|------|-------------| +| [`enum ComposePreview`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L11) | L11 | Preview variants (image, voice, file, etc.) | +| [`enum ComposeContextItem`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L20) | L20 | Context item for reply/quote | +| [`enum VoiceMessageRecordingState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L29) | L29 | Recording state enum | +| [`struct ComposeState`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L45) | L45 | Full compose state struct | +| [`copy()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L98) | L98 | Copy compose state with overrides | +| [`mentionMemberName()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L118) | L118 | Format mention display name | +| [`chatItemPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L266) | L266 | Build preview from chat item | +| [`enum UploadContent`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L287) | L287 | Upload content variants | + +### States + +| State | Description | UI | +|-------|-------------|-----| +| `.empty` | No input, no attachments | Placeholder text, attachment button | +| `.text` | Text entered, no attachments | Send button visible | +| `.mediaPending` | Media/file selected, optionally with text | Preview visible, send button | +| `.voiceRecording` | Voice recording in progress | Waveform animation, stop/send | +| `.replying` | Replying to a specific message | Quote banner above input | +| `.editing` | Editing a previously sent message | Edit banner, pre-filled text | +| `.forwarding` | Forwarding selected messages | Forward banner, item previews | + +### Transitions + +| From | Trigger | To | +|------|---------|-----| +| `.empty` | User types text | `.text` | +| `.empty` | User selects media | `.mediaPending` | +| `.empty` | User holds voice button | `.voiceRecording` | +| `.empty` | User long-presses message "Reply" | `.replying` | +| `.empty` | User long-presses message "Edit" | `.editing` | +| `.empty` | User selects "Forward" | `.forwarding` | +| Any | User taps send | `.empty` | +| Any | User taps cancel (X) | `.empty` | + +--- + +## 4. Attachment Types + +### [ComposeImageView](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) + +**File**: [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) (struct at L12) + +Preview of selected image(s) before sending. Shows thumbnail with remove button. Images are compressed to `MAX_IMAGE_SIZE` (255KB) before sending. + +### [ComposeFileView](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) + +**File**: [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) (struct at L11) + +Preview of selected file or video. Shows filename, size, and remove button. Videos show a thumbnail frame. + +### [ComposeVoiceView](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) + +**File**: [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) (struct at L26) + +Voice message recording/playback preview. Shows waveform visualization, duration, and play/delete buttons. + +### Attachment Menu Options + +| Option | Picker | Max Size | Transfer Method | +|--------|--------|----------|-----------------| +| Camera photo | UIImagePickerController | Compressed to 255KB | Inline in SMP message | +| Photo library | PHPickerViewController | Compressed to 255KB | Inline or XFTP | +| Video | PHPickerViewController | Up to 1GB | XFTP | +| File | UIDocumentPickerViewController | Up to 1GB | XFTP | + +--- + +## 5. Reply Mode + +Activated via long-press context menu "Reply" on any message. + +### UI +- Quote banner above text input showing original message preview +- X button to cancel reply +- Original message reference stored in compose state + +### API +- Reply is sent as part of `ComposedMessage` with `quotedItemId` parameter +- `apiSendMessages(composedMessages: [ComposedMessage(quotedItemId: originalItem.id, ...)])` + +--- + +## 6. Edit Mode + +Activated via long-press context menu "Edit" on own sent messages (within the edit window). + +### UI +- Edit banner above text input with pencil icon +- Text field pre-filled with original message content +- Send button changes to "Save" / checkmark + +### API +- `apiUpdateChatItem(type:, id:, scope:, itemId:, updatedMessage:, live:)` +- Response: `ChatResponse1.chatItemUpdated(user:, chatItem:)` + +### Constraints +- Only own sent messages can be edited +- Edit is available within a server-defined time window +- Edited messages show a pencil indicator in `CIMetaView` +- Edit history is visible in `ChatItemInfoView` + +--- + +## 7. Forward Mode + +Activated via long-press context menu "Forward" or via multi-select toolbar. + +### Flow +1. User selects "Forward" on message(s) +2. `apiPlanForwardChatItems(fromChatType:, fromChatId:, fromScope:, itemIds:)` is called to plan +3. Response: `ChatResponse1.forwardPlan(user:, chatItemIds:, forwardConfirmation:)` +4. User selects destination chat +5. `apiForwardChatItems(toChatType:, toChatId:, toScope:, fromChatType:, fromChatId:, fromScope:, itemIds:, ttl:)` executes the forward +6. Forwarded messages appear with a forwarded indicator + +### ForwardConfirmation +The plan response may include a `forwardConfirmation` requiring user confirmation (e.g., forwarding to a less secure chat). + +--- + +## 8. [Live Messages](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L36) (`struct LiveMessage`) + +Optional feature where the recipient sees typing in real-time. + +### How It Works +- User enables live message mode (lightning icon) +- As user types, `apiSendMessages(live: true)` is called repeatedly +- Each call sends the current text as an update to the same message +- Recipient sees the message being composed in real-time +- Final send marks the message as complete + +### Key Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`sendLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1102) | L1102 | Initiates a live message | +| [`updateLiveMessage()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1120) | L1120 | Sends incremental live update | +| [`liveMessageToSend()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1139) | L1139 | Determines text diff to send | +| [`truncateToWords()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1144) | L1144 | Truncates text at word boundary | + +### API +- Initial: `apiSendMessages(live: true, composedMessages: [...])` -- creates live message +- Updates: `apiUpdateChatItem(live: true)` -- updates content as user types +- Final: `apiUpdateChatItem(live: false)` -- marks as complete + +--- + +## 9. Voice Recording + +### Recording Flow +1. User taps (or holds) the microphone button +2. `AVAudioRecorder` starts recording in compressed format +3. Waveform visualization shows real-time audio levels +4. User taps stop (or releases hold) to finish recording +5. Preview with playback shown in compose area +6. User taps send to deliver + +### Voice Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`startVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1564) | L1564 | Begins audio recording | +| [`finishVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1605) | L1605 | Stops recording, shows preview | +| [`allowVoiceMessagesToContact()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1616) | L1616 | Enables voice messages for contact | +| [`updateComposeVMRFinished()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1623) | L1623 | Updates state after recording finishes | +| [`cancelCurrentVoiceRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1635) | L1635 | Cancels in-progress recording | +| [`cancelVoiceMessageRecording()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1642) | L1642 | Cancels and cleans up recording file | + +### Constraints +- Maximum duration: `MAX_VOICE_MESSAGE_LENGTH = 300` seconds (5 minutes) +- Auto-receive threshold: `MAX_VOICE_SIZE_AUTO_RCV = 522,240` bytes (510KB) +- Compressed audio format for small file sizes + +### Audio Management +- [`AudioRecorder`](../../Shared/Model/AudioRecPlay.swift#L14) (`Shared/Model/AudioRecPlay.swift` L14) manages recording and playback +- `ChatModel.stopPreviousRecPlay` coordinates exclusive audio playback (only one audio source plays at a time) + +--- + +## 10. [Link Previews](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) (`ComposeLinkView`) + +**File**: [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) (struct at L13) + +### Auto-Detection +- As user types, URLs in the text are detected +- When a URL is found, `ComposeLinkView` fetches OpenGraph metadata +- Preview card shows title, description, and thumbnail image + +### Link Preview Functions + +| Function | Line | Description | +|----------|------|-------------| +| [`showLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1677) | L1677 | Triggers link preview loading | +| [`getMessageLinks()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1697) | L1697 | Extracts URLs from formatted text | +| [`isSimplexLink()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1708) | L1708 | Checks if URL is a SimpleX link | +| [`cancelLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1712) | L1712 | Cancels pending preview | +| [`loadLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1724) | L1724 | Fetches OpenGraph metadata | +| [`resetLinkPreview()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1741) | L1741 | Resets preview state | + +### Behavior +- Only the first URL in the message generates a preview +- Preview can be dismissed by the user +- Link preview data is included in the `ComposedMessage` sent to the core +- Toggle in privacy settings to disable auto-preview generation + +--- + +## 11. Mentions + +In group chats, typing `@` triggers member name autocomplete: + +### Flow +1. User types `@` in the text field +2. Autocomplete dropdown appears with matching group members +3. User selects a member +4. `@displayName` is inserted into the text +5. Mention is rendered with special formatting in the sent message + +### Data +- Group members loaded from `ChatModel.groupMembers` +- Mention metadata included in `ComposedMessage` + +--- + +## Channel Compose Behavior + +When `chat.chatInfo.groupInfo?.useRelays == true` (channel mode), compose behaves differently: + +### Owner/Admin Compose +- [`send()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1498) passes `sendAsGroup: true` to `apiSendMessages` when `useRelays && memberRole >= .owner` +- [`forwardItems()`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L1526) passes `sendAsGroup: true` to `apiForwardChatItems` under same condition +- Placeholder text shows "Broadcast" instead of "Message" (via `sendMessageView()` `placeholder:` parameter) +- Share Extension ([`ShareAPI.swift`](../../SimpleX%20SE/ShareAPI.swift#L71)) uses the same `sendAsGroup` expression + +### Subscriber Compose +- [`userCantSendReason`](../../SimpleXChat/ChatTypes.swift#L1566) returns `("you are subscriber", nil)` when `useRelays && memberRole == .observer` +- This check is evaluated after `memberPending` (which takes priority) but replaces the `observer` message +- Compose field is disabled; tapping shows "You can't send messages!" alert with no body text + +--- + +## Source Files + +| File | Path | Struct/Class | Line | +|------|------|--------------|------| +| Compose view | [`ComposeView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift) | `ComposeView` | [L329](../../Shared/Views/Chat/ComposeMessage/ComposeView.swift#L329) | +| Send message UI | [`SendMessageView.swift`](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift) | `SendMessageView` | [L15](../../Shared/Views/Chat/ComposeMessage/SendMessageView.swift#L15) | +| Image preview | [`ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | `ComposeImageView` | [L12](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift#L12) | +| File preview | [`ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | `ComposeFileView` | [L11](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift#L11) | +| Voice preview | [`ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | `ComposeVoiceView` | [L26](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift#L26) | +| Link preview | [`ComposeLinkView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift) | `ComposeLinkView` | [L13](../../Shared/Views/Chat/ComposeMessage/ComposeLinkView.swift#L13) | +| Audio recording | [`AudioRecPlay.swift`](../../Shared/Model/AudioRecPlay.swift) | `AudioRecorder` | [L14](../../Shared/Model/AudioRecPlay.swift#L14) | diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md new file mode 100644 index 0000000000..22985c6fe1 --- /dev/null +++ b/apps/ios/spec/client/navigation.md @@ -0,0 +1,380 @@ +# SimpleX Chat iOS -- Navigation Architecture + +> Technical specification for the navigation stack, deep linking, sheet presentation, and call overlay. +> +> Related specs: [Chat List](chat-list.md) | [Chat View](chat-view.md) | [State Management](../state.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ContentView.swift`](../../Shared/ContentView.swift) | [`NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | [`SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | [`OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | [`UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Root View -- ContentView](#2-root-view) +3. [Navigation Stack](#3-navigation-stack) +4. [Sheet Presentation](#4-sheet-presentation) +5. [Deep Linking](#5-deep-linking) +6. [Call Overlay](#6-call-overlay) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) + +--- + +## 1. Overview + +The app's navigation follows a hierarchical model with a single navigation stack rooted in `ContentView`. Modal sheets and full-screen overlays augment the primary navigation path. + +``` +SimpleXApp +└── ContentView (root) + ├── Authentication gate (LocalAuthView / SetAppPasscodeView) + ├── Onboarding flow (if first launch / migration) + ├── Main content + │ └── NavigationStack / NavigationView + │ ├── ChatListView (root of stack) + │ │ ├── ChatView (pushed) + │ │ │ ├── ChatInfoView / GroupChatInfoView (pushed) + │ │ │ └── ChatItemInfoView (pushed) + │ │ └── ContactConnectionInfo (pushed) + │ └── Settings views (pushed) + ├── Sheets (modal) + │ ├── UserPicker + │ ├── NewChatView + │ ├── WhatsNew / Notices + │ └── Settings sub-views + └── Overlays (always on top) + ├── Active call banner (when call active) + └── ActiveCallView (full-screen call) +``` + +--- + +## 2. Root View -- [`ContentView`](../../Shared/ContentView.swift#L24) + +**File**: [`Shared/ContentView.swift`](../../Shared/ContentView.swift) + +`ContentView` is the root view injected by `SimpleXApp`. It manages: + +### [Environment](../../Shared/ContentView.swift#L25-L37) +- `@EnvironmentObject var chatModel: ChatModel` +- `@EnvironmentObject var theme: AppTheme` +- `@Environment(\.scenePhase) var scenePhase` + +### [Key State](../../Shared/ContentView.swift#L35-L52) +| Property | Type | Purpose | +|----------|------|---------| +| [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) | `Bool` | Passed at init to avoid re-render timing issues | +| [`automaticAuthenticationAttempted`](../../Shared/ContentView.swift#L38) | `Bool` | Whether biometric auth was auto-attempted | +| [`waitingForOrPassedAuth`](../../Shared/ContentView.swift#L51) | `Bool` | Whether auth gate should show | +| [`chatListUserPickerSheet`](../../Shared/ContentView.swift#L52) | `UserPickerSheet?` | Active user picker sheet | + +### [View Selection Logic](../../Shared/ContentView.swift#L60-L80) + +```swift +// Simplified decision tree in ContentView.body: +if !prefPerformLA || accessAuthenticated { + contentView() // Main app content +} else { + lockButton() // Authentication required +} +``` + +The [`contentView()`](../../Shared/ContentView.swift#L169) function further decides: +- If `chatModel.onboardingStage != .onboardingComplete`: show [onboarding](../../Shared/ContentView.swift#L174) +- If `chatModel.migrationState != nil`: show migration UI +- Otherwise: show `ChatListView` in a navigation container + +--- + +## 3. Navigation Stack + +### iOS Version Compatibility + +**File**: [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) + +The app supports iOS 15+ and uses a compatibility wrapper ([`NavStackCompat`](../../Shared/Views/Helpers/NavStackCompat.swift#L11)): + +```swift +// NavStackCompat provides: +// - NavigationStack (iOS 16+): programmatic navigation via NavigationPath +// - NavigationView (iOS 15): classic NavigationLink-based navigation +``` + +### Primary Navigation Path + +``` +ChatListView + │ + ├─[tap chat]─→ ChatView + │ │ + │ ├─[tap info]─→ ChatInfoView (direct) + │ │ └─→ VerifyCodeView, etc. + │ │ + │ ├─[tap info]─→ GroupChatInfoView (group) + │ │ ├─→ GroupMemberInfoView + │ │ ├─→ GroupProfileView + │ │ └─→ GroupLinkView + │ │ + │ └─[tap message info]─→ ChatItemInfoView + │ + ├─[tap connection]─→ ContactConnectionInfo + │ + └─[settings]─→ SettingsView + ├─→ NotificationsView + ├─→ NetworkAndServers + ├─→ AppearanceSettings + ├─→ PrivacySettings + ├─→ DatabaseView + └─→ UserProfilesView +``` + +### Navigation Trigger + +Chat navigation is triggered by setting `ChatModel.chatId`: + +```swift +// In ChatListNavLink: +ItemsModel.shared.loadOpenChat(chatId) { + // This sets ChatModel.chatId = chatId after a 250ms delay + // allowing navigation animation to start smoothly +} +``` + +--- + +## 4. Sheet Presentation + +Sheets are presented modally on top of the navigation stack: + +| Sheet | Trigger | Content | +|-------|---------|---------| +| UserPicker | Tap user avatar in nav bar | User list, settings shortcuts | +| [`NewChatView`](../../Shared/Views/NewChat/NewChatView.swift#L78) | Tap FAB / "+" button | Create link, scan QR, paste link, new group | +| WhatsNew | App update detected | Release notes | +| AddGroupView | "New Group" action | Group creation wizard | +| ConnectDesktopView | Settings > Desktop | Remote desktop pairing | +| MigrateFromDevice | Settings > Migration | Device export | +| MigrateToDevice | Onboarding migration | Device import | +| [LocalAuthView](../../Shared/ContentView.swift#L95) | App foreground after background | Biometric/passcode auth | + +### Sheet Management + +Sheets use SwiftUI `.sheet(item:)` or `.sheet(isPresented:)` modifiers on `ContentView` and `ChatListView`. Some sheets use the centralized [`AppSheetState.shared`](../../Shared/ContentView.swift#L29) observable for coordination: + +```swift +class AppSheetState: ObservableObject { + static let shared = AppSheetState() + var scenePhaseActive: Bool = false + // ... sheet state coordination +} +``` + +--- + +## 5. Deep Linking + +### Notification Deep Link + +When the user taps a notification: + +1. `NtfManager.processNotificationResponse()` extracts the `chatId` from notification payload +2. If a different user: calls `changeActiveUser(userId:)` +3. Sets `ChatModel.chatId = chatId` to navigate to the conversation +4. If the app was in background: the notification response is stored in `ChatModel.notificationResponse` and processed when the app becomes active + +### [URL Deep Link](../../Shared/ContentView.swift#L281) + +SimpleX links (`simplex:/chat#...`) are handled via [`connectViaUrl()`](../../Shared/ContentView.swift#L439): + +```swift +.onOpenURL { url in + if AppChatState.shared.value == .active { + chatModel.appOpenUrl = url // Process immediately + } else { + chatModel.appOpenUrlLater = url // Process when active + } +} +``` + +URL processing routes to the appropriate connection flow (join group, add contact, etc.) via [`planAndConnect()`](../../Shared/Views/NewChat/NewChatView.swift#L1181). + +### Call Deep Link + +Call invitations from notifications: +1. `NtfManager` detects `ntfActionAcceptCall` action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept)` +3. `ContentView` picks up the pending action and initiates the call + +--- + +## 6. Call Overlay + +The call UI overlays the entire app when a call is active: + +### [Call Banner](../../Shared/ContentView.swift#L203) + +When `ChatModel.activeCall != nil` and call is in connecting/active state: +- A banner appears at the top of ContentView (height: [`callTopPadding = 40`](../../Shared/ContentView.swift#L54)) +- Shows contact name, call duration, tap to return to full-screen call +- Main content is padded down to accommodate the banner + +### [Full-Screen Call View](../../Shared/ContentView.swift#L185) + +When `ChatModel.showCallView == true`: +- `ActiveCallView` covers the entire screen as a ZStack overlay +- Contains local/remote video, controls (mute, camera, speaker, end) +- PiP mode: `ChatModel.activeCallViewIsCollapsed` collapses to mini view +- Call view is always rendered on top of navigation and sheets + +```swift +// In ContentView.allViews(): +ZStack { + contentView() + .padding(.top, showCallArea ? callTopPadding : 0) + + if showCallArea, let call = chatModel.activeCall { + VStack { + activeCallInteractiveArea(call) + Spacer() + } + } + + if chatModel.showCallView, let call = chatModel.activeCall { + callView(call) // Full screen overlay + } +} +``` + +--- + +## 7. Authentication Gate + +### [Local Authentication](../../Shared/ContentView.swift#L359) + +When [`DEFAULT_PERFORM_LA`](../../Shared/ContentView.swift#L44) is enabled: + +1. App enters background: `chatModel.contentViewAccessAuthenticated = false` +2. App returns to foreground: `ContentView` shows [`lockButton()`](../../Shared/ContentView.swift#L238) instead of content +3. User taps lock button: [`LocalAuthView`](../../Shared/ContentView.swift#L95) presented +4. On successful auth: `chatModel.contentViewAccessAuthenticated = true`, content revealed + +### Authentication Methods +- Face ID / Touch ID (via `LocalAuthentication` framework) +- Custom numeric passcode +- Custom alphanumeric passcode + +### [Extended Authentication](../../Shared/ContentView.swift#L351) +- After successful auth, a grace period prevents re-auth for brief background/foreground cycles ([`unlockedRecently()`](../../Shared/ContentView.swift#L351)) +- [`contentAccessAuthenticationExtended`](../../Shared/ContentView.swift#L35) is computed at `ContentView.init` to avoid render-time race conditions +- The `enteredBackgroundAuthenticated` timestamp tracks when the app was last authenticated in background + +--- + +## 8. [Onboarding Flow](../../Shared/Views/Onboarding/OnboardingView.swift#L13) + +First-launch experience controlled by [`ChatModel.onboardingStage`](../../Shared/Views/Onboarding/OnboardingView.swift#L46): + +```swift +enum OnboardingStage: String, Identifiable { + case step1_SimpleXInfo // Welcome screen + case step2_CreateProfile // deprecated + case step3_CreateSimpleXAddress // deprecated + case step3_ChooseServerOperators // Choose server operators + case step4_SetNotificationsMode // Set notification preferences + case onboardingComplete // Normal operation +} +``` + +Each stage is a dedicated view presented in place of `ChatListView` within [`ContentView`](../../Shared/ContentView.swift#L174). + +Migration state (`ChatModel.migrationState != nil`) takes precedence over onboarding. + +--- + +## 9. Channel Creation Flow (`AddChannelView`) + +**Source:** [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) + +### Entry Point + +`NewChatMenuButton` includes a NavigationLink "Create channel (BETA)" with antenna icon, navigating to `AddChannelView`. + +### Three-Step Wizard + +| Step | Function | Description | +|------|----------|-------------| +| 1. Profile | `profileStepView()` | Channel name input (`channelNameTextField()`), profile image picker. "Configure relays" link to `NetworkAndServers`. Validates via `canCreateProfile()` (non-empty + valid display name) and `checkHasRelays()`. | +| 2. Progress | `progressStepView(_:)` | Relay connection progress with `RelayProgressIndicator` (circular active/total or spinner). Expandable relay list with `relayStatusIndicator(_:)` (green/red/orange dots). Cancel via `cancelChannelCreation(_:)` which calls `apiDeleteChat`. | +| 3. Link | `linkStepView(_:)` | Wraps `GroupLinkView(isChannel: true)` for channel link sharing. | + +### Key Functions + +| Function | Scope | Description | +|----------|-------|-------------| +| `createChannel()` | private | Calls `apiNewPublicGroup(incognito:relayIds:groupProfile:)`, sets `ChannelRelaysModel` | +| `getEnabledRelays()` | private | Filters enabled/non-deleted relays, selects random 3 | +| `checkHasRelays()` | private | Validates at least one relay exists | +| `relayDisplayName(_:)` | module | name > domain > link host > fallback | +| `relayStatusIndicator(_:)` | module | Green/red/orange dot + status text | +| `RelayProgressIndicator` | module | Circular progress (active/total) or spinner | + +## 10. Relay URL Interception + +**Source:** [`Shared/ContentView.swift`](../../Shared/ContentView.swift#L454) + +In `connectViaUrl_()`, relay address links (URL path `/r`) are intercepted before processing: + +```swift +if path == "/r" { + showAlert(NSLocalizedString("Relay address", ...), + message: NSLocalizedString("This is a chat relay address, it cannot be used to connect.", ...)) + return +} +``` + +Similarly, in `planAndConnect()` (`NewChatView.swift`), `.simplexLink(_, .relay, _, _)` patterns trigger the same alert and block connection. + +## 11. Channel-Specific NewChatView Behavior + +**Source:** [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) + +### Prepared Group Alert (`showPrepareGroupAlert`) + +When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare alert uses: +- Channel icon: `antenna.radiowaves.left.and.right.circle.fill` +- Title: "Open new channel" +- Error: "Error opening channel" +- `apiPrepareGroup` call passes `directLink: false` +- Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames` + +### Own Link Confirmation (`showOwnGroupLinkConfirmConnectSheet`) + +For channels: shows "This is your link for channel" with only "Open channel" + "Cancel" buttons. No incognito or profile selection options. + +### Known Group Alert (`showOpenKnownGroupAlert`) + +For channels (`groupInfo.useRelays`): titles become "Open channel" / "Open new channel". + +--- + +## Source Files + +| File | Path | +|------|------| +| Root view | [`Shared/ContentView.swift`](../../Shared/ContentView.swift) | +| App entry point | `Shared/SimpleXApp.swift` | +| Navigation compat | [`Shared/Views/Helpers/NavStackCompat.swift`](../../Shared/Views/Helpers/NavStackCompat.swift) | +| Chat list (nav root) | `Shared/Views/ChatList/ChatListView.swift` | +| Nav link wrapper | `Shared/Views/ChatList/ChatListNavLink.swift` | +| User picker | `Shared/Views/ChatList/UserPicker.swift` | +| New chat view | [`Shared/Views/NewChat/NewChatView.swift`](../../Shared/Views/NewChat/NewChatView.swift) | +| Channel creation | [`Shared/Views/NewChat/AddChannelView.swift`](../../Shared/Views/NewChat/AddChannelView.swift) | +| New chat menu | [`Shared/Views/NewChat/NewChatMenuButton.swift`](../../Shared/Views/NewChat/NewChatMenuButton.swift) | +| Settings view | [`Shared/Views/UserSettings/SettingsView.swift`](../../Shared/Views/UserSettings/SettingsView.swift) | +| User profiles | [`Shared/Views/UserSettings/UserProfilesView.swift`](../../Shared/Views/UserSettings/UserProfilesView.swift) | +| Onboarding view | [`Shared/Views/Onboarding/OnboardingView.swift`](../../Shared/Views/Onboarding/OnboardingView.swift) | +| Active call view | `Shared/Views/Call/ActiveCallView.swift` | +| Local auth view | `Shared/Views/LocalAuth/LocalAuthView.swift` | +| Notification manager | `Shared/Model/NtfManager.swift` | diff --git a/apps/ios/spec/database.md b/apps/ios/spec/database.md new file mode 100644 index 0000000000..9e5adfcb64 --- /dev/null +++ b/apps/ios/spec/database.md @@ -0,0 +1,298 @@ +# SimpleX Chat iOS -- Database & Storage + +**Source:** [`FileUtils.swift`](../SimpleXChat/FileUtils.swift) + +> Technical specification for the database architecture, encryption, file storage, and export/import functionality. +> +> Related specs: [Architecture](architecture.md) | [State Management](state.md) | [README](README.md) +> Related product: [Product Overview](../product/README.md) + +--- + +## Table of Contents + +1. [Database Overview](#1-database-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [App Group Sharing](#8-app-group-sharing) + +--- + +## 1. Database Overview + +SimpleX Chat uses two SQLite databases managed entirely by the Haskell core. The iOS Swift layer never reads or writes directly to the databases -- all data access goes through the FFI command/response API. + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat DB | `_chat.db` | Messages, contacts, groups, user profiles, files, tags, preferences, call history | +| Agent DB | `_agent.db` | SMP agent connections, cryptographic keys, message queues, server state, XFTP chunks | + +Both databases are initialized and migrated via the C FFI function `chat_migrate_init_key()`, which applies pending migrations and returns a `chat_ctrl` pointer. + +--- + +## 2. Database Files & Paths + +### [Path Resolution](../SimpleXChat/FileUtils.swift#L63-L73) (FileUtils.swift) + +```swift +let DB_FILE_PREFIX = "simplex_v1" + +// Database path depends on container preference +func getAppDatabasePath() -> URL { + dbContainerGroupDefault.get() == .group + ? getGroupContainerDirectory().appendingPathComponent(DB_FILE_PREFIX) + : getLegacyDatabasePath() +} + +// Full database file paths: +// Chat: {container}/simplex_v1_chat.db +// Agent: {container}/simplex_v1_agent.db +``` + +### [File Constants](../SimpleXChat/FileUtils.swift#L38-L44) + +```swift +let CHAT_DB: String = "_chat.db" +let AGENT_DB: String = "_agent.db" +private let CHAT_DB_BAK: String = "_chat.db.bak" +private let AGENT_DB_BAK: String = "_agent.db.bak" +``` + +### Container Locations + +See [`getDocumentsDirectory()`](../SimpleXChat/FileUtils.swift#L47) and [`getGroupContainerDirectory()`](../SimpleXChat/FileUtils.swift#L52). + +| Container | Path | Used When | +|-----------|------|-----------| +| App Group | `FileManager.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)` | Default (shared with NSE) | +| Documents | `FileManager.urls(for: .documentDirectory)` | Legacy installations | + +The container choice is stored in `dbContainerGroupDefault` (`GroupDefaults`). + +--- + +## 3. Haskell Store Modules + +All database operations are implemented in Haskell. Key store modules (paths relative to repo root): + +| Module | Path | Size | Description | +|--------|------|------|-------------| +| Messages | `src/Simplex/Chat/Store/Messages.hs` | ~178KB | Message CRUD, pagination, search, reactions, delivery receipts | +| Groups | `src/Simplex/Chat/Store/Groups.hs` | ~126KB | Group CRUD, member management, roles, links, invitations | +| Direct | `src/Simplex/Chat/Store/Direct.hs` | ~52KB | Direct contact connections, contact requests. See `createDirectChat` in `Store/Direct.hs` | +| Files | `src/Simplex/Chat/Store/Files.hs` | ~43KB | File transfer state, XFTP chunks, inline files | +| Profiles | `src/Simplex/Chat/Store/Profiles.hs` | ~42KB | User profiles, contact profiles, incognito profiles | +| Connections | `src/Simplex/Chat/Store/Connections.hs` | ~17KB | Connection lifecycle, queue management | + +### Data Model (key tables) + +``` +users -- User profiles (userId, displayName, fullName, image, ...) +contacts -- Contact records (contactId, userId, localDisplayName, ...) +groups -- Group records (groupId, userId, groupProfile, ...) +group_members -- Group membership (groupMemberId, groupId, memberId, role, ...) +messages -- Message records (messageId, chatItemId, msgBody, ...) +chat_items -- Chat items (chatItemId, chatType, chatId, content, ...) +files -- File transfer records (fileId, chatItemId, fileName, fileSize, ...) +connections -- SMP connections (connId, agentConnId, ...) +chat_tags -- User-defined chat tags +chat_tags_chats -- Tag-to-chat assignments +``` + +--- + +## 4. Migrations + +Database migrations are managed by the Haskell core. Migration files are located in: + +``` +src/Simplex/Chat/Store/SQLite/Migrations/ +``` + +Migrations are numbered sequentially starting from `M20220101` through `M20260122` (200+ migrations). Each migration is a Haskell module containing SQL statements for schema changes. + +The migration process: +1. `chat_migrate_init_key()` is called with the database path +2. Haskell reads the current schema version from the database +3. Pending migrations are applied in order +4. If migration fails, the function returns an error string (not a `chat_ctrl`) +5. On success, a `chat_ctrl` pointer is returned + +Migration results are decoded in Swift as `DBMigrationResult`: +- `.ok` -- migrations applied successfully +- `.invalidConfirmation` -- migration requires user confirmation +- `.errorNotADatabase(dbFile:)` -- file is not a valid SQLite database +- `.errorMigration(dbFile:, migrationError:)` -- migration failed +- `.errorSQL(dbFile:, migrationSQLError:)` -- SQL error during migration +- `.errorKeychain` -- keychain access failed +- `.unknown(json:)` -- unrecognized response + +--- + +## 5. Database Encryption + +### Encryption Configuration + +Database encryption uses SQLCipher (AES-256) and is managed through the API: + +```swift +// Set or change encryption +ChatCommand.apiStorageEncryption(config: DBEncryptionConfig) + +// Test if a key is correct +ChatCommand.testStorageEncryption(key: String) +``` + +`DBEncryptionConfig` contains: +- `currentKey: String` -- current encryption key (empty if unencrypted) +- `newKey: String` -- new encryption key (empty to decrypt) + +### Key Storage + +The encryption key is stored in the iOS Keychain via `kcDatabasePassword`: +- On first launch with encryption, the key is generated and stored +- The `storeDBPassphraseGroupDefault` flag controls whether the key is auto-stored +- If the user opts out of auto-storage, they must enter the key on each launch + +### UI + +- [`DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) -- Encryption settings UI +- [`DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) -- Database management UI (size, export, import, encryption) + +--- + +## 6. File Storage + +### Directory Structure + +``` +{App Container}/ +├── Documents/ +│ ├── app_files/ -- Downloaded and sent files +│ ├── temp_files/ -- Temporary files during transfer +│ └── assets/wallpapers/ -- Custom wallpaper images +├── {App Group Container}/ +│ ├── simplex_v1_chat.db -- Chat database +│ ├── simplex_v1_agent.db -- Agent database +│ └── ... +``` + +### [File Size Constants](../SimpleXChat/FileUtils.swift#L18-L36) (FileUtils.swift) + +```swift +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB -- inline image compression target +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive images +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB -- auto-receive voice +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB -- auto-receive video +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB -- max XFTP transfer +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB -- max SMP inline +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit for local files +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes +``` + +### CryptoFile (Encrypted File Storage) + +When `apiSetEncryptLocalFiles(enable: true)` is set, files stored on device are AES-encrypted: + +- Encryption/decryption uses `chat_encrypt_file` / `chat_decrypt_file` C FFI functions +- Each file gets a unique key and nonce stored alongside the file reference +- The `CryptoFile` type wraps `(filePath: String, cryptoArgs: CryptoFileArgs?)` where `CryptoFileArgs` contains `(fileKey: String, fileNonce: String)` + +### [File Path Helpers](../SimpleXChat/FileUtils.swift#L219-L221) + +```swift +public func getDocumentsDirectory() -> URL // Standard documents dir +public func getGroupContainerDirectory() -> URL // App group container +func getAppFilesDirectory() -> URL // {appDir}/app_files/ +func getTempFilesDirectory() -> URL // {appDir}/temp_files/ +func getWallpaperDirectory() -> URL // {appDir}/assets/wallpapers/ +``` + +See also [`saveFile()`](../SimpleXChat/FileUtils.swift#L226), [`removeFile()`](../SimpleXChat/FileUtils.swift#L243), and [`getMaxFileSize()`](../SimpleXChat/FileUtils.swift#L276). + +### [Cleanup](../SimpleXChat/FileUtils.swift#L86-L116) + +- Files are deleted when their associated `ChatItem` is deleted. See [`cleanupFile()`](../SimpleXChat/FileUtils.swift#L267) and [`cleanupDirectFile()`](../SimpleXChat/FileUtils.swift#L260). +- Timed message expiry triggers file deletion +- [`deleteAppDatabaseAndFiles()`](../SimpleXChat/FileUtils.swift#L86) removes all databases, files, temp files, and wallpapers +- [`deleteAppFiles()`](../SimpleXChat/FileUtils.swift#L108) removes only the files directory (preserving databases) + +--- + +## 7. Export & Import + +### Export + +```swift +ChatCommand.apiExportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveExported(archiveErrors: [ArchiveError]) +``` + +`ArchiveConfig` specifies: +- `archivePath: String` -- destination path for the archive +- `disableCompression: Bool?` -- optional flag to skip compression + +The archive contains both databases and optionally files. The Haskell core handles the actual export, creating a ZIP archive. + +### Import + +```swift +ChatCommand.apiImportArchive(config: ArchiveConfig) +// Response: ChatResponse2.archiveImported(archiveErrors: [ArchiveError]) +``` + +Import replaces the current databases with the archive contents. The app must be restarted after import. + +### Archive Errors + +`ArchiveError` is an array returned with both export and import results, listing any non-fatal issues encountered (e.g., missing files, corrupt entries). + +--- + +## 8. App Group Sharing + +### Shared Access Model + +The main app and NSE share database access through the iOS App Group container: + +``` +Main App ──┐ + ├── {App Group}/simplex_v1_chat.db + ├── {App Group}/simplex_v1_agent.db +NSE ────────┘ +``` + +### Coordination + +- Both processes can initialize their own `chat_ctrl` instance pointing to the same database files +- SQLite WAL mode allows concurrent reads +- Write coordination uses `chat_close_store` / `chat_reopen_store` to manage database locks +- The main app suspends its chat controller when entering background, allowing NSE to access the database +- NSE is short-lived (~30 seconds per notification) and releases its lock quickly + +### App State Communication + +The `appStateGroupDefault` in `GroupDefaults` communicates app state between main app and NSE: +- `.active` -- main app is in foreground +- `.suspended` -- main app is in background +- `.stopped` -- main app is terminated + +The NSE checks this flag to determine whether to process notifications (it avoids processing if the main app is active). + +--- + +## Source Files + +| File | Path | +|------|------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | +| Database management UI | [`Shared/Views/Database/DatabaseView.swift`](../Shared/Views/Database/DatabaseView.swift) | +| Encryption settings UI | [`Shared/Views/Database/DatabaseEncryptionView.swift`](../Shared/Views/Database/DatabaseEncryptionView.swift) | +| C FFI (migration, file ops) | `SimpleXChat/SimpleX.h` | +| Haskell store root | `../../src/Simplex/Chat/Store/` | +| Haskell migrations | `../../src/Simplex/Chat/Store/SQLite/Migrations/` | diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md new file mode 100644 index 0000000000..eaf646e7f4 --- /dev/null +++ b/apps/ios/spec/impact.md @@ -0,0 +1,119 @@ +# SimpleX Chat iOS -- Impact Graph + +> Source file → product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Derived from [CODE.md](../CODE.md) Document Map and [product/concepts.md](../product/concepts.md). + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Push Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | +| PC31 | Channels (Relays) | + +--- + +## 1. Swift Source Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| Shared/ContentView.swift | PC1, PC2, PC3 | High | Root navigation — affects all chat access | +| Shared/SimpleXApp.swift | PC1 through PC31 | High | App entry point — initialization affects everything | +| Shared/AppDelegate.swift | PC18 | Medium | Push notification registration | +| Shared/Views/ChatList/ChatListView.swift | PC1, PC28 | High | Main screen rendering and filtering | +| Shared/Views/Chat/ChatView.swift | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC31 | High | Core conversation UI — most messaging features, channel message rendering | +| Shared/Views/Chat/ComposeMessage/ComposeView.swift | PC4, PC6, PC9, PC11, PC31 | High | Message composition — send path for all messages, channel sendAsGroup | +| Shared/Views/Chat/ChatItem/ | PC2, PC3, PC5, PC7, PC8, PC9, PC10, PC11 | Medium | Individual message rendering components | +| Shared/Views/Chat/ChatInfoView.swift | PC2, PC13, PC20 | Medium | Contact details and verification | +| Shared/Views/Chat/Group/GroupChatInfoView.swift | PC3, PC14, PC15, PC16, PC30, PC31 | High | Group management hub, channel info adaptations | +| Shared/Views/Chat/Group/ChannelMembersView.swift | PC31 | Medium | Channel owners/subscribers list | +| Shared/Views/Chat/Group/ChannelRelaysView.swift | PC31 | Medium | Channel relay status list | +| Shared/Views/Chat/Group/AddGroupMembersView.swift | PC14, PC16 | Medium | Member invitation flow | +| Shared/Views/Chat/Group/GroupLinkView.swift | PC15 | Low | Group link creation/sharing | +| Shared/Views/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| Shared/Views/NewChat/NewChatView.swift | PC12, PC31 | High | New connection creation — onramp for all contacts and channels | +| Shared/Views/NewChat/QRCode.swift | PC12 | Low | QR code display/scanning utility | +| Shared/Views/Call/ActiveCallView.swift | PC17 | Medium | Call UI rendering | +| Shared/Views/Call/CallController.swift | PC17 | High | CallKit integration — call lifecycle | +| Shared/Views/Call/WebRTCClient.swift | PC17 | High | WebRTC session management | +| Shared/Views/UserSettings/SettingsView.swift | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| Shared/Views/UserSettings/AppearanceSettings.swift | PC24 | Low | Theme customization UI | +| Shared/Views/UserSettings/NetworkAndServers/ | PC25, PC31 | High | Server configuration — affects connectivity and relay validation | +| Shared/Views/UserSettings/UserProfilesView.swift | PC19, PC21 | Medium | Profile management | +| Shared/Views/Onboarding/ | PC1 | Medium | First-time setup — affects initial state | +| Shared/Views/LocalAuth/ | PC22 | Medium | App lock functionality | +| Shared/Views/Database/ | PC23, PC26 | High | Database encryption and export | +| Shared/Views/Migration/ | PC26 | High | Device migration — data portability | +| Shared/Model/ChatModel.swift | PC1 through PC31 | High | Central state — all features depend on it | +| Shared/Model/SimpleXAPI.swift | PC1 through PC31 | High | FFI bridge — all commands flow through here | +| Shared/Model/AppAPITypes.swift | PC1 through PC31 | High | Command/response types — all API communication | +| Shared/Model/NtfManager.swift | PC18 | High | Notification delivery | +| Shared/Model/BGManager.swift | PC18 | Medium | Background fetch scheduling | +| Shared/Theme/ThemeManager.swift | PC24 | Medium | Theme resolution engine | +| SimpleXChat/ChatTypes.swift | PC1 through PC31 | High | Core data types — all features use them | +| SimpleXChat/APITypes.swift | PC1 through PC31 | High | API result types and error handling | +| SimpleXChat/CallTypes.swift | PC17 | Medium | Call-specific data types | +| SimpleXChat/FileUtils.swift | PC10, PC23, PC26 | Medium | File paths and encryption utilities | +| SimpleXChat/Notifications.swift | PC18 | Medium | Notification type definitions | +| SimpleX NSE/NotificationService.swift | PC18 | High | Push notification decryption and display | +| Shared/Views/Chat/ChatItemsMerger.swift | PC2, PC3, PC31 | Low | Chat item merge categories — added channelRcv hash | +| SimpleX SE/ShareAPI.swift | PC4, PC31 | Medium | Share extension API — sendAsGroup support | + +--- + +## 2. Haskell Core Impact + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| src/Simplex/Chat/Controller.hs | PC1 through PC31 | High | Command processor — all API commands | +| src/Simplex/Chat/Types.hs | PC1 through PC31 | High | Core data types shared across all features | +| src/Simplex/Chat/Core.hs | PC1 through PC31 | High | Chat engine lifecycle | +| src/Simplex/Chat/Protocol.hs | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| src/Simplex/Chat/Messages.hs | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| src/Simplex/Chat/Messages/CIContent.hs | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| src/Simplex/Chat/Call.hs | PC17 | Medium | Call signaling types | +| src/Simplex/Chat/Files.hs | PC10 | Medium | File transfer orchestration | +| src/Simplex/Chat/Store/Messages.hs | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| src/Simplex/Chat/Store/Groups.hs | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| src/Simplex/Chat/Store/Direct.hs | PC2, PC12, PC13 | High | Contact persistence | +| src/Simplex/Chat/Store/Files.hs | PC10 | Medium | File transfer persistence | +| src/Simplex/Chat/Store/Profiles.hs | PC19, PC21 | Medium | User profile persistence | +| src/Simplex/Chat/Store/Connections.hs | PC2, PC12 | High | Connection persistence and entity resolution | +| src/Simplex/Chat/Archive.hs | PC26 | Medium | Database export/import for migration | +| src/Simplex/Chat/ProfileGenerator.hs | PC20 | Low | Random profile generation for incognito | +| src/Simplex/Chat/Remote.hs | PC27 | Medium | Remote desktop protocol handler | +| src/Simplex/Chat/Remote/Types.hs | PC27 | Low | Remote desktop data types | +| src/Simplex/Chat/Types/UITheme.hs | PC24 | Low | Theme data types for UI customization | +| src/Simplex/Chat/Types/Preferences.hs | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| src/Simplex/Chat/Types/Shared.hs | PC3, PC16 | Medium | Shared types including GroupMemberRole | diff --git a/apps/ios/spec/services/calls.md b/apps/ios/spec/services/calls.md new file mode 100644 index 0000000000..6a1d89f6a3 --- /dev/null +++ b/apps/ios/spec/services/calls.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- WebRTC Calling Service + +> Technical specification for the calling system: CallController, WebRTCClient, CallKit integration, and signaling via SMP. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Notifications](notifications.md) | [README](../README.md) +> Related product: [Chat View](../../product/views/chat.md) + +**Source:** [`CallController.swift`](../../Shared/Views/Call/CallController.swift) | [`WebRTCClient.swift`](../../Shared/Views/Call/WebRTCClient.swift) | [`ActiveCallView.swift`](../../Shared/Views/Call/ActiveCallView.swift) | [`CallTypes.swift`](../../SimpleXChat/CallTypes.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [CallController](#2-callcontroller) +3. [WebRTCClient](#3-webrtcclient) +4. [Call Flow via SMP](#4-call-flow-via-smp) +5. [CallKit Integration](#5-callkit-integration) +6. [CallKit-Free Mode](#6-callkit-free-mode) +7. [Audio Routing](#7-audio-routing) +8. [Key Types](#8-key-types) +9. [ActiveCallView](#9-activecallview) + +--- + +## 1. Overview + +SimpleX Chat provides end-to-end encrypted audio and video calls using WebRTC. The unique aspect is that all call signaling (SDP offers/answers, ICE candidates) is transmitted through the same encrypted SMP messaging channels used for chat, eliminating the need for a separate signaling server. + +``` +Caller SMP Relay Callee + │ │ │ + ├─ apiSendCallInvitation ──────→│──── push/event ──────→│ + │ │ │ + │ │←── apiSendCallOffer ──┤ + │←── ChatEvent.callOffer ───────│ │ + │ │ │ + ├─ apiSendCallAnswer ──────────→│──── callAnswer ──────→│ + │ │ │ + │←── callExtraInfo (ICE) ───────│←── apiSendCallExtraInfo│ + ├─ apiSendCallExtraInfo ───────→│──── callExtraInfo ───→│ + │ │ │ + │◄══════════ WebRTC P2P Media Stream ═══════════════════►│ + │ │ │ + ├─ apiEndCall ─────────────────→│──── callEnded ───────→│ +``` + +--- + +## [2. CallController](../../Shared/Views/Call/CallController.swift#L19-L449) + +**File**: `Shared/Views/Call/CallController.swift` + +Central call coordinator that bridges SimpleX call protocol with iOS CallKit (or non-CallKit fallback). + +### [Class Definition](../../Shared/Views/Call/CallController.swift#L19-L48) + +```swift +class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, ObservableObject { + static let shared = CallController() + static let isInChina = SKStorefront().countryCode == "CHN" + static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } + + private let provider: CXProvider // CallKit provider + private let controller: CXCallController // CallKit controller + private let callManager: CallManager // Internal call state + private let registry: PKPushRegistry // VoIP push registration + + @Published var activeCallInvitation: RcvCallInvitation? + var shouldSuspendChat: Bool = false + var fulfillOnConnect: CXAnswerCallAction? = nil +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`reportNewIncomingCall()`](../../Shared/Views/Call/CallController.swift#L287) | Reports incoming call to CallKit for native UI | L287 | +| [`reportOutgoingCall()`](../../Shared/Views/Call/CallController.swift#L328) | Reports outgoing call to CallKit | L328 | +| [`provider(_:perform: CXAnswerCallAction)`](../../Shared/Views/Call/CallController.swift#L66) | Handles user answering via CallKit UI | L66 | +| [`provider(_:perform: CXEndCallAction)`](../../Shared/Views/Call/CallController.swift#L96) | Handles user ending via CallKit UI | L96 | +| [`provider(_:perform: CXStartCallAction)`](../../Shared/Views/Call/CallController.swift#L55) | Handles outgoing call start | L55 | +| [`pushRegistry(_:didReceiveIncomingPushWith:)`](../../Shared/Views/Call/CallController.swift#L202) | Handles VoIP push tokens | L202 | +| [`hasActiveCalls()`](../../Shared/Views/Call/CallController.swift#L435) | Checks if any calls are active | L435 | + +### Call Manager (internal) + +`CallManager` tracks call state internally: +- Maps call UUIDs to `Call` objects +- Handles call state transitions +- Coordinates between CallKit actions and SimpleX API calls + +--- + +## [3. WebRTCClient](../../Shared/Views/Call/WebRTCClient.swift#L13-L676) + +**File**: `Shared/Views/Call/WebRTCClient.swift` (~49KB) + +Manages the WebRTC peer connection, media streams, and data channels. + +### Responsibilities + +- Creates and configures `RTCPeerConnection` +- Manages local audio/video capture (`RTCCameraVideoCapturer`, `RTCAudioTrack`) +- Handles SDP offer/answer creation and application +- Processes ICE candidates +- Manages media stream encryption + +### Key Operations + +| Operation | Description | Line | +|-----------|-------------|------| +| [`initializeCall`](../../Shared/Views/Call/WebRTCClient.swift#L93) | Sets up peer connection, tracks, encryption | L93 | +| [`createPeerConnection`](../../Shared/Views/Call/WebRTCClient.swift#L139) | Creates and configures RTCPeerConnection | L139 | +| [`sendCallCommand`](../../Shared/Views/Call/WebRTCClient.swift#L176) | Dispatches WCallCommand (offer/answer/ICE) | L176 | +| [`addIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L165) | `peerConnection.add(RTCIceCandidate)` | L165 | +| [`getInitialIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L285) | Collects initial ICE candidates | L285 | +| [`sendIceCandidates`](../../Shared/Views/Call/WebRTCClient.swift#L305) | Sends gathered ICE candidates | L305 | +| [`enableMedia`](../../Shared/Views/Call/WebRTCClient.swift#L365) | Enable/disable audio or video track | L365 | +| [`setupLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L423) | Creates audio/video tracks and adds to connection | L423 | +| [`startCaptureLocalVideo`](../../Shared/Views/Call/WebRTCClient.swift#L581) | Front/back camera toggle and capture start | L581 | +| [`endCall`](../../Shared/Views/Call/WebRTCClient.swift#L645) | Tears down connection and tracks | L645 | +| [`setupEncryptionForLocalTracks`](../../Shared/Views/Call/WebRTCClient.swift#L503) | Sets up frame encryption for local media tracks | L503 | + +### [Additional Encryption](../../Shared/Views/Call/WebRTCClient.swift#L513-L546) + +Beyond WebRTC's built-in SRTP encryption, SimpleX adds an extra encryption layer: +- A shared key from the E2E SMP channel is used +- Applied via `chat_encrypt_media` / `chat_decrypt_media` C FFI functions +- Each media frame is encrypted/decrypted with this additional key +- Provides defense-in-depth even if SRTP is compromised + +--- + +## 4. Call Flow via SMP + +All call signaling travels through the same encrypted SMP message channels used for chat. No separate signaling server is needed. + +### Outgoing Call (Caller Side) + +``` +1. User initiates call + └── apiSendCallInvitation(contact:, callType:) + └── Sends CallInvitation via SMP to contact + +2. Callee accepts, sends SDP offer + └── ChatEvent.callOffer received + └── WebRTCClient creates answer + └── apiSendCallAnswer(contact:, answer:) + +3. ICE candidates exchanged + └── ChatEvent.callExtraInfo received → WebRTCClient.addIceCandidate() + └── WebRTCClient generates candidates → apiSendCallExtraInfo(contact:, extraInfo:) + +4. P2P connection established + └── Media streams flowing + +5. End call + └── apiEndCall(contact:) +``` + +### Incoming Call (Callee Side) + +``` +1. ChatEvent.callInvitation received (or push notification) + └── CallController reports to CallKit (or shows in-app notification) + +2. User accepts + └── WebRTCClient creates SDP offer (callee creates offer in SimpleX protocol) + └── apiSendCallOffer(contact:, callOffer:) + +3. Caller sends answer + └── ChatEvent.callAnswer received + └── WebRTCClient.setRemoteDescription(answer) + +4. ICE candidates exchanged (same as above) + +5. P2P connection established +``` + +### API Commands + +| Command | Direction | Purpose | +|---------|-----------|---------| +| `apiSendCallInvitation(contact:, callType:)` | Caller -> Callee | Initiate call | +| `apiRejectCall(contact:)` | Callee -> Caller | Reject call | +| `apiSendCallOffer(contact:, callOffer:)` | Callee -> Caller | Send SDP offer | +| `apiSendCallAnswer(contact:, answer:)` | Caller -> Callee | Send SDP answer | +| `apiSendCallExtraInfo(contact:, extraInfo:)` | Both | Send ICE candidates | +| `apiEndCall(contact:)` | Either | End call | +| `apiGetCallInvitations` | -- | Get pending invitations | +| `apiCallStatus(contact:, callStatus:)` | -- | Report status change | + +--- + +## [5. CallKit Integration](../../Shared/Views/Call/CallController.swift#L24-L155) + +CallKit provides the native iOS incoming call experience (lock screen UI, call history, system call handling). + +### [CXProvider Configuration](../../Shared/Views/Call/CallController.swift#L24-L37) + +```swift +let configuration = CXProviderConfiguration() +configuration.supportsVideo = true +configuration.supportedHandleTypes = [.generic] +configuration.includesCallsInRecents = UserDefaults.standard.bool( + forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS +) +configuration.maximumCallGroups = 1 +configuration.maximumCallsPerCallGroup = 1 +configuration.iconTemplateImageData = UIImage(named: "icon-transparent")?.pngData() +``` + +### [VoIP Push (PKPushRegistry)](../../Shared/Views/Call/CallController.swift#L207-L284) + +CallKit requires VoIP push for incoming calls on locked device: +- `PKPushRegistry` registers for `.voIP` push type +- VoIP push token is separate from regular APNs token +- When VoIP push received, **must** report an incoming call to CallKit within the callback (iOS requirement) + +### CallKit Actions + +| CXAction | Handler | Description | Line | +|----------|---------|-------------|------| +| `CXStartCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L55) | User starts outgoing call | L55 | +| `CXAnswerCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L66) | User answers incoming call from CallKit UI | L66 | +| `CXEndCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L96) | User ends call from CallKit UI | L96 | +| `CXSetMutedCallAction` | [`provider(_:perform:)`](../../Shared/Views/Call/CallController.swift#L112) | User mutes from CallKit UI | L112 | + +### [Lock Screen Answer](../../Shared/Views/Call/CallController.swift#L66-L94) + +When answering from the lock screen: +1. `CXAnswerCallAction` fires +2. CallController waits for chat to be ready ([`waitUntilChatStarted(timeoutMs: 30_000)`](../../Shared/Views/Call/CallController.swift#L183)) +3. WebRTC connection established +4. `fulfillOnConnect` action is fulfilled only when WebRTC reaches connected state (required for audio to work on lock screen) + +--- + +## [6. CallKit-Free Mode](../../Shared/Views/Call/CallController.swift#L21-L22) + +In regions where CallKit is unavailable (e.g., China, determined by `SKStorefront.countryCode == "CHN"`), the app falls back to in-app notifications: + +```swift +static let isInChina = SKStorefront().countryCode == "CHN" +static func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() } +``` + +### Non-CallKit Behavior +- Incoming calls shown as in-app banners (via `CallController.activeCallInvitation`) +- No lock screen call UI +- No system call integration +- User can also manually disable CallKit via settings (`callKitEnabledGroupDefault`) + +--- + +## [7. Audio Routing](../../Shared/Views/Call/WebRTCClient.swift#L907-L1005) + +### [AVAudioSession Management](../../Shared/Views/Call/WebRTCClient.swift#L907-L950) + +Audio routing is managed through `AVAudioSession`: +- **Receiver**: Default for audio-only calls (ear speaker) +- **Speaker**: For video calls or when user toggles speaker +- **Bluetooth**: Detected and used when available +- **Headphones**: Detected and used when connected + +### Route Change Handling + +The `WebRTCClient` observes `AVAudioSession.routeChangeNotification` to handle: +- Bluetooth device connection/disconnection +- Headphone plug/unplug +- Speaker/receiver toggle + +--- + +## [8. Key Types](../../SimpleXChat/CallTypes.swift#L1-L115) + +### [RcvCallInvitation](../../SimpleXChat/CallTypes.swift#L45-L71) + +```swift +struct RcvCallInvitation { + var user: User + var contact: Contact + var callType: CallType + var sharedKey: String? // Optional E2E encryption key + var callUUID: String? + var callTs: Date +} +``` + +### [CallType](../../SimpleXChat/CallTypes.swift#L74-L82) + +```swift +struct CallType { + var media: CallMediaType // .audio or .video + var capabilities: CallCapabilities +} + +enum CallMediaType: String { + case audio + case video +} +``` + +### [WebRTCCallOffer](../../SimpleXChat/CallTypes.swift#L14-L22) / [WebRTCSession](../../SimpleXChat/CallTypes.swift#L25-L33) + +```swift +struct WebRTCCallOffer { + var callType: CallType + var rtcSession: WebRTCSession +} + +struct WebRTCSession { + var rtcSession: String // SDP string + var rtcIceCandidates: String // ICE candidates JSON +} +``` + +### [WebRTCExtraInfo](../../SimpleXChat/CallTypes.swift#L36-L42) + +```swift +struct WebRTCExtraInfo { + var rtcIceCandidates: String // Additional ICE candidates +} +``` + +### Call (Active Call State) + +Stored in `ChatModel.activeCall`: +- Contact reference +- Call UUID +- Call state (enum: `.waitCapabilities`, `.invitationAccepted`, `.offerSent`, `.answerReceived`, `.connected`, etc.) +- Media type +- WebRTCClient reference + +--- + +## [9. ActiveCallView](../../Shared/Views/Call/ActiveCallView.swift#L16-L285) + +**File**: `Shared/Views/Call/ActiveCallView.swift` + +Full-screen call UI when `ChatModel.showCallView == true`: + +### UI Elements +- Remote video (full screen background) +- Local video (PiP corner, draggable) +- Contact name and call duration +- Control buttons: mute, camera toggle, speaker toggle, camera flip, end call +- Minimize button (collapses to banner) + +### [ActiveCallOverlay](../../Shared/Views/Call/ActiveCallView.swift#L288-L522) + +| Control | Method | Line | +|---------|--------|------| +| Audio call info | [`audioCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L357) | L357 | +| Video call info | [`videoCallInfoView`](../../Shared/Views/Call/ActiveCallView.swift#L377) | L377 | +| End call | [`endCallButton`](../../Shared/Views/Call/ActiveCallView.swift#L407) | L407 | +| Mute toggle | [`toggleMicButton`](../../Shared/Views/Call/ActiveCallView.swift#L418) | L418 | +| Audio device | [`audioDeviceButton`](../../Shared/Views/Call/ActiveCallView.swift#L428) | L428 | +| Speaker toggle | [`toggleSpeakerButton`](../../Shared/Views/Call/ActiveCallView.swift#L452) | L452 | +| Camera toggle | [`toggleCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L464) | L464 | +| Flip camera | [`flipCameraButton`](../../Shared/Views/Call/ActiveCallView.swift#L475) | L475 | + +### PiP (Picture-in-Picture) + +When `ChatModel.activeCallViewIsCollapsed == true`: +- Call view collapses to a small floating overlay +- User can return to full-screen by tapping the banner +- Navigation continues normally underneath + +--- + +## Source Files + +| File | Path | Lines | +|------|------|-------| +| [Call controller](../../Shared/Views/Call/CallController.swift) | `Shared/Views/Call/CallController.swift` | 449 | +| [WebRTC client](../../Shared/Views/Call/WebRTCClient.swift) | `Shared/Views/Call/WebRTCClient.swift` | 1139 | +| [Active call UI](../../Shared/Views/Call/ActiveCallView.swift) | `Shared/Views/Call/ActiveCallView.swift` | 528 | +| WebRTC helpers | `Shared/Views/Call/WebRTC.swift` | | +| [Call types (Swift)](../../SimpleXChat/CallTypes.swift) | `SimpleXChat/CallTypes.swift` | 115 | +| Call types (Haskell) | `../../src/Simplex/Chat/Call.hs` | | diff --git a/apps/ios/spec/services/files.md b/apps/ios/spec/services/files.md new file mode 100644 index 0000000000..7e1f8a2ad1 --- /dev/null +++ b/apps/ios/spec/services/files.md @@ -0,0 +1,368 @@ +# SimpleX Chat iOS -- File Transfer Service + +> Technical specification for file transfer: inline/XFTP protocols, auto-receive thresholds, CryptoFile encryption, and file constants. +> +> Related specs: [Compose Module](../client/compose.md) | [Chat View](../client/chat-view.md) | [API Reference](../api.md) | [Database](../database.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | [`ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | [`AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | [`SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Transfer Methods](#2-transfer-methods) +3. [Auto-Receive Thresholds](#3-auto-receive-thresholds) +4. [File Size Constants](#4-file-size-constants) +5. [Image Handling](#5-image-handling) +6. [Voice Messages](#6-voice-messages) +7. [CryptoFile -- At-Rest Encryption](#7-cryptofile) +8. [File Storage Paths](#8-file-storage-paths) +9. [File Lifecycle](#9-file-lifecycle) +10. [API Commands](#10-api-commands) + +--- + +## 1. Overview + +SimpleX Chat supports two file transfer methods depending on file size: + +``` +File ≤ 255KB (inline) +├── Base64 encoded directly in SMP message +├── Single message delivery +└── No extra server infrastructure needed + +File > 255KB up to 1GB (XFTP) +├── Encrypted and chunked +├── Uploaded to XFTP relay servers +├── Recipient downloads chunks from relays +└── Files auto-deleted from relays after download or expiry +``` + +All files are end-to-end encrypted. The XFTP protocol adds a second encryption layer on top of the SMP channel encryption. + +--- + +## 2. Transfer Methods + +### Inline Transfer + +- Files up to [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB) are base64-encoded and embedded directly in the SMP message body +- No additional protocol or server needed +- Delivered with the same reliability guarantees as regular messages +- Used primarily for compressed images + +### XFTP Transfer + +For files exceeding the inline threshold (up to [`MAX_FILE_SIZE_XFTP`](../../SimpleXChat/FileUtils.swift#L30) = 1GB): + +1. **Sender side**: + - File is AES-encrypted with a random key + - Encrypted file is split into chunks + - Chunks are uploaded to one or more XFTP relay servers + - File metadata (key, chunk locations) sent to recipient via SMP message + +2. **Recipient side**: + - Receives file metadata via SMP + - Downloads chunks from XFTP relays + - Reassembles and decrypts the file + +3. **Cleanup**: + - XFTP relays delete chunks after download or after expiry period + - No persistent storage on relays + +### SMP Transfer (legacy) + +[`MAX_FILE_SIZE_SMP`](../../SimpleXChat/FileUtils.swift#L34) (8MB) exists as a constant for larger inline transfers through SMP, used in specific scenarios. + +--- + +## 3. Auto-Receive Thresholds + +Files below certain size thresholds are automatically accepted and downloaded without user confirmation: + +| Media Type | Auto-Receive Threshold | Constant | Line | +|------------|----------------------|----------|------| +| Images | 510 KB | [`MAX_IMAGE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L21) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| Voice messages | 510 KB | [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| Video | 1023 KB | [`MAX_VIDEO_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L27) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| Other files | Not auto-received | Requires manual acceptance | -- | + +### Behavior + +- When a message with a file attachment arrives: + 1. Check if file size is below the auto-receive threshold for its type + 2. If below: automatically call [`setFileToReceive(fileId:, userApprovedRelays:, encrypted:)`](../../Shared/Model/AppAPITypes.swift#L168) followed by download + 3. If above: show download button in chat item, wait for user action + 4. User manually triggers download via [`receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:)`](../../Shared/Model/AppAPITypes.swift#L167) + +### Relay Approval + +`userApprovedRelays` parameter: when the file is hosted on relays not in the user's configured server list, the user is asked for confirmation before connecting to unknown relays. + +--- + +## [4. File Size Constants](../../SimpleXChat/FileUtils.swift#L18) + +Defined in [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift): + +| Constant | Value | Line | +|----------|-------|------| +| `MAX_IMAGE_SIZE` | 261,120 (255 KB) | [L18](../../SimpleXChat/FileUtils.swift#L18) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L21](../../SimpleXChat/FileUtils.swift#L21) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 (510 KB) | [L24](../../SimpleXChat/FileUtils.swift#L24) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 (1023 KB) | [L27](../../SimpleXChat/FileUtils.swift#L27) | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 (1 GB) | [L30](../../SimpleXChat/FileUtils.swift#L30) | +| `MAX_FILE_SIZE_LOCAL` | Int64.max (no limit) | [L32](../../SimpleXChat/FileUtils.swift#L32) | +| `MAX_FILE_SIZE_SMP` | 8,000,000 (~7.6 MB) | [L34](../../SimpleXChat/FileUtils.swift#L34) | +| `MAX_VOICE_MESSAGE_LENGTH` | 300 s (5 min) | [L36](../../SimpleXChat/FileUtils.swift#L36) | + +```swift +// Image compression target for inline transfer +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255 KB + +// Auto-receive thresholds +public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = 522_240 // 510 KB (2 * MAX_IMAGE_SIZE) +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023 KB + +// Transfer method limits +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1 GB +public let MAX_FILE_SIZE_SMP: Int64 = 8_000_000 // ~7.6 MB +public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max // No limit (local notes) + +// Voice message constraints +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) // 5 minutes (300 seconds) +``` + +--- + +## 5. Image Handling + +### Compression Pipeline + +1. User selects image (camera or photo library) +2. Image is compressed to fit within [`MAX_IMAGE_SIZE`](../../SimpleXChat/FileUtils.swift#L18) (255KB): + - Progressive JPEG compression with decreasing quality + - Resize if dimensions are too large +3. Compressed image is base64-encoded into the message content +4. For larger images that cannot compress to 255KB: sent via XFTP + +### Display + +- `CIImageView` renders images in chat bubbles with aspect-fit sizing +- Tapping opens `FullScreenMediaView` with zoom/pan/share capabilities +- Thumbnail is displayed immediately; full-size loaded on demand for XFTP images + +### Animated Images + +- GIFs are handled by `AnimatedImageView` +- Displayed inline with animation support + +--- + +## 6. Voice Messages + +### Recording + +1. `ComposeVoiceView` manages the recording UI +2. `AudioRecPlay` handles `AVAudioRecorder` lifecycle +3. Recorded in compressed audio format +4. Maximum duration: [`MAX_VOICE_MESSAGE_LENGTH`](../../SimpleXChat/FileUtils.swift#L36) = 300 seconds (5 minutes) +5. Waveform data extracted for visualization + +### Transfer + +- Voice files up to [`MAX_VOICE_SIZE_AUTO_RCV`](../../SimpleXChat/FileUtils.swift#L24) (510KB) are auto-received +- Larger voice files follow standard file transfer flow +- Voice messages include waveform metadata for UI rendering + +### Playback + +- `CIVoiceView` / `FramedCIVoiceView` render voice messages +- Shows waveform visualization and play/pause control +- `ChatModel.stopPreviousRecPlay` ensures only one audio source plays at a time +- Playback position and progress tracked + +--- + +## [7. CryptoFile -- At-Rest Encryption](../../SimpleXChat/ChatTypes.swift#L4241) + +When [`apiSetEncryptLocalFiles(enable: true)`](../../Shared/Model/SimpleXAPI.swift#L384) is configured, files stored on the device are AES-encrypted. + +### [`CryptoFile`](../../SimpleXChat/ChatTypes.swift#L4241) Type + +```swift +struct CryptoFile { + var filePath: String + var cryptoArgs: CryptoFileArgs? // nil = unencrypted +} + +struct CryptoFileArgs { + var fileKey: String // AES encryption key + var fileNonce: String // AES nonce/IV +} +``` + +> Defined in [`ChatTypes.swift` L4241](../../SimpleXChat/ChatTypes.swift#L4241) (`CryptoFile`) and [L4289](../../SimpleXChat/ChatTypes.swift#L4289) (`CryptoFileArgs`). + +### Encryption Operations (C FFI) + +Implemented in [`CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`writeCryptoFile`](../../SimpleXChat/CryptoFile.swift#L18) | Write encrypted file, returns `CryptoFileArgs` | [L18](../../SimpleXChat/CryptoFile.swift#L18) | +| [`readCryptoFile`](../../SimpleXChat/CryptoFile.swift#L31) | Read and decrypt file, returns `Data` | [L31](../../SimpleXChat/CryptoFile.swift#L31) | +| [`encryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L54) | Encrypt existing file to new path | [L54](../../SimpleXChat/CryptoFile.swift#L54) | +| [`decryptCryptoFile`](../../SimpleXChat/CryptoFile.swift#L66) | Decrypt file to new path | [L66](../../SimpleXChat/CryptoFile.swift#L66) | + +### Storage + +- Encrypted files stored alongside unencrypted files in `Documents/files/` +- The `CryptoFileArgs` (key + nonce) are stored in the Haskell database, not on the filesystem +- Toggle via privacy settings: [`apiSetEncryptLocalFiles(enable:)`](../../Shared/Model/SimpleXAPI.swift#L384) + +--- + +## [8. File Storage Paths](../../SimpleXChat/FileUtils.swift#L199) + +### Directory Structure + +| Function | Path | Line | +|----------|------|------| +| [`getAppFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L208) | `Documents/files/` | [L208](../../SimpleXChat/FileUtils.swift#L208) | +| [`getTempFilesDirectory()`](../../SimpleXChat/FileUtils.swift#L199) | `Documents/temp_files/` | [L199](../../SimpleXChat/FileUtils.swift#L199) | +| [`getWallpaperDirectory()`](../../SimpleXChat/FileUtils.swift#L217) | `Documents/wallpapers/` | [L217](../../SimpleXChat/FileUtils.swift#L217) | +| [`getAppFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L212) | `Documents/files/{filename}` | [L212](../../SimpleXChat/FileUtils.swift#L212) | +| [`getWallpaperFilePath(_:)`](../../SimpleXChat/FileUtils.swift#L221) | `Documents/wallpapers/{filename}` | [L221](../../SimpleXChat/FileUtils.swift#L221) | + +```swift +func getAppFilesDirectory() -> URL // Documents/files/ +func getTempFilesDirectory() -> URL // Documents/temp_files/ +func getWallpaperDirectory() -> URL // Documents/wallpapers/ +``` + +### Path Management + +- Downloaded files: `Documents/files/{filename}` +- Temporary files during transfer: `Documents/temp_files/` +- Wallpaper images: `Documents/wallpapers/` +- File paths are set via [`apiSetAppFilePaths(filesFolder:, tempFolder:, assetsFolder:)`](../../Shared/Model/SimpleXAPI.swift#L377) at startup + +--- + +## 9. File Lifecycle + +### Sending + +``` +1. User selects file/image/video in compose +2. ComposeView creates ComposedMessage with file reference +3. apiSendMessages() → Haskell core processes: + a. File ≤ inline threshold: base64 encode into message + b. File > inline threshold: start XFTP upload +4. Upload events: + - ChatEvent.sndFileStart + - ChatEvent.sndFileProgressXFTP (periodic progress) + - ChatEvent.sndFileCompleteXFTP (upload done) + - ChatEvent.sndFileError (on failure) +``` + +### Receiving + +``` +1. Message with file attachment arrives +2. Auto-receive check: + a. Below threshold: automatic download starts + b. Above threshold: user sees download button +3. User triggers download (or auto-triggered): + - receiveFile(fileId:, userApprovedRelays:, encrypted:, inline:) +4. Download events: + - ChatEvent.rcvFileStart + - ChatEvent.rcvFileProgressXFTP (periodic progress) + - ChatEvent.rcvFileComplete (download done) + - ChatEvent.rcvFileError (on failure) + - ChatEvent.rcvFileSndCancelled (sender cancelled) +``` + +### Cancellation + +```swift +ChatCommand.cancelFile(fileId: Int64) +``` + +Cancels an in-progress upload or download. For XFTP transfers, also requests chunk deletion from relays. + +### Cleanup + +| Function | Purpose | Line | +|----------|---------|------| +| [`cleanupFile(_:)`](../../SimpleXChat/FileUtils.swift#L267) | Remove file associated with a chat item | [L267](../../SimpleXChat/FileUtils.swift#L267) | +| [`cleanupDirectFile(_:)`](../../SimpleXChat/FileUtils.swift#L260) | Remove file only for direct chats | [L260](../../SimpleXChat/FileUtils.swift#L260) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L243) | Delete file at URL | [L243](../../SimpleXChat/FileUtils.swift#L243) | +| [`removeFile(_:)`](../../SimpleXChat/FileUtils.swift#L251) | Delete file by name | [L251](../../SimpleXChat/FileUtils.swift#L251) | +| [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) | Remove all app files (preserving databases) | [L108](../../SimpleXChat/FileUtils.swift#L108) | +| [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) | Remove everything | [L86](../../SimpleXChat/FileUtils.swift#L86) | + +- When a `ChatItem` is deleted, its associated file is deleted from disk +- When a timed message expires, its file is deleted +- `ChatModel.filesToDelete` queues files for deferred deletion +- [`deleteAppFiles()`](../../SimpleXChat/FileUtils.swift#L108) removes all files (preserving databases) +- [`deleteAppDatabaseAndFiles()`](../../SimpleXChat/FileUtils.swift#L86) removes everything + +--- + +## [10. API Commands](../../Shared/Model/AppAPITypes.swift#L167) + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| [`receiveFile`](../../Shared/Model/AppAPITypes.swift#L167) | `fileId, userApprovedRelays, encrypted, inline` | Accept and start downloading a file | [L167](../../Shared/Model/AppAPITypes.swift#L167) | +| [`setFileToReceive`](../../Shared/Model/AppAPITypes.swift#L168) | `fileId, userApprovedRelays, encrypted` | Mark file for auto-receive (no immediate download) | [L168](../../Shared/Model/AppAPITypes.swift#L168) | +| [`cancelFile`](../../Shared/Model/AppAPITypes.swift#L169) | `fileId` | Cancel in-progress transfer | [L169](../../Shared/Model/AppAPITypes.swift#L169) | +| [`apiUploadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L179) | `userId, file: CryptoFile` | Upload file to XFTP without a chat context | [L179](../../Shared/Model/AppAPITypes.swift#L179) | +| [`apiDownloadStandaloneFile`](../../Shared/Model/AppAPITypes.swift#L180) | `userId, url, file: CryptoFile` | Download from XFTP URL | [L180](../../Shared/Model/AppAPITypes.swift#L180) | +| [`apiStandaloneFileInfo`](../../Shared/Model/AppAPITypes.swift#L181) | `url` | Get metadata for an XFTP URL | [L181](../../Shared/Model/AppAPITypes.swift#L181) | + +### File Transfer Events + +| Event | Description | Line | +|-------|-------------|------| +| [`rcvFileAccepted`](../../Shared/Model/AppAPITypes.swift#L1095) | Download request accepted | [L1095](../../Shared/Model/AppAPITypes.swift#L1095) | +| [`rcvFileStart`](../../Shared/Model/AppAPITypes.swift#L1097) | Download started | [L1097](../../Shared/Model/AppAPITypes.swift#L1097) | +| [`rcvFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1098) | Download progress (receivedSize, totalSize) | [L1098](../../Shared/Model/AppAPITypes.swift#L1098) | +| [`rcvFileComplete`](../../Shared/Model/AppAPITypes.swift#L1099) | Download complete | [L1099](../../Shared/Model/AppAPITypes.swift#L1099) | +| [`rcvFileSndCancelled`](../../Shared/Model/AppAPITypes.swift#L1101) | Sender cancelled the transfer | [L1101](../../Shared/Model/AppAPITypes.swift#L1101) | +| [`rcvFileError`](../../Shared/Model/AppAPITypes.swift#L1102) | Download failed | [L1102](../../Shared/Model/AppAPITypes.swift#L1102) | +| [`rcvFileWarning`](../../Shared/Model/AppAPITypes.swift#L1103) | Download warning (non-fatal) | [L1103](../../Shared/Model/AppAPITypes.swift#L1103) | +| [`sndFileStart`](../../Shared/Model/AppAPITypes.swift#L1105) | Upload started | [L1105](../../Shared/Model/AppAPITypes.swift#L1105) | +| [`sndFileComplete`](../../Shared/Model/AppAPITypes.swift#L1106) | Inline upload complete | [L1106](../../Shared/Model/AppAPITypes.swift#L1106) | +| [`sndFileProgressXFTP`](../../Shared/Model/AppAPITypes.swift#L1108) | XFTP upload progress (sentSize, totalSize) | [L1108](../../Shared/Model/AppAPITypes.swift#L1108) | +| [`sndFileCompleteXFTP`](../../Shared/Model/AppAPITypes.swift#L1110) | XFTP upload complete | [L1110](../../Shared/Model/AppAPITypes.swift#L1110) | +| [`sndFileRcvCancelled`](../../Shared/Model/AppAPITypes.swift#L1107) | Receiver cancelled | [L1107](../../Shared/Model/AppAPITypes.swift#L1107) | +| [`sndFileError`](../../Shared/Model/AppAPITypes.swift#L1112) | Upload failed | [L1112](../../Shared/Model/AppAPITypes.swift#L1112) | +| [`sndFileWarning`](../../Shared/Model/AppAPITypes.swift#L1113) | Upload warning (non-fatal) | [L1113](../../Shared/Model/AppAPITypes.swift#L1113) | + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| File utilities & constants | [`SimpleXChat/FileUtils.swift`](../../SimpleXChat/FileUtils.swift) | `MAX_IMAGE_SIZE`, `saveFile`, `removeFile`, `getMaxFileSize` | +| CryptoFile FFI operations | [`SimpleXChat/CryptoFile.swift`](../../SimpleXChat/CryptoFile.swift) | `writeCryptoFile`, `readCryptoFile`, `encryptCryptoFile`, `decryptCryptoFile` | +| CryptoFile / CryptoFileArgs types | [`SimpleXChat/ChatTypes.swift`](../../SimpleXChat/ChatTypes.swift) | `CryptoFile` (L4241), `CryptoFileArgs` (L4289) | +| API command definitions | [`Shared/Model/AppAPITypes.swift`](../../Shared/Model/AppAPITypes.swift) | `receiveFile`, `cancelFile`, `ChatEvent` file events | +| API implementations | [`Shared/Model/SimpleXAPI.swift`](../../Shared/Model/SimpleXAPI.swift) | `receiveFile` (L1471), `cancelFile` (L1590) | +| File view (chat item) | [`Shared/Views/Chat/ChatItem/CIFileView.swift`](../../Shared/Views/Chat/ChatItem/CIFileView.swift) | | +| Image view (chat item) | [`Shared/Views/Chat/ChatItem/CIImageView.swift`](../../Shared/Views/Chat/ChatItem/CIImageView.swift) | | +| Video view (chat item) | [`Shared/Views/Chat/ChatItem/CIVideoView.swift`](../../Shared/Views/Chat/ChatItem/CIVideoView.swift) | | +| Voice view (chat item) | [`Shared/Views/Chat/ChatItem/CIVoiceView.swift`](../../Shared/Views/Chat/ChatItem/CIVoiceView.swift) | | +| Compose file preview | [`Shared/Views/Chat/ComposeMessage/ComposeFileView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeFileView.swift) | | +| Compose image preview | [`Shared/Views/Chat/ComposeMessage/ComposeImageView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeImageView.swift) | | +| Compose voice preview | [`Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift`](../../Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift) | | +| C FFI (file encryption) | [`SimpleXChat/SimpleX.h`](../../SimpleXChat/SimpleX.h) | `chat_write_file`, `chat_read_file`, `chat_encrypt_file`, `chat_decrypt_file` | +| Haskell file logic | `../../src/Simplex/Chat/Files.hs` | -- | +| Haskell file store | `../../src/Simplex/Chat/Store/Files.hs` | -- | diff --git a/apps/ios/spec/services/notifications.md b/apps/ios/spec/services/notifications.md new file mode 100644 index 0000000000..1062833f9c --- /dev/null +++ b/apps/ios/spec/services/notifications.md @@ -0,0 +1,390 @@ +# SimpleX Chat iOS -- Push Notification Service + +> Technical specification for the notification system: NtfManager, Notification Service Extension (NSE), notification modes, and token lifecycle. +> +> Related specs: [Architecture](../architecture.md) | [API Reference](../api.md) | [Navigation](../client/navigation.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`NtfManager.swift`](../../Shared/Model/NtfManager.swift) | [`BGManager.swift`](../../Shared/Model/BGManager.swift) | [`Notifications.swift`](../../SimpleXChat/Notifications.swift) | [`NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Notification Modes](#2-notification-modes) +3. [NtfManager](#3-ntfmanager) +4. [Notification Service Extension (NSE)](#4-notification-service-extension) +5. [Token Lifecycle](#5-token-lifecycle) +6. [Notification Categories & Actions](#6-notification-categories--actions) +7. [Badge Management](#7-badge-management) +8. [Background Tasks (BGManager)](#8-background-tasks) + +--- + +## 1. Overview + +SimpleX Chat uses a privacy-preserving notification architecture. Because messages are end-to-end encrypted and the notification server never sees message content, the app uses a Notification Service Extension (NSE) to decrypt push payloads on-device before displaying notifications. + +``` +APNs Push → NSE receives encrypted payload + → NSE starts Haskell core (own chat_ctrl) + → NSE decrypts message using stored keys + → NSE creates UNNotificationContent with decrypted preview + → iOS displays notification to user +``` + +The notification system has three modes of operation, allowing users to choose their privacy/convenience tradeoff. + +--- + +## 2. Notification Modes + +| Mode | Description | Mechanism | +|------|-------------|-----------| +| **Instant** | Real-time notifications via Apple Push | APNs push triggers NSE, which decrypts and displays | +| **Periodic** | Background fetch every ~20 minutes | `BGAppRefreshTask` wakes app, checks for new messages | +| **Off** | No notifications | User must open app to see messages | + +### Configuration + +Notification mode is set via: +```swift +ChatCommand.apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) +``` + +`NotificationsMode` enum: `.instant`, `.periodic`, `.off` + +The mode is stored in `ChatModel.notificationMode` and persisted in `GroupDefaults`. + +--- + +## 3. NtfManager + +**File**: [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) + +Central notification coordinator. Singleton: `NtfManager.shared`. + +### [Class Definition](../../Shared/Model/NtfManager.swift#L27) + +```swift +class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { + static let shared = NtfManager() + public var navigatingToChat = false + private var granted = false + private var prevNtfTime: Dictionary = [:] +} +``` + +### Key Responsibilities + +| Method | Purpose | Line | +|--------|---------|------| +| [`registerCategories()`](../../Shared/Model/NtfManager.swift#L156) | Registers notification action categories with iOS | [156](../../Shared/Model/NtfManager.swift#L156) | +| [`requestAuthorization()`](../../Shared/Model/NtfManager.swift#L215) | Requests notification permission from user | [215](../../Shared/Model/NtfManager.swift#L215) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Updates app icon badge | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`processNotificationResponse(_:)`](../../Shared/Model/NtfManager.swift#L54) | Handles user interaction with notification | [54](../../Shared/Model/NtfManager.swift#L54) | +| [`notifyContactRequest(_:)`](../../Shared/Model/NtfManager.swift#L239) | Shows contact request notification | [239](../../Shared/Model/NtfManager.swift#L239) | +| [`notifyCallInvitation(_:)`](../../Shared/Model/NtfManager.swift#L258) | Shows incoming call notification | [258](../../Shared/Model/NtfManager.swift#L258) | +| [`notifyMessageReceived(_:)`](../../Shared/Model/NtfManager.swift#L250) | Shows message received notification | [250](../../Shared/Model/NtfManager.swift#L250) | + +### [Notification Response Processing](../../Shared/Model/NtfManager.swift#L40) + +When user taps a notification: + +1. `userNotificationCenter(didReceive:)` delegate method fires +2. If app is active: calls `processNotificationResponse()` immediately +3. If app is inactive: stores in `ChatModel.notificationResponse` for later processing +4. [`processNotificationResponse()`](../../Shared/Model/NtfManager.swift#L54): + - Extracts `userId` from `userInfo` -- switches user if needed + - Extracts `chatId` -- navigates to the conversation + - Handles action identifiers (accept contact, accept/reject call) + +### [Rate Limiting](../../Shared/Model/NtfManager.swift#L144) + +`prevNtfTime` dictionary prevents notification flooding: +- Each chat has a timestamp of its last notification +- New notifications are suppressed if within `ntfTimeInterval` (1 second) of the previous one for the same chat + +--- + +## 4. Notification Service Extension (NSE) + +**File**: [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) + +### Architecture + +The NSE is a separate process that iOS launches when a push notification arrives. It has: +- Its own Haskell runtime instance (`chat_ctrl`) +- Shared database access (via app group container) +- ~30 second execution window per notification +- No access to main app's in-memory state + +### [Processing Flow](../../SimpleX NSE/NotificationService.swift#L300) + +``` +1. didReceive(request:, withContentHandler:) L300 + ├── 2. Initialize Haskell core (if not already running) + │ └── chat_migrate_init_key() with shared DB path L861 + ├── 3. Decode encrypted notification payload + │ └── apiGetNtfConns(nonce:, encNtfInfo:) L1123 + ├── 4. Fetch and decrypt messages + │ └── apiGetConnNtfMessages(connMsgReqs:) L1140 + ├── 5. Create notification content + │ ├── Contact name as title + │ ├── Decrypted message preview as body + │ └── Thread identifier for grouping + └── 6. Deliver to content handler +``` + +### NSE Commands + +The NSE uses a subset of the chat API: + +| Command | Purpose | Line | +|---------|---------|------| +| [`apiGetNtfConns(nonce:, encNtfInfo:)`](../../SimpleX NSE/NotificationService.swift#L1123) | Decrypt notification connection info | [1123](../../SimpleX NSE/NotificationService.swift#L1123) | +| [`apiGetConnNtfMessages(connMsgReqs:)`](../../SimpleX NSE/NotificationService.swift#L1140) | Fetch messages for notification connections | [1140](../../SimpleX NSE/NotificationService.swift#L1140) | + +### Database Coordination + +- NSE checks `appStateGroupDefault` before processing +- If main app is `.active`, NSE may skip processing (main app handles notifications directly) +- NSE uses `chat_close_store` / `chat_reopen_store` for safe concurrent access + +### [Preview Modes](../../SimpleXChat/APITypes.swift#L664) + +`NotificationPreviewMode` controls what the NSE shows: + +| Mode | Title | Body | +|------|-------|------| +| `.message` | Contact name | Message text | +| `.contact` | Contact name | "New message" | +| `.hidden` | "SimpleX" | "New message" | + +### Key Internal Types + +| Type | Purpose | Line | +|------|---------|------| +| [`NSENotificationData`](../../SimpleX NSE/NotificationService.swift#L27) | Enum of possible notification payloads | [27](../../SimpleX NSE/NotificationService.swift#L27) | +| [`NSEThreads`](../../SimpleX NSE/NotificationService.swift#L82) | Concurrency coordinator for multiple NSE instances | [82](../../SimpleX NSE/NotificationService.swift#L82) | +| [`NotificationEntity`](../../SimpleX NSE/NotificationService.swift#L245) | Per-connection processing state | [245](../../SimpleX NSE/NotificationService.swift#L245) | +| [`NotificationService`](../../SimpleX NSE/NotificationService.swift#L287) | Main NSE class (`UNNotificationServiceExtension`) | [287](../../SimpleX NSE/NotificationService.swift#L287) | +| [`NSEChatState`](../../SimpleX NSE/NotificationService.swift#L781) | Singleton managing NSE lifecycle state | [781](../../SimpleX NSE/NotificationService.swift#L781) | + +### Key Internal Functions + +| Function | Purpose | Line | +|----------|---------|------| +| [`startChat()`](../../SimpleX NSE/NotificationService.swift#L836) | Initializes Haskell core for NSE | [836](../../SimpleX NSE/NotificationService.swift#L836) | +| [`doStartChat()`](../../SimpleX NSE/NotificationService.swift#L861) | Performs actual chat initialization (migration, config) | [861](../../SimpleX NSE/NotificationService.swift#L861) | +| [`activateChat()`](../../SimpleX NSE/NotificationService.swift#L907) | Reactivates suspended chat controller | [907](../../SimpleX NSE/NotificationService.swift#L907) | +| [`suspendChat(_:)`](../../SimpleX NSE/NotificationService.swift#L921) | Suspends chat controller with timeout | [921](../../SimpleX NSE/NotificationService.swift#L921) | +| [`receiveMessages()`](../../SimpleX NSE/NotificationService.swift#L954) | Main message-receive loop | [954](../../SimpleX NSE/NotificationService.swift#L954) | +| [`receivedMsgNtf(_:)`](../../SimpleX NSE/NotificationService.swift#L1003) | Maps chat events to notification data | [1003](../../SimpleX NSE/NotificationService.swift#L1003) | +| [`receiveNtfMessages(_:)`](../../SimpleX NSE/NotificationService.swift#L403) | Orchestrates notification message fetch and delivery | [403](../../SimpleX NSE/NotificationService.swift#L403) | +| [`deliverBestAttemptNtf()`](../../SimpleX NSE/NotificationService.swift#L604) | Delivers the best available notification content | [604](../../SimpleX NSE/NotificationService.swift#L604) | +| [`didReceive(_:withContentHandler:)`](../../SimpleX%20NSE/NotificationService.swift#L300) | Main NSE entry point -- processes incoming notification | [300](../../SimpleX%20NSE/NotificationService.swift#L300) | + +--- + +## 5. Token Lifecycle + +### Registration Flow + +``` +1. App starts → AppDelegate.didRegisterForRemoteNotificationsWithDeviceToken + └── ChatModel.deviceToken = token + +2. Token registration (when chat running and token available): + └── apiRegisterToken(token, notificationMode) + └── Response: ntfToken(token, status, ntfMode, ntfServer) + └── ChatModel.tokenStatus = status + +3. Token verification (if server requires): + └── apiVerifyToken(token, nonce, code) + └── ChatModel.tokenRegistered = true + +4. Token check (periodic): + └── apiCheckToken(token) + └── Updates ChatModel.tokenStatus +``` + +### Token States (NtfTknStatus) + +| Status | Description | +|--------|-------------| +| `.new` | Token just registered, not yet verified | +| `.registered` | Token registered with notification server | +| `.confirmed` | Token confirmed and ready | +| `.active` | Token actively receiving notifications | +| `.expired` | Token expired, needs re-registration | +| `.invalid` | Token invalid, needs new registration | +| `.invalidBad` | Token invalid due to bad data | +| `.invalidTopic` | Token invalid due to wrong topic | +| `.invalidExpired` | Token invalid because it expired | +| `.invalidUnregistered` | Token invalid, was unregistered | + +### Token Deletion + +```swift +ChatCommand.apiDeleteToken(token: DeviceToken) +``` + +Called when: +- User switches to `.off` notification mode +- User deletes their profile +- Token becomes invalid and needs replacement + +--- + +## 6. Notification Categories & Actions + +Registered in [`NtfManager.registerCategories()`](../../Shared/Model/NtfManager.swift#L156): + +### Contact Request Category + +```swift +// Category: "NTF_CAT_CONTACT_REQUEST" +// Actions: +// - "NTF_ACT_ACCEPT_CONTACT": Accept contact request +``` + +When user taps "Accept" on a contact request notification: +1. `processNotificationResponse()` detects `ntfActionAcceptContact` +2. Calls `apiAcceptContact(incognito: false, contactReqId:)` +3. Navigates to the new contact's chat + +### Call Invitation Category + +```swift +// Category: "NTF_CAT_CALL_INVITATION" +// Actions: +// - "NTF_ACT_ACCEPT_CALL": Accept incoming call +// - "NTF_ACT_REJECT_CALL": Reject incoming call +``` + +When user taps "Accept" / "Reject" on a call notification: +1. `processNotificationResponse()` detects the action +2. Sets `ChatModel.ntfCallInvitationAction = (chatId, .accept/.reject)` +3. Call controller picks up the pending action + +### Message Category + +Standard tap-to-open behavior navigates to the chat. + +### Many Events Category + +Batch notification for multiple events -- navigates to the app without specific chat context. + +--- + +## 7. Badge Management + +The app icon badge shows the total unread message count: + +```swift +// Updated when: +// 1. App enters background: +NtfManager.shared.setNtfBadgeCount(chatModel.totalUnreadCountForAllUsers()) + +// 2. Messages are read: +// Badge is recalculated and updated + +// 3. NSE receives notification: +// NSE updates badge based on its count +``` + +`totalUnreadCountForAllUsers()` sums unread counts across all user profiles (not just the active user). + +### NSE Badge Handling + +| Method | Purpose | Line | +|--------|---------|------| +| [`setBadgeCount()`](../../SimpleX NSE/NotificationService.swift#L592) | Increments badge via `ntfBadgeCountGroupDefault` | [592](../../SimpleX NSE/NotificationService.swift#L592) | +| [`setNtfBadgeCount(_:)`](../../Shared/Model/NtfManager.swift#L264) | Sets badge on `UIApplication` | [264](../../Shared/Model/NtfManager.swift#L264) | +| [`changeNtfBadgeCount(by:)`](../../Shared/Model/NtfManager.swift#L270) | Adjusts badge by delta | [270](../../Shared/Model/NtfManager.swift#L270) | + +--- + +## 8. Background Tasks + +**File**: [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) + +### [BGManager](../../Shared/Model/BGManager.swift#L30) + +```swift +class BGManager { + static let shared = BGManager() + func register() // Register BGAppRefreshTask handlers + func schedule() // Schedule next background refresh +} +``` + +| Method | Purpose | Line | +|--------|---------|------| +| [`register()`](../../Shared/Model/BGManager.swift#L38) | Registers `BGAppRefreshTask` handler with iOS | [38](../../Shared/Model/BGManager.swift#L38) | +| [`schedule()`](../../Shared/Model/BGManager.swift#L46) | Schedules next background refresh request | [46](../../Shared/Model/BGManager.swift#L46) | +| [`handleRefresh(_:)`](../../Shared/Model/BGManager.swift#L74) | Processes background refresh task | [74](../../Shared/Model/BGManager.swift#L74) | +| [`completionHandler(_:)`](../../Shared/Model/BGManager.swift#L95) | Creates completion callback with cleanup | [95](../../Shared/Model/BGManager.swift#L95) | +| [`receiveMessages(_:)`](../../Shared/Model/BGManager.swift#L112) | Activates chat and receives pending messages | [112](../../Shared/Model/BGManager.swift#L112) | + +### Background Refresh (Periodic Mode) + +When notification mode is `.periodic`: + +1. `BGManager.schedule()` is called when app enters background +2. iOS wakes the app in the background approximately every 20 minutes +3. `BGAppRefreshTask` handler: + - Activates the chat engine: `apiActivateChat(restoreChat: true)` + - Checks for new messages + - Creates local notifications for any new messages + - Suspends chat: `apiSuspendChat(timeoutMicroseconds:)` + - Schedules next refresh +4. Must complete within ~30 seconds or iOS terminates the task + +### Background Task Protection + +All API calls use `beginBGTask()` / `endBackgroundTask()` to request extra execution time: + +```swift +func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) { + var id: UIBackgroundTaskIdentifier! + // ... + id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask) + return endTask +} +``` + +Maximum task duration: `maxTaskDuration = 15` seconds. + +--- + +## Notification Content Builders + +**File**: [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) + +| Function | Purpose | Line | +|----------|---------|------| +| [`createContactRequestNtf()`](../../SimpleXChat/Notifications.swift#L27) | Builds notification for incoming contact request | [L27](../../SimpleXChat/Notifications.swift#L27) | +| [`createContactConnectedNtf()`](../../SimpleXChat/Notifications.swift#L46) | Builds notification for contact connected event | [L46](../../SimpleXChat/Notifications.swift#L46) | +| [`createMessageReceivedNtf()`](../../SimpleXChat/Notifications.swift#L66) | Builds notification for received message | [L66](../../SimpleXChat/Notifications.swift#L66) | +| [`createCallInvitationNtf()`](../../SimpleXChat/Notifications.swift#L86) | Builds notification for incoming call | [L86](../../SimpleXChat/Notifications.swift#L86) | +| [`createConnectionEventNtf()`](../../SimpleXChat/Notifications.swift#L102) | Builds notification for connection events | [L102](../../SimpleXChat/Notifications.swift#L102) | +| [`createErrorNtf()`](../../SimpleXChat/Notifications.swift#L134) | Builds notification for database/encryption errors | [L134](../../SimpleXChat/Notifications.swift#L134) | +| [`createAppStoppedNtf()`](../../SimpleXChat/Notifications.swift#L160) | Builds notification when app is stopped | [L160](../../SimpleXChat/Notifications.swift#L160) | +| [`createNotification()`](../../SimpleXChat/Notifications.swift#L175) | Generic notification builder (used by all above) | [L175](../../SimpleXChat/Notifications.swift#L175) | +| [`hideSecrets()`](../../SimpleXChat/Notifications.swift#L200) | Redacts secret-formatted text in previews | [L200](../../SimpleXChat/Notifications.swift#L200) | + +--- + +## Source Files + +| File | Path | +|------|------| +| Notification manager | [`Shared/Model/NtfManager.swift`](../../Shared/Model/NtfManager.swift) | +| Background manager | [`Shared/Model/BGManager.swift`](../../Shared/Model/BGManager.swift) | +| Notification types | [`SimpleXChat/Notifications.swift`](../../SimpleXChat/Notifications.swift) | +| NSE service | [`SimpleX NSE/NotificationService.swift`](../../SimpleX NSE/NotificationService.swift) | +| App delegate (token) | `Shared/AppDelegate.swift` | +| Notification settings UI | `Shared/Views/UserSettings/NotificationsView.swift` | diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md new file mode 100644 index 0000000000..321f3307f9 --- /dev/null +++ b/apps/ios/spec/services/theme.md @@ -0,0 +1,383 @@ +# SimpleX Chat iOS -- Theme Engine + +> Technical specification for the theming system: ThemeManager, default themes, customization layers, wallpapers, and YAML export. +> +> Related specs: [State Management](../state.md) | [Architecture](../architecture.md) | [README](../README.md) +> Related product: [Product Overview](../../product/README.md) + +**Source:** [`ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | [`ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | [`ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | [`Theme.swift`](../../Shared/Theme/Theme.swift) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Customization Layers](#4-customization-layers) +5. [Color System](#5-color-system) +6. [Wallpapers](#6-wallpapers) +7. [Chat Bubble Styling](#7-chat-bubble-styling) +8. [Color Scheme Mode](#8-color-scheme-mode) +9. [YAML Export/Import](#9-yaml-exportimport) + +--- + +## 1. Overview + +The theme engine provides a layered customization system where themes can be overridden at multiple levels: global defaults, per-user, and per-chat. + +``` +Theme Resolution Order (most specific wins): +┌─────────────────────┐ +│ Per-chat override │ apiSetChatUIThemes(chatId:, themes:) +├─────────────────────┤ +│ Per-user override │ apiSetUserUIThemes(userId:, themes:) +├─────────────────────┤ +│ App settings theme │ themeOverridesDefault (UserDefaults) +├─────────────────────┤ +│ Base theme │ Light / Dark / SimpleX / Black +└─────────────────────┘ +``` + +The resolved theme is published as `AppTheme.shared` and consumed by all SwiftUI views via `@EnvironmentObject`. + +--- + +## 2. [ThemeManager](../../Shared/Theme/ThemeManager.swift) (L15) + +**File**: [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) + +Static utility class that resolves the current theme by merging all customization layers. + +### [ActiveTheme](../../Shared/Theme/ThemeManager.swift#L17) + +The resolved theme output: + +```swift +struct ActiveTheme: Equatable { + let name: String // Theme name (e.g., "light", "dark", "simplex", "black", "system") + let base: DefaultTheme // Base theme enum + let colors: Colors // Resolved color palette + let appColors: AppColors // App-specific colors (sent/received bubbles, etc.) + var wallpaper: AppWallpaper // Resolved wallpaper +} +``` + +### Key Static Methods + +| Method | Purpose | Line | +|--------|---------|------| +| [`applyTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L124) | Apply a theme by name, updates `AppTheme.shared` | [L124](../../Shared/Theme/ThemeManager.swift#L124) | +| [`currentColors(...)`](../../Shared/Theme/ThemeManager.swift#L64) | Resolve full theme from all layers | [L64](../../Shared/Theme/ThemeManager.swift#L64) | +| [`defaultActiveTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L48) | Get default theme override from app settings | [L48](../../Shared/Theme/ThemeManager.swift#L48) | +| [`currentThemeOverridesForExport(...)`](../../Shared/Theme/ThemeManager.swift#L105) | Get current overrides for YAML export | [L105](../../Shared/Theme/ThemeManager.swift#L105) | +| [`adjustWindowStyle()`](../../Shared/Theme/ThemeManager.swift#L136) | Adjust window style after theme change | [L136](../../Shared/Theme/ThemeManager.swift#L136) | +| [`changeDarkTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L166) | Change the dark theme variant | [L166](../../Shared/Theme/ThemeManager.swift#L166) | +| [`saveAndApplyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L173) | Save and apply a theme color override | [L173](../../Shared/Theme/ThemeManager.swift#L173) | +| [`applyThemeColor(...)`](../../Shared/Theme/ThemeManager.swift#L186) | Apply a theme color to a binding | [L186](../../Shared/Theme/ThemeManager.swift#L186) | +| [`saveAndApplyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L191) | Save and apply a wallpaper change | [L191](../../Shared/Theme/ThemeManager.swift#L191) | +| [`copyFromSameThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L213) | Copy overrides from matching theme | [L213](../../Shared/Theme/ThemeManager.swift#L213) | +| [`applyWallpaper(...)`](../../Shared/Theme/ThemeManager.swift#L256) | Apply wallpaper to a binding | [L256](../../Shared/Theme/ThemeManager.swift#L256) | +| [`saveAndApplyThemeOverrides(...)`](../../Shared/Theme/ThemeManager.swift#L267) | Save and apply full theme overrides | [L267](../../Shared/Theme/ThemeManager.swift#L267) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L288) | Reset all color overrides (CodableDefault) | [L288](../../Shared/Theme/ThemeManager.swift#L288) | +| [`resetAllThemeColors(_:)`](../../Shared/Theme/ThemeManager.swift#L302) | Reset all color overrides (Binding) | [L302](../../Shared/Theme/ThemeManager.swift#L302) | +| [`removeTheme(_:)`](../../Shared/Theme/ThemeManager.swift#L311) | Remove a saved theme by ID | [L311](../../Shared/Theme/ThemeManager.swift#L311) | + +### Theme Resolution Algorithm + +[`currentColors()`](../../Shared/Theme/ThemeManager.swift#L64) in `ThemeManager.swift`: + +1. Determine base theme from `currentThemeDefault`: + - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) + - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +3. Look up app settings theme override (`themeOverridesDefault`) +4. Look up per-user theme override (`User.uiThemes`) +5. Look up per-chat theme override (from ChatInfo) +6. Look up wallpaper preset colors (if wallpaper has preset color overrides) +7. Merge layers: base <- app override <- preset wallpaper colors <- per-user <- per-chat +8. Return `ActiveTheme` with resolved colors, app colors, and wallpaper + +--- + +## 3. Default Themes + +Four built-in themes with pre-defined color palettes: + +| Theme | Enum | Key Characteristics | +|-------|------|---------------------| +| **Light** | `DefaultTheme.LIGHT` | White background, standard colors | +| **Dark** | `DefaultTheme.DARK` | Dark gray background, light text | +| **SimpleX** | `DefaultTheme.SIMPLEX` | Brand purple accents, dark background | +| **Black** | `DefaultTheme.BLACK` | Pure black background (OLED), high contrast | + +### [DefaultTheme](../../SimpleXChat/Theme/ThemeTypes.swift#L13) Enum + +```swift +enum DefaultTheme { + case LIGHT + case DARK + case SIMPLEX + case BLACK + + static let SYSTEM_THEME_NAME = "SYSTEM" + + var themeName: String { ... } + var mode: DefaultThemeMode { ... } // .light or .dark +} +``` + +### Color Palettes + +Each base theme defines two palette types: +- [`Colors`](../../SimpleXChat/Theme/ThemeTypes.swift#L44): Standard UI colors (primary, background, surface, error, onBackground, onSurface) +- [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) + +Palette instances: +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) + +--- + +## 4. Customization Layers + +### Layer 1: App Settings Theme + +Stored in `themeOverridesDefault` (UserDefaults). Contains `[ThemeOverrides]` -- an array of theme overrides, one per base theme. + +#### [`ThemeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L385) + +```swift +struct ThemeOverrides: Codable { + var base: DefaultTheme + var colors: ThemeColors? // Color overrides + var wallpaper: ThemeWallpaper? // Wallpaper setting +} +``` + +### Layer 2: Per-User Theme + +Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in the Haskell database via `apiSetUserUIThemes(userId:, themes:)`. + +#### [`ThemeModeOverrides`](../../SimpleXChat/Theme/ThemeTypes.swift#L570) + +```swift +struct ThemeModeOverrides: Codable { + var light: ThemeModeOverride? + var dark: ThemeModeOverride? +} +``` + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) + +```swift +struct ThemeModeOverride: Codable { + var mode: DefaultThemeMode? + var colors: ThemeColors? + var wallpaper: ThemeWallpaper? + var type: WallpaperType? // Computed from wallpaper +} +``` + +### Layer 3: Per-Chat Theme + +Stored per-chat via `apiSetChatUIThemes(chatId:, themes:)`. Same `ThemeModeOverrides` structure. + +### Override Merging + +Colors are merged field-by-field: if a more-specific layer defines a color, it overrides; if nil, falls through to the next layer. + +--- + +## 5. Color System + +**File**: [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) + +### [ThemeColors](../../SimpleXChat/Theme/ThemeTypes.swift#L230) + +Overridable color definitions: + +```swift +struct ThemeColors: Codable { + var primary: String? // Primary brand color + var primaryVariant: String? // Primary variant + var secondary: String? // Secondary color + var secondaryVariant: String? // Secondary variant + var background: String? // Main background + var surface: String? // Card/surface background + var title: String? // Title text color + var primaryVariant2: String? // Additional variant + var sentMessage: String? // Sent message bubble + var sentQuote: String? // Sent quote background + var receivedMessage: String? // Received message bubble + var receivedQuote: String? // Received quote background +} +``` + +Colors are stored as hex strings (e.g., `"#FF6600"`) and converted to SwiftUI `Color` values at resolution time. + +### [Colors](../../SimpleXChat/Theme/ThemeTypes.swift#L44) (Resolved Palette) + +```swift +struct Colors { + var isLight: Bool + var primary: Color + var primaryVariant: Color + var secondary: Color + var secondaryVariant: Color + var background: Color + var surface: Color + var error: Color + var onBackground: Color + var onSurface: Color + // ... etc +} +``` + +### [AppColors](../../SimpleXChat/Theme/ThemeTypes.swift#L90) (Resolved App-Specific) + +```swift +struct AppColors { + var title: Color + var primaryVariant2: Color + var sentMessage: Color + var sentQuote: Color + var receivedMessage: Color + var receivedQuote: Color +} +``` + +--- + +## 6. Wallpapers + +**File**: [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) + +### [Preset Wallpapers](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L13) + +6 built-in wallpaper presets: + +| Preset | ID | Description | +|--------|-----|-------------| +| Cats | `cats` | Cat-themed pattern | +| Flowers | `flowers` | Floral pattern | +| Hearts | `hearts` | Heart pattern | +| Kids | `kids` | Children's pattern | +| School | `school` | School/notebook pattern (default) | +| Travel | `travel` | Travel-themed pattern | + +Each preset defines per-theme color tints (`PresetWallpaper.colors[DefaultTheme]`) that subtly adjust the color palette to complement the wallpaper. + +### Custom Wallpapers + +Users can set a custom image as wallpaper: +- Stored in `Documents/wallpapers/` directory +- Scaled and tiled to fill the chat background +- Custom wallpapers can be combined with color overrides + +### [WallpaperType](../../SimpleXChat/Theme/ChatWallpaperTypes.swift#L311) + +```swift +enum WallpaperType { + case preset(filename: String, scale: Float?) // Built-in wallpaper + case image(filename: String, scale: Float?) // Custom image + case empty // No wallpaper +} +``` + +### [AppWallpaper](../../SimpleXChat/Theme/ThemeTypes.swift#L142) (Resolved) + +```swift +struct AppWallpaper { + var background: Color? // Background color override + var tint: Color? // Tint/overlay color + var type: WallpaperType +} +``` + +--- + +## 7. Chat Bubble Styling + +Configurable bubble appearance properties: + +| Property | Description | Stored In | +|----------|-------------|-----------| +| `chatItemRoundness` | Corner radius of message bubbles | App settings | +| `chatItemTail` | Whether bubbles have a tail/arrow | App settings | +| Avatar corner radius | Roundness of profile avatars | App settings | + +These are configured in [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) ([L26](../../Shared/Views/UserSettings/AppearanceSettings.swift#L26)). + +--- + +## 8. Color Scheme Mode + +### System Follow + +When theme is set to `"system"` (DefaultTheme.SYSTEM_THEME_NAME): +- Light mode: uses `DefaultTheme.LIGHT` palette +- Dark mode: uses the configured dark theme (`systemDarkThemeDefault`), which can be Dark, SimpleX, or Black + +### Forced Mode + +Users can force light or dark mode regardless of system setting by selecting a specific theme other than "system". + +### Detection + +[`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95): + +```swift +var systemInDarkThemeCurrently: Bool { + return UITraitCollection.current.userInterfaceStyle == .dark +} +``` + +`ChatModel.currentUser` setter triggers [`ThemeManager.applyTheme()`](../../Shared/Theme/ThemeManager.swift#L124) to handle per-user theme overrides when switching users. + +--- + +## 9. YAML Export/Import + +Theme configurations can be exported as YAML for sharing: + +### Export + +[`ThemeManager.currentThemeOverridesForExport()`](../../Shared/Theme/ThemeManager.swift#L105) generates a `ThemeOverrides` representing the current resolved theme, which is then serialized to YAML using the Yams library. + +### Import + +YAML theme strings are parsed back into `ThemeOverrides` and applied as app settings theme overrides. + +Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift): + +| Function | Purpose | Line | +|----------|---------|------| +| [`ImportExportThemeSection`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | UI section for import/export controls | [L603](../../Shared/Views/UserSettings/AppearanceSettings.swift#L603) | +| [`ThemeImporter`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | ViewModifier for YAML file import | [L640](../../Shared/Views/UserSettings/AppearanceSettings.swift#L640) | +| [`decodeYAML(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | Parse YAML string into Decodable type | [L1150](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1150) | +| [`encodeThemeOverrides(_:)`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | Encode ThemeOverrides to YAML string | [L1160](../../Shared/Views/UserSettings/AppearanceSettings.swift#L1160) | + +### Toolbar Material + +[`ToolbarMaterial`](../../Shared/Views/UserSettings/AppearanceSettings.swift#L319) controls the navigation bar appearance: +- Configurable opacity/material (translucent, opaque) +- Stored in app settings + +--- + +## Source Files + +| File | Path | Key Definitions | +|------|------|-----------------| +| Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | +| Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | +| App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | +| Appearance settings UI | [`Shared/Views/UserSettings/AppearanceSettings.swift`](../../Shared/Views/UserSettings/AppearanceSettings.swift) | `AppearanceSettings` (L26), `ToolbarMaterial` (L319), `ImportExportThemeSection` (L603) | +| Theme mode editor | `Shared/Views/Helpers/ThemeModeEditor.swift` | Theme mode selection UI | +| Haskell theme types | `../../src/Simplex/Chat/Types/UITheme.hs` | Server-side theme persistence | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md new file mode 100644 index 0000000000..6dda4ba275 --- /dev/null +++ b/apps/ios/spec/state.md @@ -0,0 +1,517 @@ +# SimpleX Chat iOS -- State Management + +**Source:** [`ChatModel.swift`](../Shared/Model/ChatModel.swift#L1-L1404) | [`ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1-L5377) + +> Technical specification for the app's state architecture: ChatModel, ItemsModel, Chat, ChatInfo, and preference storage. +> +> Related specs: [Architecture](architecture.md) | [API Reference](api.md) | [README](README.md) +> Related product: [Concept Index](../product/concepts.md) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel -- Primary App State](#2-chatmodel) +3. [ItemsModel -- Per-Chat Message State](#3-itemsmodel) +4. [ChatTagsModel -- Tag Filtering State](#4-chattagsmodel) +5. [ChannelRelaysModel -- Channel Relay State](#5-channelrelaysmodel) +6. [Chat -- Single Conversation State](#6-chat) +7. [ChatInfo -- Conversation Metadata](#7-chatinfo) +8. [State Flow](#8-state-flow) +9. [Preference Storage](#9-preference-storage) + +--- + +## 1. Overview + +The app uses SwiftUI's `ObservableObject` pattern for reactive state management. The state hierarchy is: + +``` +ChatModel (singleton -- global app state) +├── currentUser: User? +├── users: [UserInfo] +├── chats: [Chat] (chat list) +├── chatId: String? (active chat ID) +├── im: ItemsModel.shared (primary chat items) +├── secondaryIM: ItemsModel? (secondary chat items, e.g. support scope) +├── activeCall: Call? +├── callInvitations: [ChatId: RcvCallInvitation] +├── deviceToken / savedToken / tokenStatus +├── notificationMode: NotificationsMode +├── onboardingStage: OnboardingStage? +├── migrationState: MigrationToState? +└── ... (50+ @Published properties) + +ItemsModel (singleton + secondary instances -- per-chat message state) +├── reversedChatItems: [ChatItem] (messages in reverse order) +├── chatState: ActiveChatState (pagination/split state) +├── isLoading / showLoadingProgress +└── preloadState: PreloadState + +Chat (per-conversation -- one per entry in chat list) +├── chatInfo: ChatInfo (type + metadata) +├── chatItems: [ChatItem] (preview items) +└── chatStats: ChatStats (unread counts) + +ChatTagsModel (singleton -- filter state) +├── userTags: [ChatTag] +├── activeFilter: ActiveFilter? +├── presetTags: [PresetTag: Int] +└── unreadTags: [Int64: Int] +``` + +--- + +## 2. [ChatModel](../Shared/Model/ChatModel.swift#L353-L1289) + +**Class**: `final class ChatModel: ObservableObject` +**Singleton**: `ChatModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L353) + +### Key Published Properties + +#### App Lifecycle +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `onboardingStage` | `OnboardingStage?` | Current onboarding step | [L354](../Shared/Model/ChatModel.swift#L354) | +| `chatInitialized` | `Bool` | Whether chat has been initialized | [L363](../Shared/Model/ChatModel.swift#L363) | +| `chatRunning` | `Bool?` | Whether chat engine is running | [L364](../Shared/Model/ChatModel.swift#L364) | +| `chatDbChanged` | `Bool` | Whether DB was changed externally | [L365](../Shared/Model/ChatModel.swift#L365) | +| `chatDbEncrypted` | `Bool?` | Whether DB is encrypted | [L366](../Shared/Model/ChatModel.swift#L366) | +| `chatDbStatus` | `DBMigrationResult?` | DB migration status | [L367](../Shared/Model/ChatModel.swift#L367) | +| `ctrlInitInProgress` | `Bool` | Whether controller is initializing | [L368](../Shared/Model/ChatModel.swift#L368) | +| `migrationState` | `MigrationToState?` | Device migration state | [L417](../Shared/Model/ChatModel.swift#L417) | + +#### User State +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `currentUser` | `User?` | Active user profile (triggers theme reapply on change) | [L357](../Shared/Model/ChatModel.swift#L357) | +| `users` | `[UserInfo]` | All user profiles | [L362](../Shared/Model/ChatModel.swift#L362) | +| `v3DBMigration` | `V3DBMigrationState` | Legacy DB migration state | [L356](../Shared/Model/ChatModel.swift#L356) | + +#### Chat List +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chats` | `[Chat]` (private set) | All conversations for current user | [L374](../Shared/Model/ChatModel.swift#L374) | +| `deletedChats` | `Set` | Chat IDs pending deletion animation | [L375](../Shared/Model/ChatModel.swift#L375) | + +#### Active Chat +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatId` | `String?` | Currently open chat ID | [L377](../Shared/Model/ChatModel.swift#L377) | +| `chatAgentConnId` | `String?` | Agent connection ID for active chat | [L378](../Shared/Model/ChatModel.swift#L378) | +| `chatSubStatus` | `SubscriptionStatus?` | Active chat subscription status | [L379](../Shared/Model/ChatModel.swift#L379) | +| `openAroundItemId` | `ChatItem.ID?` | Item to scroll to when opening | [L380](../Shared/Model/ChatModel.swift#L380) | +| `chatToTop` | `String?` | Chat to scroll to top | [L381](../Shared/Model/ChatModel.swift#L381) | +| `groupMembers` | `[GMember]` | Members of active group | [L382](../Shared/Model/ChatModel.swift#L382) | +| `groupMembersIndexes` | `[Int64: Int]` | Member ID to index mapping | [L383](../Shared/Model/ChatModel.swift#L383) | +| `membersLoaded` | `Bool` | Whether members have been loaded | [L384](../Shared/Model/ChatModel.swift#L384) | +| `secondaryIM` | `ItemsModel?` | Secondary items model (e.g. support chat scope) | [L435](../Shared/Model/ChatModel.swift#L435) | + +#### Authentication +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `contentViewAccessAuthenticated` | `Bool` | Whether user has passed authentication | [L371](../Shared/Model/ChatModel.swift#L371) | +| `laRequest` | `LocalAuthRequest?` | Pending authentication request | [L372](../Shared/Model/ChatModel.swift#L372) | + +#### Notifications +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `deviceToken` | `DeviceToken?` | Current APNs device token | [L395](../Shared/Model/ChatModel.swift#L395) | +| `savedToken` | `DeviceToken?` | Previously saved token | [L396](../Shared/Model/ChatModel.swift#L396) | +| `tokenRegistered` | `Bool` | Whether token is registered with server | [L397](../Shared/Model/ChatModel.swift#L397) | +| `tokenStatus` | `NtfTknStatus?` | Token registration status | [L399](../Shared/Model/ChatModel.swift#L399) | +| `notificationMode` | `NotificationsMode` | Current notification mode (.off/.periodic/.instant) | [L400](../Shared/Model/ChatModel.swift#L400) | +| `notificationServer` | `String?` | Notification server URL | [L401](../Shared/Model/ChatModel.swift#L401) | +| `notificationPreview` | `NotificationPreviewMode` | What to show in notifications | [L402](../Shared/Model/ChatModel.swift#L402) | +| `notificationResponse` | `UNNotificationResponse?` | Pending notification action | [L369](../Shared/Model/ChatModel.swift#L369) | +| `ntfContactRequest` | `NTFContactRequest?` | Pending contact request from notification | [L404](../Shared/Model/ChatModel.swift#L404) | +| `ntfCallInvitationAction` | `(ChatId, NtfCallAction)?` | Pending call action from notification | [L405](../Shared/Model/ChatModel.swift#L405) | + +#### Calls +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `callInvitations` | `[ChatId: RcvCallInvitation]` | Pending incoming call invitations | [L407](../Shared/Model/ChatModel.swift#L407) | +| `activeCall` | `Call?` | Currently active call | [L408](../Shared/Model/ChatModel.swift#L408) | +| `callCommand` | `WebRTCCommandProcessor` | WebRTC command queue | [L409](../Shared/Model/ChatModel.swift#L409) | +| `showCallView` | `Bool` | Whether to show full-screen call UI | [L410](../Shared/Model/ChatModel.swift#L410) | +| `activeCallViewIsCollapsed` | `Bool` | Whether call view is in PiP mode | [L411](../Shared/Model/ChatModel.swift#L411) | + +#### Remote Desktop +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `remoteCtrlSession` | `RemoteCtrlSession?` | Active remote desktop session | [L414](../Shared/Model/ChatModel.swift#L414) | + +#### Misc +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userAddress` | `UserContactLink?` | User's SimpleX address | [L391](../Shared/Model/ChatModel.swift#L391) | +| `chatItemTTL` | `ChatItemTTL` | Global message TTL | [L392](../Shared/Model/ChatModel.swift#L392) | +| `appOpenUrl` | `URL?` | URL opened while app active | [L393](../Shared/Model/ChatModel.swift#L393) | +| `appOpenUrlLater` | `URL?` | URL opened while app inactive | [L394](../Shared/Model/ChatModel.swift#L394) | +| `showingInvitation` | `ShowingInvitation?` | Currently displayed invitation | [L416](../Shared/Model/ChatModel.swift#L416) | +| `draft` | `ComposeState?` | Saved compose draft | [L420](../Shared/Model/ChatModel.swift#L420) | +| `draftChatId` | `String?` | Chat ID for saved draft | [L421](../Shared/Model/ChatModel.swift#L421) | +| `networkInfo` | `UserNetworkInfo` | Current network type and status | [L422](../Shared/Model/ChatModel.swift#L422) | +| `conditions` | `ServerOperatorConditions` | Server usage conditions | [L424](../Shared/Model/ChatModel.swift#L424) | +| `stopPreviousRecPlay` | `URL?` | Currently playing audio source | [L419](../Shared/Model/ChatModel.swift#L419) | + +### Non-Published Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `messageDelivery` | `[Int64: () -> Void]` | Pending delivery confirmation callbacks | [L426](../Shared/Model/ChatModel.swift#L426) | +| `filesToDelete` | `Set` | Files queued for deletion | [L428](../Shared/Model/ChatModel.swift#L428) | +| `im` | `ItemsModel` | Reference to `ItemsModel.shared` | [L432](../Shared/Model/ChatModel.swift#L432) | + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `getUser(_ userId:)` | Find user by ID | [L455](../Shared/Model/ChatModel.swift#L455) | +| `updateUser(_ user:)` | Update user in list and current | [L466](../Shared/Model/ChatModel.swift#L466) | +| `removeUser(_ user:)` | Remove user from list | [L476](../Shared/Model/ChatModel.swift#L476) | +| `getChat(_ id:)` | Find chat by ID | [L487](../Shared/Model/ChatModel.swift#L487) | +| `addChat(_ chat:)` | Add chat to list | [L542](../Shared/Model/ChatModel.swift#L542) | +| `updateChatInfo(_ cInfo:)` | Update chat metadata | [L556](../Shared/Model/ChatModel.swift#L556) | +| `replaceChat(_ id:, _ chat:)` | Replace chat in list | [L608](../Shared/Model/ChatModel.swift#L608) | +| `removeChat(_ id:)` | Remove chat from list | [L1217](../Shared/Model/ChatModel.swift#L1217) | +| `popChat(_ id:)` | Move chat to top of list | [L1193](../Shared/Model/ChatModel.swift#L1193) | +| `totalUnreadCountForAllUsers()` | Sum unread across all users | [L1093](../Shared/Model/ChatModel.swift#L1093) | + +--- + +## 3. [ItemsModel](../Shared/Model/ChatModel.swift#L74-L174) + +**Class**: `class ItemsModel: ObservableObject` +**Primary singleton**: `ItemsModel.shared` +**Secondary instances**: Created via `ItemsModel.loadSecondaryChat()` for scope-based views (e.g., group member support chat) +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L74) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `reversedChatItems` | `[ChatItem]` | Messages in reverse chronological order (newest first) | [L80](../Shared/Model/ChatModel.swift#L80) | +| `itemAdded` | `Bool` | Flag indicating a new item was added | [L83](../Shared/Model/ChatModel.swift#L83) | +| `chatState` | `ActiveChatState` | Pagination splits and loaded ranges | [L87](../Shared/Model/ChatModel.swift#L87) | +| `isLoading` | `Bool` | Whether messages are currently loading | [L91](../Shared/Model/ChatModel.swift#L91) | +| `showLoadingProgress` | `ChatId?` | Chat ID showing loading spinner | [L92](../Shared/Model/ChatModel.swift#L92) | +| `preloadState` | `PreloadState` | State for infinite-scroll preloading | [L77](../Shared/Model/ChatModel.swift#L77) | +| `secondaryIMFilter` | `SecondaryItemsModelFilter?` | Filter for secondary instances | [L76](../Shared/Model/ChatModel.swift#L76) | + +### Computed Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `lastItemsLoaded` | `Bool` | Whether the oldest messages have been loaded | [L97](../Shared/Model/ChatModel.swift#L97) | +| `contentTag` | `MsgContentTag?` | Content type filter (if secondary) | [L159](../Shared/Model/ChatModel.swift#L159) | +| `groupScopeInfo` | `GroupChatScopeInfo?` | Group scope filter (if secondary) | [L167](../Shared/Model/ChatModel.swift#L167) | + +### Throttling + +`ItemsModel` uses a custom publisher throttle (0.2 seconds) to batch rapid updates to `reversedChatItems` and prevent excessive SwiftUI re-renders: + +```swift +publisher + .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) + .sink { self.objectWillChange.send() } + .store(in: &bag) +``` + +Direct `@Published` properties (`isLoading`, `showLoadingProgress`) bypass throttling for immediate UI response. + +### Key Methods + +| Method | Description | Line | +|--------|-------------|------| +| `loadOpenChat(_ chatId:)` | Load chat with 250ms navigation delay | [L117](../Shared/Model/ChatModel.swift#L117) | +| `loadOpenChatNoWait(_ chatId:, _ openAroundItemId:)` | Load chat without delay | [L143](../Shared/Model/ChatModel.swift#L143) | +| `loadSecondaryChat(_ chatId:, chatFilter:)` | Create secondary ItemsModel instance | [L110](../Shared/Model/ChatModel.swift#L110) | + +### [SecondaryItemsModelFilter](../Shared/Model/ChatModel.swift#L58-L70) + +Used for secondary chat views (e.g., group member support scope, content type filter): + +```swift +enum SecondaryItemsModelFilter { + case groupChatScopeContext(groupScopeInfo: GroupChatScopeInfo) + case msgContentTagContext(contentTag: MsgContentTag) +} +``` + +--- + +## 4. [ChatTagsModel](../Shared/Model/ChatModel.swift#L189-L291) + +**Class**: `class ChatTagsModel: ObservableObject` +**Singleton**: `ChatTagsModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L189) + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `userTags` | `[ChatTag]` | User-defined tags | [L192](../Shared/Model/ChatModel.swift#L192) | +| `activeFilter` | `ActiveFilter?` | Currently active filter tab | [L193](../Shared/Model/ChatModel.swift#L193) | +| `presetTags` | `[PresetTag: Int]` | Preset tag counts (groups, contacts, favorites, etc.) | [L194](../Shared/Model/ChatModel.swift#L194) | +| `unreadTags` | `[Int64: Int]` | Unread count per user tag | [L195](../Shared/Model/ChatModel.swift#L195) | + +### [ActiveFilter](../Shared/Views/ChatList/ChatListView.swift#L52) + +```swift +enum ActiveFilter { + case presetTag(PresetTag) // .favorites, .contacts, .groups, .business, .groupReports + case userTag(ChatTag) // User-defined tag + case unread // Unread conversations +} +``` + +--- + +## 5. [ChannelRelaysModel](../Shared/Model/ChatModel.swift#L336-L350) + +**Class**: `class ChannelRelaysModel: ObservableObject` +**Singleton**: `ChannelRelaysModel.shared` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L336) + +Holds runtime relay state for the currently viewed channel. Used by `ChannelRelaysView` to display and manage relays. Reset when the view is dismissed. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `groupId` | `Int64?` | Group ID of the channel whose relays are loaded | [L338](../Shared/Model/ChatModel.swift#L338) | +| `groupRelays` | `[GroupRelay]` | Current relay instances for the channel | [L339](../Shared/Model/ChatModel.swift#L339) | + +### Methods + +| Method | Description | Line | +|--------|-------------|------| +| `set(groupId:groupRelays:)` | Populate all properties at once | [L341](../Shared/Model/ChatModel.swift#L341) | +| `reset()` | Clear all properties to nil/empty | [L346](../Shared/Model/ChatModel.swift#L346) | + +--- + +## 6. [Chat](../Shared/Model/ChatModel.swift#L1301-L1353) + +**Class**: `final class Chat: ObservableObject, Identifiable, ChatLike` +**Source**: [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift#L1301) + +Represents a single conversation in the chat list. Each `Chat` is an independent observable object. + +### Properties + +| Property | Type | Description | Line | +|----------|------|-------------|------| +| `chatInfo` | `ChatInfo` | Conversation type and metadata | [L1302](../Shared/Model/ChatModel.swift#L1302) | +| `chatItems` | `[ChatItem]` | Preview items (typically last message) | [L1303](../Shared/Model/ChatModel.swift#L1303) | +| `chatStats` | `ChatStats` | Unread counts and min unread item ID | [L1304](../Shared/Model/ChatModel.swift#L1304) | +| `created` | `Date` | Creation timestamp | [L1305](../Shared/Model/ChatModel.swift#L1305) | + +### [ChatStats](../SimpleXChat/ChatTypes.swift#L1881-L1903) + +```swift +struct ChatStats: Decodable, Hashable { + var unreadCount: Int = 0 + var unreadMentions: Int = 0 + var reportsCount: Int = 0 + var minUnreadItemId: Int64 = 0 + var unreadChat: Bool = false +} +``` + +### Computed Properties + +| Property | Description | Line | +|----------|-------------|------| +| `id` | Chat ID from `chatInfo.id` | [L1336](../Shared/Model/ChatModel.swift#L1336) | +| `viewId` | Unique view identity including creation time | [L1338](../Shared/Model/ChatModel.swift#L1338) | +| `unreadTag` | Whether chat counts as "unread" based on notification settings | [L1328](../Shared/Model/ChatModel.swift#L1328) | +| `supportUnreadCount` | Unread count for group support scope | [L1340](../Shared/Model/ChatModel.swift#L1340) | + +--- + +## 7. [ChatInfo](../SimpleXChat/ChatTypes.swift#L1374-L1856) + +**Enum**: `public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable` +**Source**: [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift#L1374) + +Represents the type and metadata of a conversation: + +```swift +public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { + case direct(contact: Contact) + case group(groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?) + case local(noteFolder: NoteFolder) + case contactRequest(contactRequest: UserContactRequest) + case contactConnection(contactConnection: PendingContactConnection) + case invalidJSON(json: Data?) +} +``` + +### Cases + +| Case | Associated Value | Description | +|------|-----------------|-------------| +| `.direct` | `Contact` | One-to-one conversation | +| `.group` | `GroupInfo, GroupChatScopeInfo?` | Group conversation (optional scope for member support threads) | +| `.local` | `NoteFolder` | Local notes (self-chat) | +| `.contactRequest` | `UserContactRequest` | Incoming contact request | +| `.contactConnection` | `PendingContactConnection` | Pending connection | +| `.invalidJSON` | `Data?` | Undecodable chat data | + +### Key Computed Properties on ChatInfo + +| Property | Type | Description | +|----------|------|-------------| +| `chatType` | `ChatType` | `.direct`, `.group`, `.local`, `.contactRequest`, `.contactConnection` | +| `id` | `ChatId` | Prefixed ID (e.g., `"@1"` for direct, `"#5"` for group) | +| `displayName` | `String` | Contact/group name | +| `image` | `String?` | Profile image (base64) | +| `chatSettings` | `ChatSettings?` | Notification/favorite settings | +| `chatTags` | `[Int64]?` | Assigned tag IDs | + +### Relay-Related Data Model (Channels) + +A **channel** is a group with `groupInfo.useRelays == true`. These types support the relay/channel infrastructure: + +#### New Fields on Existing Types + +| Type | Field | Type | Description | Line | +|------|-------|------|-------------|------| +| `User` | `userChatRelay` | `Bool` | Whether user acts as a chat relay | [L46](../SimpleXChat/ChatTypes.swift#L46) | +| `GroupInfo` | `useRelays` | `Bool` | Whether group uses relay infrastructure (channel mode) | [L2343](../SimpleXChat/ChatTypes.swift#L2343) | +| `GroupInfo` | `relayOwnStatus` | `RelayStatus?` | Current user's relay status in this group | [L2344](../SimpleXChat/ChatTypes.swift#L2344) | +| `GroupProfile` | `publicGroup` | `PublicGroupProfile?` | Channel-specific profile data (type, link, ID) | [L2472](../SimpleXChat/ChatTypes.swift#L2472) | + +#### New Types + +| Type | Kind | Description | Line | +|------|------|-------------|------| +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active | [L2565](../SimpleXChat/ChatTypes.swift#L2565) | +| `GroupRelay` | `struct` | Relay instance for a group (ID, member ID, relay status). Fetched at runtime via `apiGetGroupRelays` (owner only) | [L2555](../SimpleXChat/ChatTypes.swift#L2555) | +| `UserChatRelay` | `struct` | User's chat relay configuration (ID, SMP address, name, domains, preset/tested/enabled/deleted flags) | [L2513](../SimpleXChat/ChatTypes.swift#L2513) | + +#### New Enum Cases + +| Enum | Case | Description | Line | +|------|------|-------------|------| +| `GroupMemberRole` | `.relay` | Role for relay members (below `.observer`) | [L2807](../SimpleXChat/ChatTypes.swift#L2807) | +| `CIDirection` | `.channelRcv` | Message direction for channel-received messages (via relay) | [L3529](../SimpleXChat/ChatTypes.swift#L3529) | + +--- + +## 8. State Flow + +### App Start +``` +SimpleXApp.init() + → haskell_init() + → initChatAndMigrate() + → chat_migrate_init_key() -- creates/opens DB + → startChat(mainApp: true) -- starts core + → apiGetChats(userId) -- populates ChatModel.chats + → UI renders ChatListView +``` + +### Opening a Chat +``` +User taps chat in ChatListView + → ItemsModel.loadOpenChat(chatId) + → 250ms delay for navigation animation + → ChatModel.chatId = chatId + → loadChat(chatId:, im:) + → apiGetChat(chatId, pagination: .last(count: 50)) + → ItemsModel.reversedChatItems = [ChatItem] + → ChatView renders messages +``` + +### Receiving a Message (Event) +``` +Haskell core generates ChatEvent.newChatItems + → Event loop calls chat_recv_msg_wait + → Decoded as ChatEvent.newChatItems(user, chatItems) + → ChatModel updates: + 1. Insert new Chat items into ChatModel.chats (preview) + 2. If chat is open: insert into ItemsModel.reversedChatItems + 3. Update ChatStats (unread counts) + 4. Update ChatTagsModel (tag unread counts) + → SwiftUI re-renders affected views via @Published observation +``` + +### Sending a Message +``` +User taps send in ComposeView + → apiSendMessages(type, id, scope, live, ttl, composedMessages) + → Haskell processes, returns ChatResponse1.newChatItems + → ChatModel.chats updated with new preview + → ItemsModel.reversedChatItems gets new item + → ChatView scrolls to bottom, shows sent message +``` + +--- + +## 9. Preference Storage + +### UserDefaults (via @AppStorage) + +App-level UI settings stored in `UserDefaults.standard`: + +| Key Constant | Type | Description | +|--------------|------|-------------| +| `DEFAULT_PERFORM_LA` | `Bool` | Enable local authentication | +| `DEFAULT_PRIVACY_PROTECT_SCREEN` | `Bool` | Hide screen in app switcher | +| `DEFAULT_SHOW_LA_NOTICE` | `Bool` | Show LA setup notice | +| `DEFAULT_NOTIFICATION_ALERT_SHOWN` | `Bool` | Notification permission alert shown | +| `DEFAULT_CALL_KIT_CALLS_IN_RECENTS` | `Bool` | Show CallKit calls in recents | + +### GroupDefaults + +Settings shared between main app and extensions (NSE, SE) via app group `UserDefaults`: + +| Key | Description | +|-----|-------------| +| `appStateGroupDefault` | Current app state (.active/.suspended/.stopped) | +| `dbContainerGroupDefault` | Database container location (.group/.documents) | +| `ntfPreviewModeGroupDefault` | Notification preview mode | +| `storeDBPassphraseGroupDefault` | Whether to store DB passphrase | +| `callKitEnabledGroupDefault` | Whether CallKit is enabled | +| `onboardingStageDefault` | Current onboarding stage | +| `currentThemeDefault` | Current theme name | +| `systemDarkThemeDefault` | Dark mode theme name | +| `themeOverridesDefault` | Custom theme overrides | +| `currentThemeIdsDefault` | Active theme override IDs | + +### Keychain (KeyChain wrapper) + +Sensitive data stored in iOS Keychain: + +| Key | Description | +|-----|-------------| +| `kcDatabasePassword` | SQLite database encryption key | +| `kcAppPassword` | App lock password | +| `kcSelfDestructPassword` | Self-destruct trigger password | + +### Haskell DB (via apiSaveSettings / apiGetSettings) + +Chat-level preferences stored in the SQLite database (managed by Haskell core): + +- Per-contact preferences (timed messages, voice, calls, etc.) +- Per-group preferences +- Per-user notification settings +- Network configuration +- Server lists + +--- + +## Source Files + +| File | Path | +|------|------| +| ChatModel, ItemsModel, Chat, ChatTagsModel, ChannelRelaysModel | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift) | +| ChatInfo, User, Contact, GroupInfo, ChatItem | [`SimpleXChat/ChatTypes.swift`](../SimpleXChat/ChatTypes.swift) | +| ActiveFilter | [`Shared/Views/ChatList/ChatListView.swift`](../Shared/Views/ChatList/ChatListView.swift#L52) | +| Preference defaults | [`Shared/Model/ChatModel.swift`](../Shared/Model/ChatModel.swift), [`SimpleXChat/FileUtils.swift`](../SimpleXChat/FileUtils.swift) | diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 2700711773..d4b4dfd949 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -910,7 +910,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "ลบข้อความ?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "ลบข้อความ"; /* No comment provided by engineer. */ @@ -1815,7 +1816,7 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "บทบาทของสมาชิกจะถูกเปลี่ยนเป็น \"%@\" สมาชิกจะได้รับคำเชิญใหม่"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "สมาชิกจะถูกลบออกจากกลุ่ม - ไม่สามารถยกเลิกได้!"; /* No comment provided by engineer. */ @@ -2332,13 +2333,13 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "เซิร์ฟเวอร์รีเลย์ปกป้องที่อยู่ IP ของคุณ แต่สามารถสังเกตระยะเวลาของการโทรได้"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "ลบ"; /* No comment provided by engineer. */ "Remove member" = "ลบสมาชิกออก"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "ลบสมาชิกออก?"; /* No comment provided by engineer. */ diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index 9acf2cc425..5cccb67170 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1733,7 +1733,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Mesaj silinsin mi?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Mesajları sil"; /* No comment provided by engineer. */ @@ -3293,10 +3294,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Üye rolü \"%@\" olarak değiştirilecektir. Ve üye yeni bir davetiye alacaktır."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Üye sohbetten kaldırılacak - bu geri alınamaz!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Üye gruptan çıkarılacaktır - bu geri alınamaz!"; /* alert message */ @@ -4362,7 +4363,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Yönlendirici sunucu IP adresinizi korur, ancak aramanın süresini gözlemleyebilir."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Sil"; /* No comment provided by engineer. */ @@ -4377,7 +4378,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Kişiyi sil"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Kişi silinsin mi?"; /* No comment provided by engineer. */ diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index fe8cfe22a0..305e64fbcf 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -1718,7 +1718,8 @@ swipe action */ /* No comment provided by engineer. */ "Delete message?" = "Видалити повідомлення?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "Видалити повідомлення"; /* No comment provided by engineer. */ @@ -3263,10 +3264,10 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "Роль учасника буде змінено на \"%@\". Учасник отримає нове запрошення."; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "Учасника буде видалено з чату – це неможливо скасувати!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "Учасник буде видалений з групи - це неможливо скасувати!"; /* alert message */ @@ -4317,7 +4318,7 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Сервер ретрансляції захищає вашу IP-адресу, але він може спостерігати за тривалістю дзвінка."; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "Видалити"; /* No comment provided by engineer. */ @@ -4329,7 +4330,7 @@ swipe action */ /* No comment provided by engineer. */ "Remove member" = "Видалити учасника"; -/* No comment provided by engineer. */ +/* alert title */ "Remove member?" = "Видалити учасника?"; /* No comment provided by engineer. */ diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index 24d153afd5..d5afea745d 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -346,12 +346,21 @@ alert action swipe action */ "Accept" = "接受"; +/* alert action */ +"Accept as member" = "接受为成员"; + +/* alert action */ +"Accept as observer" = "接受为观察员"; + /* No comment provided by engineer. */ "Accept conditions" = "接受条款"; /* No comment provided by engineer. */ "Accept connection request?" = "接受联系人?"; +/* alert title */ +"Accept contact request" = "接受联络请求"; + /* notification body */ "Accept contact request from %@?" = "接受来自 %@ 的联系人请求?"; @@ -359,12 +368,21 @@ swipe action */ swipe action */ "Accept incognito" = "接受隐身聊天"; +/* alert title */ +"Accept member" = "接受成员"; + /* call status */ "accepted call" = "已接受通话"; /* No comment provided by engineer. */ "Accepted conditions" = "已接受的条款"; +/* chat list item title */ +"accepted invitation" = "已接受邀请"; + +/* rcv group event chat item */ +"accepted you" = "接受了你"; + /* No comment provided by engineer. */ "Acknowledged" = "确认"; @@ -386,6 +404,9 @@ swipe action */ /* No comment provided by engineer. */ "Add list" = "添加列表"; +/* placeholder for sending contact request */ +"Add message" = "添加信息"; + /* No comment provided by engineer. */ "Add profile" = "添加个人资料"; @@ -461,6 +482,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "同意加密…"; +/* member criteria value */ +"all" = "全部"; + /* No comment provided by engineer. */ "All" = "全部"; @@ -485,6 +509,9 @@ swipe action */ /* feature role */ "all members" = "所有成员"; +/* No comment provided by engineer. */ +"All messages" = "所有消息"; + /* No comment provided by engineer. */ "All messages and files are sent **end-to-end encrypted**, with post-quantum security in direct messages." = "所有消息和文件均通过**端到端加密**发送;私信以量子安全方式发送。"; @@ -530,6 +557,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow downgrade" = "允许降级"; +/* No comment provided by engineer. */ +"Allow files and media only if your contact allows them." = "只有你的联系人允许的情况下才允许文件和媒体。"; + /* No comment provided by engineer. */ "Allow irreversible message deletion only if your contact allows it to you. (24 hours)" = "仅有您的联系人许可后才允许不可撤回消息移除"; @@ -581,6 +611,9 @@ swipe action */ /* No comment provided by engineer. */ "Allow your contacts to send disappearing messages." = "允许您的联系人发送限时消息。"; +/* No comment provided by engineer. */ +"Allow your contacts to send files and media." = "允许你的联系人发送文件和媒体。"; + /* No comment provided by engineer. */ "Allow your contacts to send voice messages." = "允许您的联系人发送语音消息。"; @@ -683,6 +716,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "已存档的联系人"; +/* No comment provided by engineer. */ +"archived report" = "已存档的举报"; + /* No comment provided by engineer. */ "Archiving database" = "正在存档数据库"; @@ -698,6 +734,9 @@ swipe action */ /* No comment provided by engineer. */ "Audio and video calls" = "语音和视频通话"; +/* No comment provided by engineer. */ +"Audio call" = "语音通话"; + /* No comment provided by engineer. */ "audio call (not e2e encrypted)" = "语音通话(非端到端加密)"; @@ -782,6 +821,12 @@ swipe action */ /* No comment provided by engineer. */ "Better user experience" = "更佳的使用体验"; +/* No comment provided by engineer. */ +"Bio" = "自我介绍"; + +/* alert title */ +"Bio too large" = "自我介绍过大"; + /* No comment provided by engineer. */ "Black" = "黑色"; @@ -825,6 +870,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "加粗"; +/* No comment provided by engineer. */ +"Bot" = "机器人"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "您和您的联系人都可以添加消息回应。"; @@ -837,6 +885,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "您和您的联系人都可以发送限时消息。"; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "你和你的联系人都可发送文件和媒体。"; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "您和您的联系人都可以发送语音消息。"; @@ -849,6 +900,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Business chats" = "企业聊天"; +/* No comment provided by engineer. */ +"Business connection" = "企业连接"; + /* No comment provided by engineer. */ "Businesses" = "企业"; @@ -888,6 +942,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't call member" = "无法呼叫成员"; +/* alert title */ +"Can't change profile" = "无法更改个人资料"; + /* No comment provided by engineer. */ "Can't invite contact!" = "无法邀请联系人!"; @@ -897,6 +954,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "无法向成员发送消息"; +/* No comment provided by engineer. */ +"can't send messages" = "无法发送消息"; + /* alert action alert button new chat action */ @@ -1035,9 +1095,21 @@ set passcode view */ /* No comment provided by engineer. */ "Chat will be deleted for you - this cannot be undone!" = "将为你删除聊天 - 此操作无法撤销!"; +/* chat toolbar */ +"Chat with admins" = "和管理员聊天"; + +/* No comment provided by engineer. */ +"Chat with member" = "和成员聊天"; + +/* No comment provided by engineer. */ +"Chat with members before they join." = "在成员加入前和这些人聊天"; + /* No comment provided by engineer. */ "Chats" = "聊天"; +/* No comment provided by engineer. */ +"Chats with members" = "和成员聊天"; + /* No comment provided by engineer. */ "Check messages every 20 min." = "每 20 分钟检查消息。"; @@ -1179,6 +1251,9 @@ set passcode view */ /* No comment provided by engineer. */ "Connect automatically" = "自动连接"; +/* No comment provided by engineer. */ +"Connect faster! 🚀" = "更快地连接!🚀"; + /* No comment provided by engineer. */ "Connect to desktop" = "连接到桌面"; @@ -1317,9 +1392,15 @@ set passcode view */ /* No comment provided by engineer. */ "Contact already exists" = "联系人已存在"; +/* No comment provided by engineer. */ +"contact deleted" = "删除了联系人"; + /* No comment provided by engineer. */ "Contact deleted!" = "联系人已删除!"; +/* No comment provided by engineer. */ +"contact disabled" = "禁用了联系人"; + /* No comment provided by engineer. */ "contact has e2e encryption" = "联系人具有端到端加密"; @@ -1338,9 +1419,18 @@ set passcode view */ /* No comment provided by engineer. */ "Contact name" = "联系人姓名"; +/* No comment provided by engineer. */ +"contact not ready" = "联系人未就绪"; + /* No comment provided by engineer. */ "Contact preferences" = "联系人偏好设置"; +/* No comment provided by engineer. */ +"Contact requests from groups" = "来自群的联络请求"; + +/* No comment provided by engineer. */ +"contact should accept…" = "联系人应当接受…"; + /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "联系人将被删除-这是无法撤消的!"; @@ -1410,6 +1500,9 @@ set passcode view */ /* No comment provided by engineer. */ "Create SimpleX address" = "创建 SimpleX 地址"; +/* No comment provided by engineer. */ +"Create your address" = "创建地址"; + /* No comment provided by engineer. */ "Create your profile" = "创建您的资料"; @@ -1583,6 +1676,9 @@ swipe action */ /* No comment provided by engineer. */ "Delete chat profile?" = "删除聊天资料?"; +/* alert title */ +"Delete chat with member?" = "删除和成员的聊天吗?"; + /* No comment provided by engineer. */ "Delete chat?" = "删除聊天?"; @@ -1637,10 +1733,14 @@ swipe action */ /* No comment provided by engineer. */ "Delete member message?" = "删除成员消息?"; +/* No comment provided by engineer. */ +"Delete member messages" = "删除成员消息"; + /* No comment provided by engineer. */ "Delete message?" = "删除消息吗?"; -/* alert button */ +/* alert action +alert button */ "Delete messages" = "删除消息"; /* No comment provided by engineer. */ @@ -1709,9 +1809,15 @@ swipe action */ /* No comment provided by engineer. */ "Delivery receipts!" = "送达回执!"; +/* No comment provided by engineer. */ +"Deprecated options" = "已废弃的选项"; + /* No comment provided by engineer. */ "Description" = "描述"; +/* alert title */ +"Description too large" = "描述过大"; + /* No comment provided by engineer. */ "Desktop address" = "桌面地址"; @@ -1914,6 +2020,9 @@ chat item action */ /* No comment provided by engineer. */ "Edit group profile" = "编辑群组资料"; +/* No comment provided by engineer. */ +"Empty message!" = "空消息!"; + /* No comment provided by engineer. */ "Enable" = "启用"; @@ -1926,6 +2035,9 @@ chat item action */ /* No comment provided by engineer. */ "Enable camera access" = "启用相机访问"; +/* No comment provided by engineer. */ +"Enable disappearing messages by default." = "默认启用定时消失消息。"; + /* No comment provided by engineer. */ "Enable Flux in Network & servers settings for better metadata privacy." = "在“网络&服务器”设置中启用 Flux,更好地保护元数据隐私。"; @@ -2097,15 +2209,24 @@ chat item action */ /* No comment provided by engineer. */ "Error accepting contact request" = "接受联系人请求错误"; +/* alert title */ +"Error accepting member" = "接受成员出错"; + /* No comment provided by engineer. */ "Error adding member(s)" = "添加成员错误"; /* alert title */ "Error adding server" = "添加服务器出错"; +/* No comment provided by engineer. */ +"Error adding short link" = "添加短链接出错"; + /* No comment provided by engineer. */ "Error changing address" = "更改地址错误"; +/* alert title */ +"Error changing chat profile" = "更改聊天资料出错"; + /* No comment provided by engineer. */ "Error changing connection profile" = "更改连接资料出错"; @@ -2118,6 +2239,9 @@ chat item action */ /* No comment provided by engineer. */ "Error changing to incognito!" = "切换至隐身聊天出错!"; +/* No comment provided by engineer. */ +"Error checking token status" = "查询token状态出错"; + /* alert message */ "Error connecting to forwarding server %@. Please try later." = "连接到转发服务器 %@ 时出错。请稍后尝试。"; @@ -2148,6 +2272,9 @@ chat item action */ /* No comment provided by engineer. */ "Error decrypting file" = "解密文件时出错"; +/* alert title */ +"Error deleting chat" = "删除聊天出错"; + /* alert title */ "Error deleting chat database" = "删除聊天数据库错误"; @@ -2202,6 +2329,9 @@ chat item action */ /* No comment provided by engineer. */ "Error opening chat" = "打开聊天时出错"; +/* No comment provided by engineer. */ +"Error opening group" = "打开群时出错"; + /* alert title */ "Error receiving file" = "接收文件错误"; @@ -2214,6 +2344,9 @@ chat item action */ /* alert title */ "Error registering for notifications" = "注册消息推送出错"; +/* alert title */ +"Error rejecting contact request" = "拒绝联络请求出错"; + /* alert title */ "Error removing member" = "删除成员错误"; @@ -2259,6 +2392,9 @@ chat item action */ /* No comment provided by engineer. */ "Error sending message" = "发送消息错误"; +/* No comment provided by engineer. */ +"Error setting auto-accept" = "设置自动接受出错"; + /* No comment provided by engineer. */ "Error setting delivery receipts!" = "设置送达回执出错!"; @@ -2309,6 +2445,9 @@ file error text snd error text */ "Error: %@" = "错误: %@"; +/* server test error */ +"Error: %@." = "错误:%@。"; + /* No comment provided by engineer. */ "Error: no database file" = "错误:没有数据库文件"; @@ -2417,6 +2556,9 @@ snd error text */ /* chat feature */ "Files and media" = "文件和媒体"; +/* No comment provided by engineer. */ +"Files and media are prohibited in this chat." = "此聊天禁止文件和媒体。"; + /* No comment provided by engineer. */ "Files and media are prohibited." = "此群组中禁止文件和媒体。"; @@ -2426,6 +2568,9 @@ snd error text */ /* No comment provided by engineer. */ "Files and media prohibited!" = "禁止文件和媒体!"; +/* No comment provided by engineer. */ +"Filter" = "过滤器"; + /* No comment provided by engineer. */ "Filter unread and favorite chats." = "过滤未读和收藏的聊天记录。"; @@ -2441,6 +2586,15 @@ snd error text */ /* No comment provided by engineer. */ "Find chats faster" = "更快地查找聊天记录"; +/* No comment provided by engineer. */ +"Fingerprint in destination server address does not match certificate: %@." = "目的地服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "转发服务器的指纹与证书不符:%@。"; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "服务器的指纹与证书不符:%@。"; + /* server test error */ "Fingerprint in server address does not match certificate." = "服务器地址中的证书指纹可能不正确"; @@ -2561,6 +2715,9 @@ snd error text */ /* message preview */ "Good morning!" = "早上好!"; +/* shown on group welcome message */ +"group" = "群"; + /* No comment provided by engineer. */ "Group" = "群组"; @@ -2591,6 +2748,9 @@ snd error text */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "群组邀请不再有效,已被发件人删除。"; +/* No comment provided by engineer. */ +"group is deleted" = "群被删除了"; + /* No comment provided by engineer. */ "Group link" = "群组链接"; @@ -2615,6 +2775,9 @@ snd error text */ /* snd group event chat item */ "group profile updated" = "群组资料已更新"; +/* alert message */ +"Group profile was changed. If you save it, the updated profile will be sent to group members." = "群资料已修改。如果你进行保存,修改后的群资料将发送给其他群成员。"; + /* No comment provided by engineer. */ "Group welcome message" = "群欢迎词"; @@ -2711,6 +2874,9 @@ snd error text */ /* No comment provided by engineer. */ "Image will be received when your contact is online, please wait or check later!" = "图片将在您的联系人在线时收到,请稍等或稍后查看!"; +/* No comment provided by engineer. */ +"Images" = "图片"; + /* No comment provided by engineer. */ "Immediately" = "立即"; @@ -2894,6 +3060,9 @@ snd error text */ /* No comment provided by engineer. */ "Invite friends" = "邀请朋友"; +/* No comment provided by engineer. */ +"Invite member" = "邀请成员"; + /* No comment provided by engineer. */ "Invite members" = "邀请成员"; @@ -2990,6 +3159,9 @@ snd error text */ /* alert title */ "Keep unused invitation?" = "保留未使用的邀请吗?"; +/* No comment provided by engineer. */ +"Keep your chats clean" = "保持聊天洁净"; + /* No comment provided by engineer. */ "Keep your connections" = "保持连接"; @@ -3023,6 +3195,9 @@ snd error text */ /* rcv group event chat item */ "left" = "已离开"; +/* No comment provided by engineer. */ +"Less traffic on mobile networks." = "消耗更少的移动网络数据。"; + /* email subject */ "Let's talk in SimpleX Chat" = "让我们一起在 SimpleX Chat 里聊天"; @@ -3041,6 +3216,9 @@ snd error text */ /* No comment provided by engineer. */ "Linked desktops" = "已链接桌面"; +/* No comment provided by engineer. */ +"Links" = "链接"; + /* swipe action */ "List" = "列表"; @@ -3059,6 +3237,9 @@ snd error text */ /* No comment provided by engineer. */ "Live messages" = "实时消息"; +/* in progress text */ +"Loading profile…" = "正加载个人资料…"; + /* No comment provided by engineer. */ "Local name" = "本地名称"; @@ -3110,15 +3291,27 @@ snd error text */ /* No comment provided by engineer. */ "Member" = "成员"; +/* past/unknown group member */ +"Member %@" = "成员 %@"; + /* profile update event chat item */ "member %@ changed to %@" = "成员 %1$@ 已更改为 %2$@"; +/* No comment provided by engineer. */ +"Member admission" = "成员准入"; + /* rcv group event chat item */ "member connected" = "已连接"; +/* No comment provided by engineer. */ +"member has old version" = "成员有旧版本"; + /* item status text */ "Member inactive" = "成员不活跃"; +/* No comment provided by engineer. */ +"Member is deleted - can't accept request" = "成员被删除——无法接受请求"; + /* chat feature */ "Member reports" = "成员举报"; @@ -3131,12 +3324,15 @@ snd error text */ /* No comment provided by engineer. */ "Member role will be changed to \"%@\". The member will receive a new invitation." = "成员角色将更改为 \"%@\"。该成员将收到一份新的邀请。"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from chat - this cannot be undone!" = "将从聊天中删除成员 - 此操作无法撤销!"; -/* No comment provided by engineer. */ +/* alert message */ "Member will be removed from group - this cannot be undone!" = "成员将被移出群组——此操作无法撤消!"; +/* alert message */ +"Member will join the group, accept member?" = "成员将加入本群,接受成员吗?"; + /* No comment provided by engineer. */ "Members can add message reactions." = "群组成员可以添加信息回应。"; @@ -3185,6 +3381,9 @@ snd error text */ /* item status text */ "Message forwarded" = "消息已转发"; +/* No comment provided by engineer. */ +"Message instantly once you tap Connect." = "轻按连接后即刻发消息。"; + /* item status description */ "Message may be delivered later if member becomes active." = "如果 member 变为活动状态,则稍后可能会发送消息。"; @@ -3233,6 +3432,9 @@ snd error text */ /* No comment provided by engineer. */ "Messages & files" = "消息"; +/* No comment provided by engineer. */ +"Messages are protected by **end-to-end encryption**." = "消息已通过**端到端加密**保护。"; + /* No comment provided by engineer. */ "Messages from %@ will be shown!" = "将显示来自 %@ 的消息!"; @@ -3311,6 +3513,9 @@ snd error text */ /* marked deleted chat item preview text */ "moderated by %@" = "由 %@ 审核"; +/* member role */ +"moderator" = "协管"; + /* time unit */ "months" = "月"; @@ -3395,6 +3600,9 @@ snd error text */ /* notification */ "New events" = "新事件"; +/* No comment provided by engineer. */ +"New group role: Moderator" = "新的群角色:协管"; + /* No comment provided by engineer. */ "New in %@" = "%@ 的新内容"; @@ -3404,6 +3612,9 @@ snd error text */ /* No comment provided by engineer. */ "New member role" = "新成员角色"; +/* rcv group event chat item */ +"New member wants to join the group." = "新成员要加入本群。"; + /* notification */ "new message" = "新消息"; @@ -3443,6 +3654,9 @@ snd error text */ /* No comment provided by engineer. */ "No chats in list %@" = "列表 %@ 中无聊天"; +/* No comment provided by engineer. */ +"No chats with members" = "没有和成员的聊天"; + /* No comment provided by engineer. */ "No contacts selected" = "未选择联系人"; @@ -3494,6 +3708,9 @@ snd error text */ /* No comment provided by engineer. */ "No permission to record voice message" = "没有录制语音消息的权限"; +/* alert title */ +"No private routing session" = "无私密路由会话"; + /* No comment provided by engineer. */ "No push server" = "本地"; @@ -3512,6 +3729,9 @@ snd error text */ /* servers error */ "No servers to send files." = "无文件发送服务器。"; +/* No comment provided by engineer. */ +"no subscription" = "无订阅"; + /* copied message info in history */ "no text" = "无文本"; @@ -3527,6 +3747,9 @@ snd error text */ /* No comment provided by engineer. */ "Not compatible!" = "不兼容!"; +/* No comment provided by engineer. */ +"not synchronized" = "未同步"; + /* No comment provided by engineer. */ "Notes" = "附注"; @@ -3634,6 +3857,9 @@ new chat action */ /* No comment provided by engineer. */ "Only you can send disappearing messages." = "只有您可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only you can send files and media." = "只有你可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only you can send voice messages." = "只有您可以发送语音消息。"; @@ -3649,6 +3875,9 @@ new chat action */ /* No comment provided by engineer. */ "Only your contact can send disappearing messages." = "只有您的联系人才可以发送限时消息。"; +/* No comment provided by engineer. */ +"Only your contact can send files and media." = "只有你的联系人可以发送文件和媒体。"; + /* No comment provided by engineer. */ "Only your contact can send voice messages." = "只有您的联系人可以发送语音消息。"; @@ -3664,18 +3893,45 @@ new chat action */ /* authentication reason */ "Open chat console" = "打开聊天控制台"; +/* alert action */ +"Open clean link" = "打开干净链接"; + /* No comment provided by engineer. */ "Open conditions" = "打开条款"; +/* alert action */ +"Open full link" = "打开完整链接"; + /* new chat action */ "Open group" = "打开群"; +/* alert title */ +"Open link?" = "打开链接?"; + /* authentication reason */ "Open migration to another device" = "打开迁移到另一台设备"; +/* new chat action */ +"Open new chat" = "打开新聊天"; + +/* new chat action */ +"Open new group" = "打开新群"; + /* No comment provided by engineer. */ "Open Settings" = "打开设置"; +/* No comment provided by engineer. */ +"Open to accept" = "打开以接受"; + +/* No comment provided by engineer. */ +"Open to connect" = "打开以连接"; + +/* No comment provided by engineer. */ +"Open to join" = "打开以加入"; + +/* No comment provided by engineer. */ +"Open to use bot" = "打开来使用机器人"; + /* No comment provided by engineer. */ "Opening app…" = "正在打开应用程序…"; @@ -3715,6 +3971,9 @@ new chat action */ /* No comment provided by engineer. */ "other errors" = "其他错误"; +/* alert message */ +"Other file errors:\n%@" = "其他文件错误:\n%@"; + /* member role */ "owner" = "群主"; @@ -3760,6 +4019,12 @@ new chat action */ /* No comment provided by engineer. */ "Pending" = "待定"; +/* No comment provided by engineer. */ +"pending approval" = "待批准"; + +/* No comment provided by engineer. */ +"pending review" = "待审核"; + /* No comment provided by engineer. */ "Periodic" = "定期"; @@ -3826,15 +4091,33 @@ new chat action */ /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "请安全地保存密码,如果您丢失了密码,您将无法更改它。"; +/* token info */ +"Please try to disable and re-enable notfications." = "请尝试禁用并重新启用通知。"; + +/* snd group event chat item */ +"Please wait for group moderators to review your request to join the group." = "请等待群的协管审核你加入该群的请求。"; + +/* token info */ +"Please wait for token activation to complete." = "请等待token激活完成。"; + +/* token info */ +"Please wait for token to be registered." = "请等待token注册完成。"; + /* No comment provided by engineer. */ "Polish interface" = "波兰语界面"; +/* No comment provided by engineer. */ +"Port" = "端口"; + /* No comment provided by engineer. */ "Preserve the last message draft, with attachments." = "保留最后的消息草稿及其附件。"; /* No comment provided by engineer. */ "Preset server address" = "预设服务器地址"; +/* No comment provided by engineer. */ +"Preset servers" = "预设服务器"; + /* No comment provided by engineer. */ "Preview" = "预览"; @@ -3844,6 +4127,9 @@ new chat action */ /* No comment provided by engineer. */ "Privacy & security" = "隐私和安全"; +/* No comment provided by engineer. */ +"Privacy for your customers." = "客户隐私。"; + /* No comment provided by engineer. */ "Privacy policy and conditions of use." = "隐私政策和使用条款。"; @@ -3856,6 +4142,9 @@ new chat action */ /* No comment provided by engineer. */ "Private filenames" = "私密文件名"; +/* No comment provided by engineer. */ +"Private media file names." = "私密媒体文件名。"; + /* No comment provided by engineer. */ "Private message routing" = "私有消息路由"; @@ -3871,6 +4160,9 @@ new chat action */ /* alert title */ "Private routing error" = "专用路由错误"; +/* alert title */ +"Private routing timeout" = "私密路由超时"; + /* No comment provided by engineer. */ "Profile and server connections" = "资料和服务器连接"; @@ -3901,6 +4193,9 @@ new chat action */ /* No comment provided by engineer. */ "Prohibit messages reactions." = "禁止消息回应。"; +/* No comment provided by engineer. */ +"Prohibit reporting messages to moderators." = "禁止向 协管 举报消息。"; + /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "禁止向成员发送私信。"; @@ -3928,6 +4223,9 @@ new chat action */ /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "保护您的 IP 地址免受联系人选择的消息中继的攻击。\n在*网络和服务器*设置中启用。"; +/* No comment provided by engineer. */ +"Protocol background timeout" = "协议后台超时"; + /* No comment provided by engineer. */ "Protocol timeout" = "协议超时"; @@ -3940,6 +4238,9 @@ new chat action */ /* No comment provided by engineer. */ "Proxied servers" = "代理服务器"; +/* No comment provided by engineer. */ +"Proxy requires password" = "代理需要密码"; + /* No comment provided by engineer. */ "Push notifications" = "推送通知"; @@ -4057,6 +4358,12 @@ new chat action */ /* No comment provided by engineer. */ "Reduced battery usage" = "减少电池使用量"; +/* No comment provided by engineer. */ +"Register" = "注册"; + +/* token status text */ +"Registered" = "已注册"; + /* alert action reject incoming call via notification swipe action */ @@ -4068,6 +4375,12 @@ swipe action */ /* alert title */ "Reject contact request" = "拒绝联系人请求"; +/* alert title */ +"Reject member?" = "拒绝成员?"; + +/* No comment provided by engineer. */ +"rejected" = "被拒绝"; + /* call status */ "rejected call" = "拒接来电"; @@ -4077,16 +4390,25 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "中继服务器保护您的 IP 地址,但它可以观察通话的持续时间。"; -/* No comment provided by engineer. */ +/* alert action */ "Remove" = "移除"; +/* alert action */ +"Remove and delete messages" = "移除并删除消息"; + +/* No comment provided by engineer. */ +"Remove archive?" = "删除存档?"; + /* No comment provided by engineer. */ "Remove image" = "移除图片"; /* No comment provided by engineer. */ -"Remove member" = "删除成员"; +"Remove link tracking" = "删除链接跟踪"; /* No comment provided by engineer. */ +"Remove member" = "删除成员"; + +/* alert title */ "Remove member?" = "删除成员吗?"; /* No comment provided by engineer. */ @@ -4101,12 +4423,18 @@ swipe action */ /* profile update event chat item */ "removed contact address" = "删除了联系地址"; +/* No comment provided by engineer. */ +"removed from group" = "从群被删除了"; + /* profile update event chat item */ "removed profile picture" = "删除了资料图片"; /* rcv group event chat item */ "removed you" = "已将您移除"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "删除消息并封禁成员。"; + /* No comment provided by engineer. */ "Renegotiate" = "重新协商"; @@ -4128,6 +4456,54 @@ swipe action */ /* chat item action */ "Reply" = "回复"; +/* chat item action */ +"Report" = "举报"; + +/* report reason */ +"Report content: only group moderators will see it." = "举报内容:仅协管会看到。"; + +/* report reason */ +"Report member profile: only group moderators will see it." = "举报成员个人资料:仅协管会看到。"; + +/* report reason */ +"Report other: only group moderators will see it." = "举报其他:仅协管会看到。"; + +/* No comment provided by engineer. */ +"Report reason?" = "举报理由?"; + +/* alert title */ +"Report sent to moderators" = "举报已发送至 协管"; + +/* report reason */ +"Report spam: only group moderators will see it." = "举报垃圾信息:仅协管会看到。"; + +/* report reason */ +"Report violation: only group moderators will see it." = "举报违规:仅协管会看到。"; + +/* report in notification */ +"Report: %@" = "举报: %@"; + +/* No comment provided by engineer. */ +"Reporting messages to moderators is prohibited." = "向协管举报消息已被禁止。"; + +/* No comment provided by engineer. */ +"Reports" = "举报"; + +/* No comment provided by engineer. */ +"request is sent" = "发送了请求"; + +/* No comment provided by engineer. */ +"request to join rejected" = "加入请求被拒绝"; + +/* rcv group event chat item */ +"requested connection" = "已请求连接"; + +/* rcv direct event chat item */ +"requested connection from group %@" = "来自群组%@的已请求连接"; + +/* chat list item title */ +"requested to connect" = "被请求连接"; + /* No comment provided by engineer. */ "Required" = "必须"; @@ -4179,9 +4555,21 @@ swipe action */ /* chat item action */ "Reveal" = "揭示"; +/* No comment provided by engineer. */ +"review" = "审核"; + /* No comment provided by engineer. */ "Review conditions" = "审阅条款"; +/* No comment provided by engineer. */ +"Review group members" = "审核群成员"; + +/* admission stage */ +"Review members" = "审核成员"; + +/* No comment provided by engineer. */ +"reviewed by admins" = "由管理员审核"; + /* No comment provided by engineer. */ "Revoke" = "吊销"; @@ -4210,6 +4598,12 @@ chat item action */ /* alert button */ "Save (and notify contacts)" = "保存(并通知联系人)"; +/* alert button */ +"Save (and notify members)" = "保存(并通知成员)"; + +/* alert title */ +"Save admission settings?" = "保存入群设置?"; + /* alert button */ "Save and notify contact" = "保存并通知联系人"; @@ -4225,6 +4619,9 @@ chat item action */ /* No comment provided by engineer. */ "Save group profile" = "保存群组资料"; +/* alert title */ +"Save group profile?" = "保存群资料?"; + /* No comment provided by engineer. */ "Save list" = "保存列表"; @@ -4303,9 +4700,24 @@ chat item action */ /* No comment provided by engineer. */ "Search bar accepts invitation links." = "搜索栏接受邀请链接。"; +/* No comment provided by engineer. */ +"Search files" = "搜索文件"; + +/* No comment provided by engineer. */ +"Search images" = "搜索图片"; + +/* No comment provided by engineer. */ +"Search links" = "搜索链接"; + /* No comment provided by engineer. */ "Search or paste SimpleX link" = "搜索或粘贴 SimpleX 链接"; +/* No comment provided by engineer. */ +"Search videos" = "搜索视频"; + +/* No comment provided by engineer. */ +"Search voice messages" = "搜索语音消息"; + /* network option */ "sec" = "秒"; @@ -4336,6 +4748,9 @@ chat item action */ /* chat item action */ "Select" = "选择"; +/* No comment provided by engineer. */ +"Select chat profile" = "选择聊天个人资料"; + /* No comment provided by engineer. */ "Selected %lld" = "选定的 %lld"; @@ -4360,6 +4775,9 @@ chat item action */ /* No comment provided by engineer. */ "Send a live message - it will update for the recipient(s) as you type it" = "发送实时消息——它会在您键入时为收件人更新"; +/* No comment provided by engineer. */ +"Send contact request?" = "发送联络请求?"; + /* No comment provided by engineer. */ "Send delivery receipts to" = "将送达回执发送给"; @@ -4390,18 +4808,30 @@ chat item action */ /* No comment provided by engineer. */ "Send notifications" = "发送通知"; +/* No comment provided by engineer. */ +"Send private reports" = "发送私下举报"; + /* No comment provided by engineer. */ "Send questions and ideas" = "发送问题和想法"; /* No comment provided by engineer. */ "Send receipts" = "发送回执"; +/* No comment provided by engineer. */ +"Send request" = "发送请求"; + +/* No comment provided by engineer. */ +"Send request without message" = "发送无消息请求"; + /* No comment provided by engineer. */ "Send them from gallery or custom keyboards." = "发送它们来自图库或自定义键盘。"; /* No comment provided by engineer. */ "Send up to 100 last messages to new members." = "给新成员发送最多 100 条历史消息。"; +/* No comment provided by engineer. */ +"Send your private feedback to groups." = "向群发送私密反馈。"; + /* alert message */ "Sender cancelled file transfer." = "发送人已取消文件传输。"; @@ -4459,6 +4889,12 @@ chat item action */ /* No comment provided by engineer. */ "Sent via proxy" = "通过代理发送"; +/* No comment provided by engineer. */ +"Server" = "服务器"; + +/* alert message */ +"Server added to operator %@." = "服务器已添加到运营方 %@。"; + /* No comment provided by engineer. */ "Server address" = "服务器地址"; @@ -4468,6 +4904,15 @@ chat item action */ /* srv error text. */ "Server address is incompatible with network settings." = "服务器地址与网络设置不兼容。"; +/* alert title */ +"Server operator changed." = "服务器运营方已更改。"; + +/* No comment provided by engineer. */ +"Server operators" = "服务器运营方"; + +/* alert title */ +"Server protocol changed." = "服务器协议已更改。"; + /* queue info */ "server queue info: %@\n\nlast received msg: %@" = "服务器队列信息: %1$@\n\n上次收到的消息: %2$@"; @@ -4504,6 +4949,9 @@ chat item action */ /* No comment provided by engineer. */ "Set 1 day" = "设定1天"; +/* No comment provided by engineer. */ +"Set chat name…" = "设置聊天名称…"; + /* No comment provided by engineer. */ "Set contact name…" = "设置联系人姓名……"; @@ -4516,6 +4964,12 @@ chat item action */ /* No comment provided by engineer. */ "Set it instead of system authentication." = "设置它以代替系统身份验证。"; +/* No comment provided by engineer. */ +"Set member admission" = "设置成员入群准许"; + +/* No comment provided by engineer. */ +"Set message expiration in chats." = "在聊天中设置消息过期时间。"; + /* profile update event chat item */ "set new contact address" = "设置新的联系地址"; @@ -4531,6 +4985,9 @@ chat item action */ /* No comment provided by engineer. */ "Set passphrase to export" = "设置密码来导出"; +/* No comment provided by engineer. */ +"Set profile bio and welcome message." = "设置自我介绍和欢迎消息。"; + /* No comment provided by engineer. */ "Set the message shown to new members!" = "设置向新成员显示的消息!"; @@ -4540,6 +4997,9 @@ chat item action */ /* No comment provided by engineer. */ "Settings" = "设置"; +/* alert message */ +"Settings were changed." = "设置已修改。"; + /* No comment provided by engineer. */ "Shape profile images" = "改变个人资料图形状"; @@ -4550,9 +5010,15 @@ chat item action */ /* No comment provided by engineer. */ "Share 1-time link" = "分享一次性链接"; +/* No comment provided by engineer. */ +"Share 1-time link with a friend" = "和一位好友分享一次性链接"; + /* No comment provided by engineer. */ "Share address" = "分享地址"; +/* No comment provided by engineer. */ +"Share address publicly" = "公开分享地址"; + /* alert title */ "Share address with contacts?" = "与联系人分享地址?"; @@ -4562,6 +5028,18 @@ chat item action */ /* No comment provided by engineer. */ "Share link" = "分享链接"; +/* alert button */ +"Share old address" = "分享旧地址"; + +/* alert button */ +"Share old link" = "分享旧链接"; + +/* No comment provided by engineer. */ +"Share profile" = "分享资料"; + +/* No comment provided by engineer. */ +"Share SimpleX address on social media." = "在社媒上分享 SimpleX 地址。"; + /* No comment provided by engineer. */ "Share this 1-time invite link" = "分享此一次性邀请链接"; @@ -4571,6 +5049,18 @@ chat item action */ /* No comment provided by engineer. */ "Share with contacts" = "与联系人分享"; +/* No comment provided by engineer. */ +"Share your address" = "分享地址"; + +/* No comment provided by engineer. */ +"Short description" = "短描述"; + +/* No comment provided by engineer. */ +"Short link" = "短链接"; + +/* No comment provided by engineer. */ +"Short SimpleX address" = "SimpleX 短地址"; + /* No comment provided by engineer. */ "Show → on messages sent via private routing." = "显示 → 通过专用路由发送的信息."; @@ -4661,6 +5151,9 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "SimpleX 协议由 Trail of Bits 审阅。"; +/* simplex link type */ +"SimpleX relay link" = "SimpleX 中继链接"; + /* No comment provided by engineer. */ "Simplified incognito mode" = "简化的隐身模式"; @@ -4679,6 +5172,9 @@ chat item action */ /* No comment provided by engineer. */ "SMP server" = "SMP 服务器"; +/* No comment provided by engineer. */ +"SOCKS proxy" = "SOCKS代理"; + /* blur media */ "Soft" = "软"; @@ -4694,9 +5190,16 @@ chat item action */ /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "导入过程中出现一些非致命错误:"; +/* alert message */ +"Some servers failed the test:\n%@" = "有服务器测试未通过:\n%@"; + /* notification title */ "Somebody" = "某人"; +/* blocking reason +report reason */ +"Spam" = "垃圾信息"; + /* No comment provided by engineer. */ "Square, circle, or anything in between." = "方形、圆形、或两者之间的任意形状."; @@ -4775,18 +5278,42 @@ chat item action */ /* No comment provided by engineer. */ "Support SimpleX Chat" = "支持 SimpleX Chat"; +/* No comment provided by engineer. */ +"Switch audio and video during the call." = "通话期间切换音频和视频。"; + +/* No comment provided by engineer. */ +"Switch chat profile for 1-time invitations." = "对一次性邀请切换聊天个人资料。"; + /* No comment provided by engineer. */ "System" = "系统"; /* No comment provided by engineer. */ "System authentication" = "系统验证"; +/* No comment provided by engineer. */ +"Tail" = "尾部"; + /* No comment provided by engineer. */ "Take picture" = "拍照"; /* No comment provided by engineer. */ "Tap button " = "点击按钮 "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "轻按连接进行聊天"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "轻按连接来发送请求"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "轻按“连接”使用机器人"; + +/* No comment provided by engineer. */ +"Tap Create SimpleX address in the menu to create it later." = "要稍后创建 SimpleX 地址,请在菜单中轻按“创建 SimpleX 地址”"; + +/* No comment provided by engineer. */ +"Tap Join group" = "轻按加入群"; + /* No comment provided by engineer. */ "Tap to activate profile." = "点击以激活个人资料。"; @@ -4808,9 +5335,15 @@ chat item action */ /* No comment provided by engineer. */ "TCP connection" = "TCP 连接"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "TCP 连接后台超时"; + /* No comment provided by engineer. */ "TCP connection timeout" = "TCP 连接超时"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "用于消息收发的 TCP 端口"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -4826,6 +5359,9 @@ chat item action */ /* server test failure */ "Test failed at step %@." = "在步骤 %@ 上测试失败。"; +/* No comment provided by engineer. */ +"Test notifications" = "测试通知"; + /* No comment provided by engineer. */ "Test server" = "测试服务器"; @@ -4844,9 +5380,15 @@ chat item action */ /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "感谢用户——通过 Weblate 做出贡献!"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "地址不会长,将通过该简短地址分享个人资料。"; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "该应用可以在您收到消息或联系人请求时通知您——请打开设置以启用通知。"; +/* No comment provided by engineer. */ +"The app protects your privacy by using different operators in each conversation." = "应用通过在每个对话中使用不同运营方保护你的隐私。"; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "该应用程序将要求确认从未知文件服务器(.onion 除外)下载。"; @@ -4856,6 +5398,9 @@ chat item action */ /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "您扫描的码不是 SimpleX 链接的二维码。"; +/* No comment provided by engineer. */ +"The connection reached the limit of undelivered messages, your contact may be offline." = "连接达到了未送达消息上限,你的联系人可能处于离线状态。"; + /* No comment provided by engineer. */ "The connection you accepted will be cancelled!" = "您接受的连接将被取消!"; @@ -4877,6 +5422,9 @@ chat item action */ /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "下一条消息的 ID 不正确(小于或等于上一条)。\n它可能是由于某些错误或连接被破坏才发生。"; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "链接不会长,群资料会通过短链接分享。"; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "将为所有成员删除该消息。"; @@ -4892,6 +5440,9 @@ chat item action */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "旧数据库在迁移过程中没有被移除,可以删除。"; +/* No comment provided by engineer. */ +"The second preset operator in the app!" = "应用中的第二个预设运营方!"; + /* No comment provided by engineer. */ "The second tick we missed! ✅" = "我们错过的第二个\"√\"!✅"; @@ -4904,9 +5455,15 @@ chat item action */ /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "您粘贴的文本不是 SimpleX 链接。"; +/* No comment provided by engineer. */ +"The uploaded database archive will be permanently removed from the servers." = "已上传的数据库归档将会从服务器中永久移除。"; + /* No comment provided by engineer. */ "Themes" = "主题"; +/* No comment provided by engineer. */ +"These conditions will also apply for: **%@**." = "这些条件将同样适用于: **%@**。"; + /* No comment provided by engineer. */ "These settings are for your current profile **%@**." = "这些设置适用于您当前的配置文件 **%@**。"; @@ -4919,6 +5476,9 @@ chat item action */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "此操作无法撤消——早于所选的发送和接收的消息将被删除。 这可能需要几分钟时间。"; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "此操作无法撤销 —— 比此聊天中所选消息更早发出并收到的消息将被删除。"; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "此操作无法撤消——您的个人资料、联系人、消息和文件将不可撤回地丢失。"; @@ -4943,12 +5503,24 @@ chat item action */ /* No comment provided by engineer. */ "This group no longer exists." = "该群组已不存在。"; +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "此链接需要更新的应用版本。请升级应用或请求你的联系人发送相容的链接。"; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "此链接已在其他移动设备上使用,请在桌面上创建新链接。"; +/* No comment provided by engineer. */ +"This message was deleted or not received yet." = "此消息被删除或尚未收到。"; + /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "此设置适用于您当前聊天资料 **%@** 中的消息。"; +/* No comment provided by engineer. */ +"This setting is for your current profile **%@**." = "此设置用于当前个人资料 **%@**。"; + +/* No comment provided by engineer. */ +"Time to disappear is set only for new contacts." = "只为新联系人设置了消失时间。"; + /* No comment provided by engineer. */ "Title" = "标题"; @@ -4964,6 +5536,9 @@ chat item action */ /* No comment provided by engineer. */ "To make a new connection" = "建立新连接"; +/* No comment provided by engineer. */ +"To protect against your link being replaced, you can compare contact security codes." = "为了防止链接被替换,你可以比较联系人安全代码。"; + /* No comment provided by engineer. */ "To protect timezone, image/voice files use UTC." = "为了保护时区,图像/语音文件使用 UTC。"; @@ -4976,15 +5551,36 @@ chat item action */ /* No comment provided by engineer. */ "To protect your privacy, SimpleX uses separate IDs for each of your contacts." = "为了保护隐私,SimpleX使用针对消息队列的标识符,而不是所有其他平台使用的用户ID,每个联系人都有独立的标识符。"; +/* No comment provided by engineer. */ +"To receive" = "消息接收"; + +/* No comment provided by engineer. */ +"To record speech please grant permission to use Microphone." = "为了记录语音请授予使用麦克风权限。"; + +/* No comment provided by engineer. */ +"To record video please grant permission to use Camera." = "为了录制视频请授予使用相机权限。"; + /* No comment provided by engineer. */ "To record voice message please grant permission to use Microphone." = "请授权使用麦克风以录制语音消息。"; /* No comment provided by engineer. */ "To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "要显示您的隐藏的个人资料,请在**您的聊天个人资料**页面的搜索字段中输入完整密码。"; +/* No comment provided by engineer. */ +"To send" = "发送"; + +/* alert message */ +"To send commands you must be connected." = "你必须已连接才能发送命令。"; + /* No comment provided by engineer. */ "To support instant push notifications the chat database has to be migrated." = "为了支持即时推送通知,聊天数据库必须被迁移。"; +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "要在连接尝试后使用不同的个人资料,请删除聊天并再次使用该链接。"; + +/* No comment provided by engineer. */ +"To use the servers of **%@**, accept conditions of use." = "要使用**%@**的服务器,需接受条款。"; + /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "要与您的联系人验证端到端加密,请比较(或扫描)您设备上的代码。"; @@ -5006,6 +5602,9 @@ chat item action */ /* No comment provided by engineer. */ "Transport sessions" = "传输会话"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "尝试连接到用于从该连接接收消息的服务器。"; + /* No comment provided by engineer. */ "Turkish interface" = "土耳其语界面"; @@ -5036,6 +5635,9 @@ chat item action */ /* rcv group event chat item */ "unblocked %@" = "未阻止 %@"; +/* No comment provided by engineer. */ +"Undelivered messages" = "未送达的消息"; + /* No comment provided by engineer. */ "Unexpected migration state" = "未预料的迁移状态"; @@ -5102,6 +5704,9 @@ chat item action */ /* swipe action */ "Unread" = "未读"; +/* No comment provided by engineer. */ +"Unsupported connection link" = "不支持的连接链接"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "给新成员发送了最多 100 条历史消息。"; @@ -5117,6 +5722,9 @@ chat item action */ /* No comment provided by engineer. */ "Update settings?" = "更新设置?"; +/* No comment provided by engineer. */ +"Updated conditions" = "条款已更新"; + /* rcv group event chat item */ "updated group profile" = "已更新的群组资料"; @@ -5126,9 +5734,27 @@ chat item action */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "更新设置会将客户端重新连接到所有服务器。"; +/* alert button */ +"Upgrade" = "升级"; + +/* No comment provided by engineer. */ +"Upgrade address" = "升级地址"; + +/* alert message */ +"Upgrade address?" = "升级地址?"; + /* No comment provided by engineer. */ "Upgrade and open chat" = "升级并打开聊天"; +/* alert message */ +"Upgrade group link?" = "升级群链接?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "升级链接"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "升级你的地址"; + /* No comment provided by engineer. */ "Upload errors" = "上传错误"; @@ -5150,18 +5776,30 @@ chat item action */ /* No comment provided by engineer. */ "Use .onion hosts" = "使用 .onion 主机"; +/* No comment provided by engineer. */ +"Use %@" = "使用 %@"; + /* No comment provided by engineer. */ "Use chat" = "使用聊天"; /* new chat action */ "Use current profile" = "使用当前配置文件"; +/* No comment provided by engineer. */ +"Use for files" = "用于文件"; + +/* No comment provided by engineer. */ +"Use for messages" = "用于消息"; + /* No comment provided by engineer. */ "Use for new connections" = "用于新连接"; /* No comment provided by engineer. */ "Use from desktop" = "从桌面端使用"; +/* No comment provided by engineer. */ +"Use incognito profile" = "使用隐身个人资料"; + /* No comment provided by engineer. */ "Use iOS call interface" = "使用 iOS 通话界面"; @@ -5180,18 +5818,36 @@ chat item action */ /* No comment provided by engineer. */ "Use server" = "使用服务器"; +/* No comment provided by engineer. */ +"Use servers" = "使用服务器"; + /* No comment provided by engineer. */ "Use SimpleX Chat servers?" = "使用 SimpleX Chat 服务器?"; +/* No comment provided by engineer. */ +"Use SOCKS proxy" = "使用 SOCKS 代理"; + +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "当未指定端口时使用TCP端口%@。"; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "仅预设服务器使用 TCP 协议 443 端口。"; + /* No comment provided by engineer. */ "Use the app while in the call." = "通话时使用本应用."; /* No comment provided by engineer. */ "Use the app with one hand." = "用一只手使用应用程序。"; +/* No comment provided by engineer. */ +"Use web port" = "使用 web 端口"; + /* No comment provided by engineer. */ "User selection" = "用户选择"; +/* No comment provided by engineer. */ +"Username" = "用户名"; + /* No comment provided by engineer. */ "Using SimpleX Chat servers." = "使用 SimpleX Chat 服务器。"; @@ -5255,12 +5911,21 @@ chat item action */ /* No comment provided by engineer. */ "Video will be received when your contact is online, please wait or check later!" = "视频将在您的联系人在线时收到,请稍等或稍后查看!"; +/* No comment provided by engineer. */ +"Videos" = "视频"; + /* No comment provided by engineer. */ "Videos and files up to 1gb" = "最大 1gb 的视频和文件"; +/* No comment provided by engineer. */ +"View conditions" = "查看条款"; + /* No comment provided by engineer. */ "View security code" = "查看安全码"; +/* No comment provided by engineer. */ +"View updated conditions" = "查看更新后的条款"; + /* chat feature */ "Visible history" = "可见的历史"; @@ -5330,6 +5995,9 @@ chat item action */ /* No comment provided by engineer. */ "Welcome message is too long" = "欢迎消息太大了"; +/* No comment provided by engineer. */ +"Welcome your contacts 👋" = "欢迎联系人👋"; + /* No comment provided by engineer. */ "What's new" = "更新内容"; @@ -5342,6 +6010,9 @@ chat item action */ /* No comment provided by engineer. */ "when IP hidden" = "当 IP 隐藏时"; +/* No comment provided by engineer. */ +"When more than one operator is enabled, none of them has metadata to learn who communicates with whom." = "当启用了超过一个运营方时,没有一个运营方拥有了解谁和谁联络的元数据。"; + /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "当您与某人共享隐身聊天资料时,该资料将用于他们邀请您加入的群组。"; @@ -5396,6 +6067,9 @@ chat item action */ /* No comment provided by engineer. */ "You accepted connection" = "您已接受连接"; +/* snd group event chat item */ +"you accepted this member" = "你接受了该成员"; + /* No comment provided by engineer. */ "You allow" = "您允许"; @@ -5405,6 +6079,9 @@ chat item action */ /* No comment provided by engineer. */ "You are already connected to %@." = "您已经连接到 %@。"; +/* No comment provided by engineer. */ +"You are already connected with %@." = "你已经与%@保持连接。"; + /* new chat sheet message */ "You are already connecting to %@." = "您已连接到 %@。"; @@ -5423,9 +6100,15 @@ chat item action */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "您已经加入了这个群组!\n重复加入请求?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "你已连接到用于接收该连接消息的服务器。"; + /* No comment provided by engineer. */ "You are invited to group" = "您被邀请加入群组"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "未连接到用于从该连接接收消息的服务器(无订阅)。"; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "您未连接到这些服务器。私有路由用于向他们发送消息。"; @@ -5441,6 +6124,9 @@ chat item action */ /* No comment provided by engineer. */ "You can change it in Appearance settings." = "您可以在外观设置中更改它。"; +/* No comment provided by engineer. */ +"You can configure servers via settings." = "你可以通过设置配置服务器。"; + /* No comment provided by engineer. */ "You can create it later" = "您可以以后创建它"; @@ -5465,6 +6151,9 @@ chat item action */ /* No comment provided by engineer. */ "You can send messages to %@ from Archived contacts." = "您可以从存档的联系人向%@发送消息。"; +/* No comment provided by engineer. */ +"You can set connection name, to remember who the link was shared with." = "你可以设置连接名称,用来记住和谁分享了这个链接。"; + /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "您可以通过设置来设置锁屏通知预览。"; @@ -5489,6 +6178,9 @@ chat item action */ /* alert message */ "You can view invitation link again in connection details." = "您可以在连接详情中再次查看邀请链接。"; +/* alert message */ +"You can view your reports in Chat with admins." = "你可以在和管理员和聊天中查看你的举报。"; + /* alert title */ "You can't send messages!" = "您无法发送消息!"; @@ -5561,6 +6253,9 @@ chat item action */ /* snd group event chat item */ "you unblocked %@" = "您解封了 %@"; +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "**只有在你的请求被接受后**你才能发送消息。"; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "您将在组主设备上线时连接到该群组,请稍等或稍后再检查!"; @@ -5579,6 +6274,9 @@ chat item action */ /* No comment provided by engineer. */ "You will still receive calls and notifications from muted profiles when they are active." = "当静音配置文件处于活动状态时,您仍会收到来自静音配置文件的电话和通知。"; +/* No comment provided by engineer. */ +"You will stop receiving messages from this chat. Chat history will be preserved." = "你将停止从这个聊天收到消息。聊天历史将被保留。"; + /* No comment provided by engineer. */ "You will stop receiving messages from this group. Chat history will be preserved." = "您将停止接收来自该群组的消息。聊天记录将被保留。"; @@ -5594,6 +6292,9 @@ chat item action */ /* No comment provided by engineer. */ "You're using an incognito profile for this group - to prevent sharing your main profile inviting contacts is not allowed" = "您正在为该群组使用隐身个人资料——为防止共享您的主要个人资料,不允许邀请联系人"; +/* No comment provided by engineer. */ +"Your business contact" = "你的企业联系人"; + /* No comment provided by engineer. */ "Your calls" = "您的通话"; @@ -5603,9 +6304,15 @@ chat item action */ /* No comment provided by engineer. */ "Your chat database is not encrypted - set passphrase to encrypt it." = "您的聊天数据库未加密——设置密码来加密。"; +/* alert title */ +"Your chat preferences" = "你的聊天偏好设置"; + /* No comment provided by engineer. */ "Your chat profiles" = "您的聊天资料"; +/* No comment provided by engineer. */ +"Your contact" = "你的联系人"; + /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "您的联系人发送的文件大于当前支持的最大大小 (%@)。"; @@ -5615,12 +6322,18 @@ chat item action */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "与您的联系人保持连接。"; +/* No comment provided by engineer. */ +"Your credentials may be sent unencrypted." = "你的凭据可能以未经加密的方式被发送。"; + /* No comment provided by engineer. */ "Your current chat database will be DELETED and REPLACED with the imported one." = "您当前的聊天数据库将被删除并替换为导入的数据库。"; /* No comment provided by engineer. */ "Your current profile" = "您当前的资料"; +/* No comment provided by engineer. */ +"Your group" = "你的群"; + /* No comment provided by engineer. */ "Your ICE servers" = "您的 ICE 服务器"; @@ -5642,12 +6355,18 @@ chat item action */ /* No comment provided by engineer. */ "Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." = "您的资料存储在您的设备上并仅与您的联系人共享。 SimpleX 服务器无法看到您的资料。"; +/* alert message */ +"Your profile was changed. If you save it, the updated profile will be sent to all your contacts." = "您的个人资料已修改。如果进行保存,更新后的个人资料将发送到所有联系人。"; + /* No comment provided by engineer. */ "Your random profile" = "您的随机资料"; /* No comment provided by engineer. */ "Your server address" = "您的服务器地址"; +/* No comment provided by engineer. */ +"Your servers" = "你的服务器"; + /* No comment provided by engineer. */ "Your settings" = "您的设置"; diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md new file mode 100644 index 0000000000..26a36e75bb --- /dev/null +++ b/apps/multiplatform/CODE.md @@ -0,0 +1,309 @@ +# 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? | +| `common/src/commonMain/` | Shared Kotlin/Compose code (Android + Desktop) | What does it **execute** on both platforms? | +| `common/src/androidMain/` | Android-specific Kotlin (platform implementations) | What does it execute on **Android**? | +| `common/src/desktopMain/` | Desktop-specific Kotlin (platform implementations) | What does it execute on **Desktop**? | +| `android/src/main/` | Android app module (Application, Activity, Services) | What is the **Android entry point**? | +| `desktop/src/jvmMain/` | Desktop app module (main function) | What is the **Desktop entry point**? | +| `../../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()`](common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#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 Kotlin data classes for value types, regular classes for reference types, and sealed classes/interfaces for variants. Why: correct type choices leverage the type system for compile-time correctness. +- Prefer exhaustive `when` expressions over `else` branches. Why: `else` branches bypass compiler checks for new sealed subclasses 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 Kotlin reader. Why: over-explaining trivial Kotlin 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. + +--- + +## Multiplatform Architecture Notes + +### Kotlin Multiplatform (KMP) + Compose Multiplatform + +The app uses Kotlin Multiplatform with Compose Multiplatform for shared UI. The project has three Gradle modules: + +- **common/** — Shared library containing all models, views, platform abstractions, and theme system +- **android/** — Android app module (Application, Activity, Services) +- **desktop/** — Desktop JVM app module (main entry point) + +### expect/actual Pattern + +Platform-specific code uses Kotlin's `expect`/`actual` mechanism. The `commonMain` source set declares `expect` functions/classes, and `androidMain`/`desktopMain` provide `actual` implementations. Files follow the naming convention: +- `commonMain`: `FileName.kt` (contains `expect` declarations) +- `androidMain`: `FileName.android.kt` (contains `actual` implementations) +- `desktopMain`: `FileName.desktop.kt` (contains `actual` implementations) + +When modifying platform abstractions, you MUST update both `actual` implementations. + +### Source Set Structure + +``` +common/src/ +├── commonMain/kotlin/chat/simplex/common/ -- Shared code (195 files) +│ ├── model/ -- ChatModel, SimpleXAPI, CryptoFile +│ ├── platform/ -- expect/actual platform abstractions +│ ├── ui/theme/ -- Theme system (ThemeManager, colors, types) +│ └── views/ -- Compose UI (chat, chatlist, call, settings, etc.) +├── androidMain/kotlin/chat/simplex/common/ -- Android actuals (55 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Android-specific view variants +├── desktopMain/kotlin/chat/simplex/common/ -- Desktop actuals (56 files) +│ ├── platform/ -- actual implementations +│ └── views/ -- Desktop-specific view variants +android/src/main/java/chat/simplex/app/ -- Android app (8 files) +desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) +``` + +### Platform Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| Layout | 2-column (chat list → chat) | 3-column (sidebar → chat list → details) | +| Background messaging | SimplexService (foreground service) + MessagesFetcherWorker (WorkManager) | Continuous (always-on process) | +| Notifications | Android NotificationManager with channels | Desktop system notifications | +| Calls | CallActivity (separate Activity) + CallService | In-window call view | +| Video playback | ExoPlayer | VLC (VLCJ) | +| Authentication | Android BiometricPrompt | Passcode only | +| Auto-update | Play Store / manual APK | Built-in AppUpdater | +| Window management | Standard Activity lifecycle | StoreWindowState persistence | +| Entry point | SimplexApp (Application) + MainActivity | Main.kt → initHaskell() → showApp() | + +--- + +## Document Map + +### Shared Sources (commonMain) + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| common/.../common/App.kt | spec/architecture.md | product/views/chat-list.md | +| common/.../common/AppLock.kt | spec/architecture.md | product/views/settings.md | +| common/.../common/model/ChatModel.kt | spec/state.md | product/concepts.md | +| common/.../common/model/SimpleXAPI.kt | spec/api.md, spec/architecture.md | product/concepts.md | +| common/.../common/model/CryptoFile.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/Core.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/AppCommon.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/platform/Notifications.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/NtfManager.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Files.kt | spec/services/files.md | product/flows/file-transfer.md | +| common/.../common/platform/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/platform/Share.kt | spec/architecture.md | product/concepts.md | +| common/.../common/platform/VideoPlayer.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/RecAndPlay.kt | spec/services/files.md | product/views/chat.md | +| common/.../common/platform/UI.kt | spec/architecture.md | product/views/chat.md | +| common/.../common/platform/Platform.kt | spec/architecture.md | product/concepts.md | +| common/.../common/ui/theme/ThemeManager.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Theme.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/ui/theme/Color.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/chatlist/ChatListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatListNavLinkView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/ChatPreviewView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/UserPicker.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chatlist/TagListView.kt | spec/client/chat-list.md | product/views/chat-list.md | +| common/.../common/views/chat/ChatView.kt | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/chat/ComposeView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/SendMsgView.kt | spec/client/compose.md | product/views/chat.md | +| common/.../common/views/chat/ChatInfoView.kt | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/chat/group/ | spec/client/chat-view.md | product/views/group-info.md | +| common/.../common/views/chat/item/ | spec/client/chat-view.md | product/views/chat.md | +| common/.../common/views/call/CallView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/IncomingCallAlertView.kt | spec/services/calls.md | product/views/call.md | +| common/.../common/views/call/WebRTC.kt | spec/services/calls.md | product/flows/calling.md | +| common/.../common/views/newchat/NewChatView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/newchat/AddGroupView.kt | spec/client/navigation.md | product/views/new-chat.md | +| common/.../common/views/usersettings/SettingsView.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/Appearance.kt | spec/services/theme.md | product/views/settings.md | +| common/.../common/views/usersettings/PrivacySettings.kt | spec/client/navigation.md | product/views/settings.md | +| common/.../common/views/usersettings/networkAndServers/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/usersettings/UserProfilesView.kt | spec/client/navigation.md | product/views/user-profiles.md | +| common/.../common/views/onboarding/ | spec/client/navigation.md | product/views/onboarding.md | +| common/.../common/views/localauth/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/database/ | spec/database.md | product/views/settings.md | +| common/.../common/views/migration/ | spec/database.md | product/flows/onboarding.md | +| common/.../common/views/remote/ | spec/architecture.md | product/views/settings.md | +| common/.../common/views/contacts/ | spec/client/chat-view.md | product/views/contact-info.md | +| common/.../common/views/helpers/ | spec/architecture.md | product/concepts.md | + +### Android-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| android/.../app/SimplexApp.kt | spec/architecture.md | product/flows/onboarding.md | +| android/.../app/MainActivity.kt | spec/architecture.md | product/views/chat-list.md | +| android/.../app/SimplexService.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/CallService.kt | spec/services/calls.md | product/flows/calling.md | +| android/.../app/MessagesFetcherWorker.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/model/NtfManager.android.kt | spec/services/notifications.md | product/flows/messaging.md | +| android/.../app/views/call/CallActivity.kt | spec/services/calls.md | product/views/call.md | + +### Desktop-Specific Sources + +| Source Location | Spec Document | Product Document | +|----------------|---------------|-----------------| +| desktop/.../desktop/Main.kt | spec/architecture.md | product/flows/onboarding.md | +| common/.../common/DesktopApp.kt (desktopMain) | spec/architecture.md | product/views/chat-list.md | +| common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | +| common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | + +### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/multiplatform/`) + +| 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 | diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt index 4f47fda130..1a3703822d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/Images.android.kt @@ -21,12 +21,19 @@ import java.net.URI import kotlin.math.min import kotlin.math.sqrt +private const val MAX_IMAGE_DIMENSION = 4320 + actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") return try { val imageBytes = Base64.decode(imageString, Base64.NO_WRAP) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) + if (options.outWidth <= 0 || options.outHeight <= 0 || options.outWidth > MAX_IMAGE_DIMENSION || options.outHeight > MAX_IMAGE_DIMENSION || options.outHeight > options.outWidth * 256) { + return errorBitmap.asImageBitmap() + } BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size).asImageBitmap() } catch (e: Exception) { Log.e(TAG, "base64ToBitmap error: $e") diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index 22e53af849..56279a5143 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -108,6 +108,7 @@ class ActiveCallState: Closeable { } +// Spec: spec/services/calls.md#ActiveCallView @SuppressLint("SourceLockedOrientationActivity") @Composable actual fun ActiveCallView() { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index 9d1e0c4e97..a5021ae54c 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -182,6 +182,8 @@ private fun spannableStringToAnnotatedString( actual fun getAppFileUri(fileName: String): URI = FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI() +actual fun clearImageCaches() {} + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap actual suspend fun getLoadedImage(file: CIFile?): Pair? { val filePath = getLoadedFilePath(file) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 70e0067260..d9439a5474 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -42,6 +42,7 @@ import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* import kotlinx.coroutines.flow.* +// Spec: spec/client/navigation.md#AppScreen @Composable fun AppScreen() { AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() } @@ -78,6 +79,7 @@ fun AppScreen() { } } +// Spec: spec/client/navigation.md#MainScreen @Composable fun MainScreen() { val chatModel = ChatModel @@ -289,6 +291,7 @@ fun AndroidWrapInCallLayout(content: @Composable () -> Unit) { } } +// Spec: spec/client/navigation.md#AndroidScreen @Composable fun AndroidScreen(userPickerState: MutableStateFlow) { BoxWithConstraints { @@ -402,6 +405,7 @@ fun EndPartOfScreen() { ModalManager.end.showInView() } +// Spec: spec/client/navigation.md#DesktopScreen @Composable fun DesktopScreen(userPickerState: MutableStateFlow) { Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt index d6f9640cb9..32a5ce1ef1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt @@ -13,6 +13,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import kotlinx.coroutines.* +// Spec: spec/client/navigation.md#AppLock object AppLock { /** * We don't want these values to be bound to Activity lifecycle since activities are changed often, for example, when a user diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 8db2cc1a76..4e406044e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -78,9 +78,33 @@ object ConnectProgressManager { val connectProgressManager = ConnectProgressManager +object ChannelRelaysModel { + val groupId = mutableStateOf(null) + val groupRelays = mutableStateListOf() + + fun set(groupId: Long, groupRelays: List) { + this.groupId.value = groupId + this.groupRelays.clear() + this.groupRelays.addAll(groupRelays) + } + + fun updateRelay(groupInfo: GroupInfo, relay: GroupRelay) { + if (groupId.value == groupInfo.groupId) { + val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId } + if (i >= 0) groupRelays[i] = relay + } + } + + fun reset() { + groupId.value = null + groupRelays.clear() + } +} + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ +// Spec: spec/state.md#ChatModel @Stable object ChatModel { val controller: ChatController = ChatController @@ -109,9 +133,13 @@ object ChatModel { val chats: State> = chatsContext.chats // rhId, chatId val deletedChats = mutableStateOf>>(emptyList()) + val creatingChannelId = mutableStateOf(null) val groupMembers = mutableStateOf>(emptyList()) val groupMembersIndexes = mutableStateOf>(emptyMap()) val membersLoaded = mutableStateOf(false) + // Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart. + // APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join. + val channelRelayHostnames = mutableStateMapOf>() // Chat Tags val userTags = mutableStateOf(emptyList()) @@ -334,6 +362,7 @@ object ChatModel { } } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) /** if you modify the items by adding/removing them, use helpers methods like [addToChatItems], [removeLastChatItems], [removeAllAndNotify], [clearAndNotify] and so on. @@ -845,12 +874,22 @@ object ChatModel { } fun removeChat(rhId: Long?, id: String) { + var groupId: Long? = null val i = getChatIndex(rhId, id) if (i != -1) { val chat = chats.removeAt(i) + groupId = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.groupId removePresetChatTags(chat.chatInfo, chat.chatStats) removeWallpaperFilesFromChat(chat) } + if (chatId.value == id) { + groupMembers.value = emptyList() + groupMembersIndexes.value = emptyMap() + if (groupId != null) { + channelRelayHostnames.remove(groupId) + } + membersLoaded.value = false + } } suspend fun upsertGroupMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember): Boolean { @@ -859,8 +898,8 @@ object ChatModel { updateGroup(rhId, groupInfo) return false } - // update current chat - return if (chatId.value == groupInfo.id) { + // update current chat or channel being created + return if (chatId.value == groupInfo.id || creatingChannelId.value == groupInfo.id) { if (groupMembers.value.isNotEmpty() && groupMembers.value.firstOrNull()?.groupId != groupInfo.groupId) { // stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn groupMembers.value = emptyList() @@ -1218,6 +1257,7 @@ data class User( val autoAcceptMemberContacts: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, + val userChatRelay: Boolean, ): NamedChat, UserLike { override val displayName: String get() = profile.displayName override val fullName: String get() = profile.fullName @@ -1248,6 +1288,7 @@ data class User( autoAcceptMemberContacts = false, viewPwdHash = null, uiThemes = null, + userChatRelay = false, ) } } @@ -1321,6 +1362,7 @@ interface SomeChat { val updatedAt: Instant } +// Spec: spec/state.md#Chat @Serializable @Stable data class Chat( val remoteHostId: Long?, @@ -1362,6 +1404,7 @@ data class Chat( true } + // Spec: spec/state.md#ChatStats @Serializable data class ChatStats( val unreadCount: Int = 0, @@ -1382,6 +1425,7 @@ data class Chat( } } +// Spec: spec/state.md#ChatInfo @Serializable sealed class ChatInfo: SomeChat, NamedChat { @@ -1578,7 +1622,11 @@ sealed class ChatInfo: SomeChat, NamedChat { return generalGetString(MR.strings.reviewed_by_admins) to generalGetString(MR.strings.observer_cant_send_message_desc) } if (groupInfo.membership.memberRole == GroupMemberRole.Observer) { - return generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + return if (groupInfo.useRelays) { + generalGetString(MR.strings.you_are_subscriber) to null + } else { + generalGetString(MR.strings.observer_cant_send_message_title) to generalGetString(MR.strings.observer_cant_send_message_desc) + } } return null } @@ -1700,6 +1748,9 @@ sealed class ChatInfo: SomeChat, NamedChat { is Group -> groupInfo else -> null } + + val isChannel: Boolean + get() = groupInfo_?.useRelays == true } @Serializable @@ -1899,6 +1950,12 @@ data class Connection( val connInactive: Boolean get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core + val connFailedErr: String? + get() = when (connStatus) { + is ConnStatus.Failed -> connStatus.connError + else -> null + } + val connPQEnabled: Boolean get() = pqSndEnabled == true && pqRcvEnabled == true @@ -1998,6 +2055,8 @@ sealed class ForwardConfirmation { @Serializable data class GroupInfo ( val groupId: Long, + val useRelays: Boolean, + val relayOwnStatus: RelayStatus? = null, override val localDisplayName: String, val groupProfile: GroupProfile, val businessChat: BusinessChatInfo? = null, @@ -2009,6 +2068,7 @@ data class GroupInfo ( val chatTs: Instant?, val preparedGroup: PreparedGroup?, val uiThemes: ThemeModeOverrides? = null, + val groupSummary: GroupSummary, val membersRequireAttention: Int, val chatTags: List, val chatItemTTL: Long?, @@ -2050,7 +2110,9 @@ data class GroupInfo ( get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive val chatIconName: ImageResource - get() = when (businessChat?.chatType) { + get() = if (useRelays) { + MR.images.ic_bigtop_updates_padded + } else when (businessChat?.chatType) { null -> MR.images.ic_supervised_user_circle_filled BusinessChatType.Business -> MR.images.ic_work_filled_padded BusinessChatType.Customer -> MR.images.ic_account_circle_filled @@ -2074,6 +2136,7 @@ data class GroupInfo ( companion object { val sampleData = GroupInfo( groupId = 1, + useRelays = false, localDisplayName = "team", groupProfile = GroupProfile.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData, @@ -2084,6 +2147,7 @@ data class GroupInfo ( chatTs = Clock.System.now(), preparedGroup = null, uiThemes = null, + groupSummary = GroupSummary(currentMembers = 0), membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", @@ -2102,6 +2166,39 @@ data class PreparedGroup ( @Serializable data class GroupRef(val groupId: Long, val localDisplayName: String) +@Serializable(with = GroupTypeSerializer::class) +sealed class GroupType { + @Serializable @SerialName("channel") object Channel: GroupType() + @Serializable @SerialName("unknown") data class Unknown(val type: String): GroupType() +} + +object GroupTypeSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("GroupType", PrimitiveKind.STRING) + + override fun deserialize(decoder: Decoder): GroupType { + return when (val value = decoder.decodeString()) { + "channel" -> GroupType.Channel + else -> GroupType.Unknown(value) + } + } + + override fun serialize(encoder: Encoder, value: GroupType) { + val stringValue = when (value) { + is GroupType.Channel -> "channel" + is GroupType.Unknown -> value.type + } + encoder.encodeString(stringValue) + } +} + +@Serializable +data class PublicGroupProfile( + val groupType: GroupType, + val groupLink: String, + val publicGroupId: String +) + @Serializable data class GroupProfile ( override val displayName: String, @@ -2109,6 +2206,7 @@ data class GroupProfile ( override val shortDescr: String?, val description: String? = null, override val image: String? = null, + val publicGroup: PublicGroupProfile? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, val memberAdmission: GroupMemberAdmission? = null @@ -2151,10 +2249,76 @@ data class ContactShortLinkData ( ) @Serializable -data class GroupShortLinkData ( - val groupProfile: GroupProfile +data class GroupSummary ( + val currentMembers: Long, + val publicMemberCount: Long? = null ) +@Serializable +data class PublicGroupData ( + val publicMemberCount: Long +) + +@Serializable +data class GroupShortLinkData ( + val groupProfile: GroupProfile, + val publicGroupData: PublicGroupData? = null +) + +@Serializable +enum class RelayStatus { + @SerialName("new") RsNew, + @SerialName("invited") RsInvited, + @SerialName("accepted") RsAccepted, + @SerialName("active") RsActive; + + val text: String get() = when (this) { + RsNew -> generalGetString(MR.strings.relay_status_new) + RsInvited -> generalGetString(MR.strings.relay_status_invited) + RsAccepted -> generalGetString(MR.strings.relay_status_accepted) + RsActive -> generalGetString(MR.strings.relay_status_active) + } +} + +@Serializable +data class RelayProfile( + val displayName: String, + val fullName: String, + val shortDescr: String? = null, + val image: String? = null +) + +@Serializable +data class UserChatRelay( + val chatRelayId: Long?, + val address: String, + val relayProfile: RelayProfile, + val domains: List, + val preset: Boolean, + val tested: Boolean? = null, + val enabled: Boolean, + val deleted: Boolean, +) { + @Transient + private val createdAt: Date = Date() + val id: String get() = "$address $createdAt" + + val displayName: String get() = relayProfile.displayName + + fun copyWithName(name: String): UserChatRelay = copy(relayProfile = relayProfile.copy(displayName = name)) +} + +@Serializable +data class GroupRelay( + val groupRelayId: Long, + val groupMemberId: Long, + val userChatRelay: UserChatRelay, + val relayStatus: RelayStatus, + val relayLink: String? = null +) { + val id: Long get() = groupRelayId +} + @Serializable data class BusinessChatInfo ( val chatType: BusinessChatType, @@ -2185,7 +2349,8 @@ data class GroupMember ( val memberContactProfileId: Long, var activeConn: Connection? = null, val supportChat: GroupSupportChat? = null, - val memberChatVRange: VersionRange + val memberChatVRange: VersionRange, + val relayLink: String? = null ): NamedChat { val id: String get() = "#$groupId @$groupMemberId" val ready get() = activeConn?.connStatus == ConnStatus.Ready @@ -2294,14 +2459,14 @@ data class GroupMember ( } fun canChangeRoleTo(groupInfo: GroupInfo): List? = - if (!canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null + if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null else groupInfo.membership.memberRole.let { userRole -> GroupMemberRole.selectableRoles.filter { it <= userRole } } fun canBlockForAll(groupInfo: GroupInfo): Boolean { val userRole = groupInfo.membership.memberRole - return memberRole < GroupMemberRole.Moderator + return memberRole != GroupMemberRole.Relay && memberRole < GroupMemberRole.Moderator && userRole >= GroupMemberRole.Moderator && userRole >= memberRole && groupInfo.membership.memberActive && !memberPending } @@ -2362,7 +2527,8 @@ data class GroupMemberIds( @Serializable enum class GroupMemberRole(val memberRole: String) { - @SerialName("observer") Observer("observer"), // order matters in comparisons + @SerialName("relay") Relay("relay"), // order matters in comparisons + @SerialName("observer") Observer("observer"), @SerialName("author") Author("author"), @SerialName("member") Member("member"), @SerialName("moderator") Moderator("moderator"), @@ -2374,6 +2540,7 @@ enum class GroupMemberRole(val memberRole: String) { } val text: String get() = when (this) { + Relay -> generalGetString(MR.strings.group_member_role_relay) Observer -> generalGetString(MR.strings.group_member_role_observer) Author -> generalGetString(MR.strings.group_member_role_author) Member -> generalGetString(MR.strings.group_member_role_member) @@ -2633,25 +2800,27 @@ class PendingContactConnection( } @Serializable -enum class ConnStatus { - @SerialName("new") New, - @SerialName("prepared") Prepared, - @SerialName("joined") Joined, - @SerialName("requested") Requested, - @SerialName("accepted") Accepted, - @SerialName("snd-ready") SndReady, - @SerialName("ready") Ready, - @SerialName("deleted") Deleted; +sealed class ConnStatus { + @Serializable @SerialName("new") object New: ConnStatus() + @Serializable @SerialName("prepared") object Prepared: ConnStatus() + @Serializable @SerialName("joined") object Joined: ConnStatus() + @Serializable @SerialName("requested") object Requested: ConnStatus() + @Serializable @SerialName("accepted") object Accepted: ConnStatus() + @Serializable @SerialName("sndReady") object SndReady: ConnStatus() + @Serializable @SerialName("ready") object Ready: ConnStatus() + @Serializable @SerialName("deleted") object Deleted: ConnStatus() + @Serializable @SerialName("failed") class Failed(val connError: String): ConnStatus() val initiated: Boolean? get() = when (this) { - New -> true - Prepared -> false - Joined -> false - Requested -> true - Accepted -> true - SndReady -> null - Ready -> null - Deleted -> null + is New -> true + is Prepared -> false + is Joined -> false + is Requested -> true + is Accepted -> true + is SndReady -> null + is Ready -> null + is Deleted -> null + is Failed -> null } } @@ -2725,12 +2894,14 @@ data class ChatItem ( val id: Long get() = meta.itemId val timestampText: String get() = meta.timestampText - val text: String get() { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String { val mc = content.msgContent return when { - content.text == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) - content.text == "" && file != null -> file.fileName - else -> content.text + content.text(isChannel) == "" && file != null && mc is MsgContent.MCVoice -> String.format(generalGetString(MR.strings.voice_message_with_duration), durationText(mc.duration)) + content.text(isChannel) == "" && file != null -> file.fileName + else -> content.text(isChannel) } } @@ -2831,6 +3002,8 @@ data class ChatItem ( } else { null } + } else if (chatInfo is ChatInfo.Group && chatDir is CIDirection.ChannelRcv) { + null } else { null } @@ -3172,6 +3345,7 @@ sealed class CIDirection { @Serializable @SerialName("directRcv") class DirectRcv: CIDirection() @Serializable @SerialName("groupSnd") class GroupSnd: CIDirection() @Serializable @SerialName("groupRcv") class GroupRcv(val groupMember: GroupMember): CIDirection() + @Serializable @SerialName("channelRcv") class ChannelRcv: CIDirection() @Serializable @SerialName("localSnd") class LocalSnd: CIDirection() @Serializable @SerialName("localRcv") class LocalRcv: CIDirection() @@ -3180,6 +3354,7 @@ sealed class CIDirection { is DirectRcv -> false is GroupSnd -> true is GroupRcv -> false + is ChannelRcv -> false is LocalSnd -> true is LocalRcv -> false } @@ -3584,7 +3759,9 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("chatBanner") object ChatBanner: CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("invalidJSON") data class InvalidJSON(val json: String): CIContent() { override val msgContent: MsgContent? get() = null } - override val text: String get() = when (this) { + override val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is SndMsgContent -> msgContent.text is RcvMsgContent -> msgContent.text is SndDeleted -> generalGetString(MR.strings.deleted_description) @@ -3596,8 +3773,8 @@ sealed class CIContent: ItemContent { is RcvGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text is RcvDirectEventContent -> rcvDirectEvent.text - is RcvGroupEventContent -> rcvGroupEvent.text - is SndGroupEventContent -> sndGroupEvent.text + is RcvGroupEventContent -> rcvGroupEvent.text(isChannel) + is SndGroupEventContent -> sndGroupEvent.text(isChannel) is RcvConnEventContent -> rcvConnEvent.text is SndConnEventContent -> sndConnEvent.text is RcvChatFeature -> featureText(feature, enabled.text, param) @@ -3720,6 +3897,7 @@ class CIQuote ( is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> membership?.displayName ?: generalGetString(MR.strings.sender_you_pronoun) is CIDirection.GroupRcv -> chatDir.groupMember.displayName + is CIDirection.ChannelRcv -> null is CIDirection.LocalSnd -> generalGetString(MR.strings.sender_you_pronoun) is CIDirection.LocalRcv -> null null -> null @@ -3777,7 +3955,7 @@ object MsgReactionSerializer : KSerializer { when(val t = json["type"]?.jsonPrimitive?.content ?: "") { "emoji" -> { val msgReaction = try { - val emoji = Json.decodeFromString(json["emoji"].toString()) + val emoji = decoder.json.decodeFromString(json["emoji"].toString()) MsgReaction.Emoji(emoji) } catch (e: Throwable) { MsgReaction.Unknown(t, json) @@ -4219,7 +4397,7 @@ object MsgContentSerializer : KSerializer { when (t) { "text" -> MsgContent.MCText(text) "link" -> { - val preview = Json.decodeFromString(json["preview"].toString()) + val preview = decoder.json.decodeFromString(json["preview"].toString()) MsgContent.MCLink(text, preview) } "image" -> { @@ -4237,11 +4415,11 @@ object MsgContentSerializer : KSerializer { } "file" -> MsgContent.MCFile(text) "report" -> { - val reason = Json.decodeFromString(json["reason"].toString()) + val reason = decoder.json.decodeFromString(json["reason"].toString()) MsgContent.MCReport(text, reason) } "chat" -> { - val chatLink = Json.decodeFromString(json["chatLink"].toString()) + val chatLink = decoder.json.decodeFromString(json["chatLink"].toString()) MsgContent.MCChat(text, chatLink) } else -> MsgContent.MCUnknown(t, text, json) @@ -4350,6 +4528,7 @@ sealed class Format { @Serializable @SerialName("strikeThrough") class StrikeThrough: Format() @Serializable @SerialName("snippet") class Snippet: Format() @Serializable @SerialName("secret") class Secret: Format() + @Serializable @SerialName("small") class Small: Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("hyperLink") class HyperLink(val showText: String?, val linkUri: String): Format() @@ -4371,6 +4550,7 @@ sealed class Format { is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough) is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace) is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor) + is Small -> SpanStyle(fontSize = MaterialTheme.typography.body2.fontSize, color = MaterialTheme.colors.secondary) is Colored -> SpanStyle(color = this.color.uiColor) is Uri -> linkStyle is HyperLink -> linkStyle @@ -4591,7 +4771,9 @@ sealed class RcvGroupEvent() { @Serializable @SerialName("memberProfileUpdated") class MemberProfileUpdated(val fromProfile: Profile, val toProfile: Profile): RcvGroupEvent() @Serializable @SerialName("newMemberPendingReview") class NewMemberPendingReview(): RcvGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberAdded -> String.format(generalGetString(MR.strings.rcv_group_event_member_added), profile.profileViewName) is MemberConnected -> generalGetString(MR.strings.rcv_group_event_member_connected) is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName) @@ -4606,8 +4788,8 @@ sealed class RcvGroupEvent() { is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text) is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName) is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted) - is GroupDeleted -> generalGetString(MR.strings.rcv_group_event_group_deleted) - is GroupUpdated -> generalGetString(MR.strings.rcv_group_event_updated_group_profile) + is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_updated_channel_profile else MR.strings.rcv_group_event_updated_group_profile) is InvitedViaGroupLink -> generalGetString(MR.strings.rcv_group_event_invited_via_your_group_link) is MemberCreatedContact -> generalGetString(MR.strings.rcv_group_event_member_created_contact) is MemberProfileUpdated -> profileUpdatedText(fromProfile, toProfile) @@ -4639,7 +4821,9 @@ sealed class SndGroupEvent() { @Serializable @SerialName("memberAccepted") class MemberAccepted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() @Serializable @SerialName("userPendingReview") class UserPendingReview(): SndGroupEvent() - val text: String get() = when (this) { + val text: String get() = text(isChannel = false) + + fun text(isChannel: Boolean): String = when (this) { is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text) is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text) is MemberBlocked -> if (blocked) { @@ -4649,7 +4833,7 @@ sealed class SndGroupEvent() { } is MemberDeleted -> String.format(generalGetString(MR.strings.snd_group_event_member_deleted), profile.profileViewName) is UserLeft -> generalGetString(MR.strings.snd_group_event_user_left) - is GroupUpdated -> generalGetString(MR.strings.snd_group_event_group_profile_updated) + is GroupUpdated -> generalGetString(if (isChannel) MR.strings.snd_channel_event_channel_profile_updated else MR.strings.snd_group_event_group_profile_updated) is MemberAccepted -> generalGetString(MR.strings.snd_group_event_member_accepted) is UserPendingReview -> generalGetString(MR.strings.snd_group_event_user_pending_review) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt index 6ef56a9124..60f5c9e2ca 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt @@ -20,6 +20,7 @@ sealed class WriteFileResult { } * */ +// Spec: spec/services/files.md#writeCryptoFile fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs { val ctrl = ChatController.getChatCtrl() ?: throw Exception("Controller is not initialized") val buffer = ByteBuffer.allocateDirect(data.size) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 31edeec55a..661b7e767f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -90,6 +90,7 @@ enum class SimplexLinkMode { } } +// Spec: spec/state.md#AppPreferences class AppPreferences { // deprecated, remove in 2024 private val runServiceInBackground = mkBoolPreference(SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND, true) @@ -491,6 +492,7 @@ private const val MESSAGE_TIMEOUT: Int = 300_000_000 object ChatController { private var chatCtrl: ChatCtrl? = -1 + // Spec: spec/state.md#appPrefs val appPrefs: AppPreferences by lazy { AppPreferences() } val messagesChannel: Channel = Channel() @@ -654,6 +656,7 @@ object ChatController { chatModel.updateChatTags(rhId) } + // Spec: spec/api.md#startReceiver private fun startReceiver() { Log.d(TAG, "ChatController startReceiver") if (receiverJob != null || chatCtrl == null) return @@ -797,6 +800,7 @@ object ChatController { return null } + // Spec: spec/api.md#sendCmd suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, retryNum: Int = 0, log: Boolean = true): API { val ctrl = otherCtrl ?: chatCtrl ?: throw Exception("Controller is not initialized") @@ -821,6 +825,7 @@ object ChatController { } } + // Spec: spec/api.md#recvMsg fun recvMsg(ctrl: ChatCtrl): API? { val rStr = chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT) return if (rStr == "") { @@ -1066,8 +1071,8 @@ object ChatController { suspend fun apiReorderChatTags(rh: Long?, tagIds: List) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds)) - suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { - val cmd = CC.ApiSendMessages(type, id, scope, live, ttl, composedMessages) + suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, sendAsGroup: Boolean = false, live: Boolean = false, ttl: Int? = null, composedMessages: List): List? { + val cmd = CC.ApiSendMessages(type, id, scope, sendAsGroup, live, ttl, composedMessages) return processSendMessageCmd(rh, cmd) } @@ -1125,8 +1130,8 @@ object ChatController { return null } - suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { - val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl) + suspend fun apiForwardChatItems(rh: Long?, toChatType: ChatType, toChatId: Long, toScope: GroupChatScope?, sendAsGroup: Boolean = false, fromChatType: ChatType, fromChatId: Long, fromScope: GroupChatScope?, itemIds: List, ttl: Int?): List? { + val cmd = CC.ApiForwardChatItems(toChatType, toChatId, toScope, sendAsGroup, fromChatType, fromChatId, fromScope, itemIds, ttl) return processSendMessageCmd(rh, cmd)?.map { it.chatItem } } @@ -1211,6 +1216,14 @@ object ChatController { throw Exception("testProtoServer bad response: ${r.responseType} ${r.details}") } + suspend fun testChatRelay(rh: Long?, address: String): Pair { + val userId = currentUserId("testChatRelay") + val r = sendCmd(rh, CC.APITestChatRelay(userId, address)) + if (r is API.Result && r.res is CR.ChatRelayTestResult) return r.res.relayProfile to r.res.relayTestFailure + Log.e(TAG, "testChatRelay bad response: ${r.responseType} ${r.details}") + throw Exception("testChatRelay bad response: ${r.responseType} ${r.details}") + } + suspend fun getServerOperators(rh: Long?): ServerOperatorConditionsDetail? { val r = sendCmd(rh, CC.ApiGetServerOperators()) if (r is API.Result && r.res is CR.ServerOperatorConditions) return r.res.conditions @@ -1245,10 +1258,10 @@ object ChatController { return false } - suspend fun validateServers(rh: Long?, userServers: List): List? { + suspend fun validateServers(rh: Long?, userServers: List): Pair, List>? { val userId = currentUserId("validateServers") val r = sendCmd(rh, CC.ApiValidateServers(userId, userServers)) - if (r is API.Result && r.res is CR.UserServersValidation) return r.res.serverErrors + if (r is API.Result && r.res is CR.UserServersValidation) return Pair(r.res.serverErrors, r.res.serverWarnings) Log.e(TAG, "validateServers bad response: ${r.responseType} ${r.details}") return null } @@ -1338,6 +1351,12 @@ object ChatController { suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) + suspend fun apiGetUpdatedGroupLinkData(rh: Long?, groupId: Long): GroupInfo? { + val r = sendCmd(rh, CC.ApiGetUpdatedGroupLinkData(groupId)) + if (r is API.Result && r.res is CR.CRGroupInfo) return r.res.groupInfo + return null + } + suspend fun apiContactInfo(rh: Long?, contactId: Long): Pair? { val r = sendCmd(rh, CC.APIContactInfo(contactId)) if (r is API.Result && r.res is CR.ContactInfo) return r.res.connectionStats_ to r.res.customUserProfile @@ -1547,9 +1566,9 @@ object ChatController { return null } - suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, groupShortLinkData: GroupShortLinkData): Chat? { + suspend fun apiPrepareGroup(rh: Long?, connLink: CreatedConnLink, directLink: Boolean, groupShortLinkData: GroupShortLinkData): Chat? { val userId = try { currentUserId("apiPrepareGroup") } catch (e: Exception) { return null } - val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, groupShortLinkData)) + val r = sendCmd(rh, CC.APIPrepareGroup(userId, connLink, directLink, groupShortLinkData)) if (r is API.Result && r.res is CR.NewPreparedChat) return r.res.chat Log.e(TAG, "apiPrepareGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_preparing_group), "${r.responseType}: ${r.details}") @@ -1582,9 +1601,9 @@ object ChatController { return null } - suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): GroupInfo? { + suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair>? { val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg)) - if (r is API.Result && r.res is CR.StartedConnectionToGroup) return r.res.groupInfo + if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults) if (r != null) { Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}") apiConnectResponseAlert(r) @@ -2092,6 +2111,20 @@ object ChatController { return null } + suspend fun apiNewPublicGroup(rh: Long?, incognito: Boolean, relayIds: List, groupProfile: GroupProfile): Triple>? { + val userId = kotlin.runCatching { currentUserId("apiNewPublicGroup") }.getOrElse { return null } + val r = sendCmdWithRetry(rh, CC.ApiNewPublicGroup(userId, incognito, relayIds, groupProfile)) + if (r is API.Result && r.res is CR.PublicGroupCreated) return Triple(r.res.groupInfo, r.res.groupLink, r.res.groupRelays) + if (r != null) throw Exception("${r.responseType}: ${r.details}") + return null + } + + suspend fun apiGetGroupRelays(groupId: Long): List { + val r = sendCmd(null, CC.ApiGetGroupRelays(groupId)) + if (r is API.Result && r.res is CR.GroupRelays) return r.res.groupRelays + return emptyList() + } + suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? { val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole)) if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member @@ -2184,18 +2217,19 @@ object ChatController { return emptyList() } - suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? { + suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? { val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile)) + val errorTitle = if (isChannel) MR.strings.error_saving_channel_profile else MR.strings.error_saving_group_profile return when { r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup r is API.Error -> { - AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_saving_group_profile), "$r.err") + AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err") null } else -> { Log.e(TAG, "apiUpdateGroup bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( - generalGetString(MR.strings.error_saving_group_profile), + generalGetString(errorTitle), "${r.responseType}: ${r.details}" ) null @@ -2559,6 +2593,7 @@ object ChatController { AlertManager.shared.showAlertMsg(title, errMsg) } + // Spec: spec/api.md#processReceivedMsg private suspend fun processReceivedMsg(msg: API) { lastMsgReceivedTimestamp = System.currentTimeMillis() val rhId = msg.rhId @@ -2806,6 +2841,7 @@ object ChatController { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) val hostConn = r.hostMember.activeConn if (hostConn != null) { chatModel.replaceConnReqView(hostConn.id, "#${r.groupInfo.groupId}") @@ -2920,6 +2956,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.hostMember) } if ( chatModel.chatId.value == r.groupInfo.id @@ -2955,6 +2992,23 @@ object ChatController { chatModel.chatsContext.updateGroup(rhId, r.toGroup) } } + is CR.GroupLinkDataUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) + val relaysModel = ChannelRelaysModel + if (relaysModel.groupId.value == r.groupInfo.groupId) { + relaysModel.set(r.groupInfo.groupId, r.groupRelays) + } + } + } + is CR.GroupRelayUpdated -> + if (active(r.user)) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + ChannelRelaysModel.updateRelay(r.groupInfo, r.groupRelay) + } + } is CR.NewMemberContactReceivedInv -> if (active(r.user)) { withContext(Dispatchers.Main) { @@ -3519,6 +3573,7 @@ class SharedPreference(val get: () -> T, set: (T) -> Unit) { } // ChatCommand +// Spec: spec/api.md#CC sealed class CC { class Console(val cmd: String): CC() class ShowActiveUser: CC() @@ -3552,7 +3607,7 @@ sealed class CC { class ApiGetChat(val type: ChatType, val id: Long, val scope: GroupChatScope?, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC() class ApiGetChatContentTypes(val type: ChatType, val id: Long, val scope: GroupChatScope?): CC() class ApiGetChatItemInfo(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long): CC() - class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() + class ApiSendMessages(val type: ChatType, val id: Long, val scope: GroupChatScope?, val sendAsGroup: Boolean, val live: Boolean, val ttl: Int?, val composedMessages: List): CC() class ApiCreateChatTag(val tag: ChatTagData): CC() class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List): CC() class ApiDeleteChatTag(val tagId: Long): CC() @@ -3568,8 +3623,10 @@ sealed class CC { class ApiChatItemReaction(val type: ChatType, val id: Long, val scope: GroupChatScope?, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiGetReactionMembers(val userId: Long, val groupId: Long, val itemId: Long, val reaction: MsgReaction): CC() class ApiPlanForwardChatItems(val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val chatItemIds: List): CC() - class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() + class ApiForwardChatItems(val toChatType: ChatType, val toChatId: Long, val toScope: GroupChatScope?, val sendAsGroup: Boolean, val fromChatType: ChatType, val fromChatId: Long, val fromScope: GroupChatScope?, val itemIds: List, val ttl: Int?): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() + class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List, val groupProfile: GroupProfile): CC() + class ApiGetGroupRelays(val groupId: Long): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC() @@ -3589,6 +3646,7 @@ sealed class CC { class APISendMemberContactInvitation(val contactId: Long, val mc: MsgContent): CC() class APIAcceptMemberContact(val contactId: Long): CC() class APITestProtoServer(val userId: Long, val server: String): CC() + class APITestChatRelay(val userId: Long, val address: String): CC() class ApiGetServerOperators(): CC() class ApiSetServerOperators(val operators: List): CC() class ApiGetUserServers(val userId: Long): CC() @@ -3607,6 +3665,7 @@ sealed class CC { class ReconnectAllServers: CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() + class ApiGetUpdatedGroupLinkData(val groupId: Long): CC() class APIContactInfo(val contactId: Long): CC() class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC() class APIContactQueueInfo(val contactId: Long): CC() @@ -3626,7 +3685,7 @@ sealed class CC { class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC() class APIConnectPlan(val userId: Long, val connLink: String): CC() class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData): CC() - class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val groupShortLinkData: GroupShortLinkData): CC() + class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData): CC() class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC() class APIChangePreparedGroupUser(val groupId: Long, val newUserId: Long): CC() class APIConnectPreparedContact(val contactId: Long, val incognito: Boolean, val msg: MsgContent?): CC() @@ -3740,7 +3799,7 @@ sealed class CC { is ApiSendMessages -> { val msgs = json.encodeToString(composedMessages) val ttlStr = if (ttl != null) "$ttl" else "default" - "/_send ${chatRef(type, id, scope)} live=${onOff(live)} ttl=${ttlStr} json $msgs" + "/_send ${chatRef(type, id, scope)}${if (sendAsGroup) "(as_group=on)" else ""} live=${onOff(live)} ttl=${ttlStr} json $msgs" } is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}" is ApiSetChatTags -> "/_tags ${chatRef(type, id, scope = null)} ${tagIds.joinToString(",")}" @@ -3761,12 +3820,14 @@ sealed class CC { is ApiGetReactionMembers -> "/_reaction members $userId #$groupId $itemId ${json.encodeToString(reaction)}" is ApiForwardChatItems -> { val ttlStr = if (ttl != null) "$ttl" else "default" - "/_forward ${chatRef(toChatType, toChatId, toScope)} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" + "/_forward ${chatRef(toChatType, toChatId, toScope)}${if (sendAsGroup) " as_group=on" else ""} ${chatRef(fromChatType, fromChatId, fromScope)} ${itemIds.joinToString(",")} ttl=${ttlStr}" } is ApiPlanForwardChatItems -> { "/_forward plan ${chatRef(fromChatType, fromChatId, fromScope)} ${chatItemIds.joinToString(",")}" } is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" + is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}" + is ApiGetGroupRelays -> "/_get relays #$groupId" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}" @@ -3786,6 +3847,7 @@ sealed class CC { is APISendMemberContactInvitation -> "/_invite member contact @$contactId ${mc.cmdString}" is APIAcceptMemberContact -> "/_accept member contact @$contactId" is APITestProtoServer -> "/_server test $userId $server" + is APITestChatRelay -> "/_relay test $userId $address" is ApiGetServerOperators -> "/_operators" is ApiSetServerOperators -> "/_operators ${json.encodeToString(operators)}" is ApiGetUserServers -> "/_servers $userId" @@ -3804,6 +3866,7 @@ sealed class CC { is ReconnectAllServers -> "/reconnect" is APISetChatSettings -> "/_settings ${chatRef(type, id, scope = null)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" + is ApiGetUpdatedGroupLinkData -> "/_get group link data #$groupId" is APIContactInfo -> "/_info @$contactId" is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId" is APIContactQueueInfo -> "/_queue info @$contactId" @@ -3823,7 +3886,7 @@ sealed class CC { is ApiChangeConnectionUser -> "/_set conn user :$connId $userId" is APIConnectPlan -> "/_connect plan $userId $connLink" is APIPrepareContact -> "/_prepare contact $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(contactShortLinkData)}" - is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} ${json.encodeToString(groupShortLinkData)}" + is APIPrepareGroup -> "/_prepare group $userId ${connLink.connFullLink} ${connLink.connShortLink ?: ""} direct=${onOff(directLink)} ${json.encodeToString(groupShortLinkData)}" is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId" is APIChangePreparedGroupUser -> "/_set group user #$groupId $newUserId" is APIConnectPreparedContact -> "/_connect contact @$contactId incognito=${onOff(incognito)}${maybeContent(msg)}" @@ -3942,6 +4005,8 @@ sealed class CC { is ApiForwardChatItems -> "apiForwardChatItems" is ApiPlanForwardChatItems -> "apiPlanForwardChatItems" is ApiNewGroup -> "apiNewGroup" + is ApiNewPublicGroup -> "apiNewPublicGroup" + is ApiGetGroupRelays -> "apiGetGroupRelays" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" is ApiAcceptMember -> "apiAcceptMember" @@ -3961,6 +4026,7 @@ sealed class CC { is APISendMemberContactInvitation -> "apiSendMemberContactInvitation" is APIAcceptMemberContact -> "apiAcceptMemberContact" is APITestProtoServer -> "testProtoServer" + is APITestChatRelay -> "apiTestChatRelay" is ApiGetServerOperators -> "apiGetServerOperators" is ApiSetServerOperators -> "apiSetServerOperators" is ApiGetUserServers -> "apiGetUserServers" @@ -3979,6 +4045,7 @@ sealed class CC { is ReconnectAllServers -> "reconnectAllServers" is APISetChatSettings -> "apiSetChatSettings" is ApiSetMemberSettings -> "apiSetMemberSettings" + is ApiGetUpdatedGroupLinkData -> "apiGetUpdatedGroupLinkData" is APIContactInfo -> "apiContactInfo" is APIGroupMemberInfo -> "apiGroupMemberInfo" is APIContactQueueInfo -> "apiContactQueueInfo" @@ -4113,7 +4180,8 @@ fun onOff(b: Boolean): String = if (b) "on" else "off" @Serializable data class NewUser( val profile: Profile?, - val pastTimestamp: Boolean + val pastTimestamp: Boolean, + val userChatRelay: Boolean = false ) sealed class ChatPagination { @@ -4150,9 +4218,11 @@ class UpdatedMessage(val msgContent: MsgContent, val mentions: Map @Serializable class ChatTagData(val emoji: String?, val text: String) +// Spec: spec/api.md#ArchiveConfig @Serializable class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null) +// Spec: spec/database.md#DBEncryptionConfig @Serializable class DBEncryptionConfig(val currentKey: String, val newKey: String) @@ -4364,7 +4434,8 @@ data class ServerRoles( data class UserOperatorServers( val operator: ServerOperator?, val smpServers: List, - val xftpServers: List + val xftpServers: List, + val chatRelays: List = emptyList() ) { val id: String get() = operator?.operatorId?.toString() ?: "nil operator" @@ -4403,19 +4474,22 @@ sealed class UserServersError { @Serializable @SerialName("storageMissing") data class StorageMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("proxyMissing") data class ProxyMissing(val protocol: ServerProtocol, val user: UserRef?): UserServersError() @Serializable @SerialName("duplicateServer") data class DuplicateServer(val protocol: ServerProtocol, val duplicateServer: String, val duplicateHost: String): UserServersError() + @Serializable @SerialName("duplicateChatRelayAddress") data class DuplicateChatRelayAddress(val duplicateChatRelay: String, val duplicateAddress: String): UserServersError() val globalError: String? get() = when (this.protocol_) { ServerProtocol.SMP -> globalSMPError ServerProtocol.XFTP -> globalXFTPError + null -> null } - private val protocol_: ServerProtocol + private val protocol_: ServerProtocol? get() = when (this) { is NoServers -> this.protocol is StorageMissing -> this.protocol is ProxyMissing -> this.protocol is DuplicateServer -> this.protocol + is DuplicateChatRelayAddress -> null } val globalSMPError: String? @@ -4459,6 +4533,34 @@ sealed class UserServersError { } } +@Serializable +sealed class UserServersWarning { + @Serializable @SerialName("noChatRelays") data class NoChatRelays(val user: UserRef? = null): UserServersWarning() + + val globalWarning: String? + get() = when (this) { + is NoChatRelays -> { + val text = generalGetString(MR.strings.no_chat_relays_enabled) + if (user != null) { + String.format(generalGetString(MR.strings.for_chat_profile), user.localDisplayName) + " " + text + } else text + } + } +} + +@Serializable +data class RelayConnectionResult( + val relayMember: GroupMember, + val relayError: ChatError? = null +) + +@Serializable +data class GroupShortLinkInfo( + val direct: Boolean, + val groupRelays: List, + val publicGroupId: String? = null +) + @Serializable data class UserServer( val remoteHostId: Long?, @@ -4587,6 +4689,44 @@ data class ProtocolTestFailure( } } +@Serializable +enum class RelayTestStep { + @SerialName("getLink") GetLink, + @SerialName("decodeLink") DecodeLink, + @SerialName("connect") Connect, + @SerialName("waitResponse") WaitResponse, + @SerialName("verify") Verify; + + val text: String get() = when (this) { + GetLink -> generalGetString(MR.strings.relay_test_step_get_link) + DecodeLink -> generalGetString(MR.strings.relay_test_step_decode_link) + Connect -> generalGetString(MR.strings.relay_test_step_connect) + WaitResponse -> generalGetString(MR.strings.relay_test_step_wait_response) + Verify -> generalGetString(MR.strings.relay_test_step_verify) + } +} + +@Serializable +data class RelayTestFailure( + val rtfStep: RelayTestStep, + val rtfError: ChatError +) { + val localizedDescription: String get() { + val err = String.format(generalGetString(MR.strings.error_relay_test_failed_at_step), rtfStep.text) + return when { + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.SMP && rtfError.agentError.smpErr is SMPErrorType.AUTH -> + err + " " + generalGetString(MR.strings.error_relay_test_server_auth) + rtfError is ChatError.ChatErrorAgent && + rtfError.agentError is AgentErrorType.BROKER && rtfError.agentError.brokerErr is BrokerErrorType.NETWORK && + rtfError.agentError.brokerErr.networkError is NetworkError.UnknownCAError -> + err + " " + generalGetString(MR.strings.error_smp_test_certificate) + else -> + err + " " + String.format(generalGetString(MR.strings.error_with_info), rtfError.string) + } + } +} + @Serializable data class ServerAddress( val serverProtocol: ServerProtocol, @@ -5960,6 +6100,7 @@ val yaml = Yaml(configuration = YamlConfiguration( codePointLimit = 5500000, )) +// Spec: spec/api.md#API @Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = APISerializer::class) sealed class API { @@ -6099,6 +6240,7 @@ private fun decodeObject(deserializer: DeserializationStrategy, obj: Json runCatching { json.decodeFromJsonElement(deserializer, obj!!) }.getOrNull() // ChatResponse +// Spec: spec/api.md#CR @Serializable sealed class CR { @Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR() @@ -6112,13 +6254,15 @@ sealed class CR { @Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List): CR() @Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR() @Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR() + @Serializable @SerialName("chatRelayTestResult") class ChatRelayTestResult(val user: UserRef, val relayProfile: RelayProfile? = null, val relayTestFailure: RelayTestFailure? = null): CR() @Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR() @Serializable @SerialName("userServers") class UserServers(val user: UserRef, val userServers: List): CR() - @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List): CR() + @Serializable @SerialName("userServersValidation") class UserServersValidation(val user: UserRef, val serverErrors: List, val serverWarnings: List = emptyList()): CR() @Serializable @SerialName("usageConditions") class UsageConditions(val usageConditions: UsageConditionsDetail, val conditionsText: String?, val acceptedConditions: UsageConditionsDetail?): CR() @Serializable @SerialName("chatItemTTL") class ChatItemTTL(val user: UserRef, val chatItemTTL: Long? = null): CR() @Serializable @SerialName("networkConfig") class NetworkConfig(val networkConfig: NetCfg): CR() @Serializable @SerialName("contactInfo") class ContactInfo(val user: UserRef, val contact: Contact, val connectionStats_: ConnectionStats? = null, val customUserProfile: Profile? = null): CR() + @Serializable @SerialName("groupInfo") class CRGroupInfo(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupMemberInfo") class GroupMemberInfo(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionStats_: ConnectionStats? = null): CR() @Serializable @SerialName("queueInfo") class QueueInfoR(val user: UserRef, val rcvMsgInfo: RcvMsgInfo?, val queueInfo: ServerQueueInfo): CR() @Serializable @SerialName("contactSwitchStarted") class ContactSwitchStarted(val user: UserRef, val contact: Contact, val connectionStats: ConnectionStats): CR() @@ -6145,7 +6289,7 @@ sealed class CR { @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef, val connection: PendingContactConnection): CR() @Serializable @SerialName("startedConnectionToContact") class StartedConnectionToContact(val user: UserRef, val contact: Contact): CR() - @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("startedConnectionToGroup") class StartedConnectionToGroup(val user: UserRef, val groupInfo: GroupInfo, val relayResults: List = emptyList()): CR() @Serializable @SerialName("sentInvitationToContact") class SentInvitationToContact(val user: UserRef, val contact: Contact, val customUserProfile: Profile?): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @@ -6184,6 +6328,8 @@ sealed class CR { @Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List, val forwardConfirmation: ForwardConfirmation? = null): CR() // group events @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @@ -6206,10 +6352,12 @@ sealed class CR { @Serializable @SerialName("deletedMember") class DeletedMember(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val deletedMember: GroupMember, val withMessages: Boolean): CR() @Serializable @SerialName("leftMember") class LeftMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("groupDeleted") class GroupDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() - @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo): CR() + @Serializable @SerialName("userJoinedGroup") class UserJoinedGroup(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR() @Serializable @SerialName("joinedGroupMember") class JoinedGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("connectedToGroupMember") class ConnectedToGroupMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val memberContact: Contact? = null): CR() @Serializable @SerialName("groupUpdated") class GroupUpdated(val user: UserRef, val toGroup: GroupInfo): CR() + @Serializable @SerialName("groupLinkDataUpdated") class GroupLinkDataUpdated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List, val relaysChanged: Boolean): CR() + @Serializable @SerialName("groupRelayUpdated") class GroupRelayUpdated(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val groupRelay: GroupRelay): CR() @Serializable @SerialName("groupLinkCreated") class GroupLinkCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLink") class CRGroupLink(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink): CR() @Serializable @SerialName("groupLinkDeleted") class GroupLinkDeleted(val user: UserRef, val groupInfo: GroupInfo): CR() @@ -6294,6 +6442,7 @@ sealed class CR { is ChatTags -> "chatTags" is ApiChatItemInfo -> "chatItemInfo" is ServerTestResult -> "serverTestResult" + is ChatRelayTestResult -> "chatRelayTestResult" is ServerOperatorConditions -> "serverOperatorConditions" is UserServers -> "userServers" is UserServersValidation -> "userServersValidation" @@ -6301,6 +6450,7 @@ sealed class CR { is ChatItemTTL -> "chatItemTTL" is NetworkConfig -> "networkConfig" is ContactInfo -> "contactInfo" + is CRGroupInfo -> "groupInfo" is GroupMemberInfo -> "groupMemberInfo" is QueueInfoR -> "queueInfo" is ContactSwitchStarted -> "contactSwitchStarted" @@ -6365,6 +6515,8 @@ sealed class CR { is GroupChatItemsDeleted -> "groupChatItemsDeleted" is ForwardPlan -> "forwardPlan" is GroupCreated -> "groupCreated" + is PublicGroupCreated -> "publicGroupCreated" + is GroupRelays -> "groupRelays" is SentGroupInvitation -> "sentGroupInvitation" is UserAcceptedGroupSent -> "userAcceptedGroupSent" is GroupLinkConnecting -> "groupLinkConnecting" @@ -6391,6 +6543,8 @@ sealed class CR { is JoinedGroupMember -> "joinedGroupMember" is ConnectedToGroupMember -> "connectedToGroupMember" is GroupUpdated -> "groupUpdated" + is GroupLinkDataUpdated -> "groupLinkDataUpdated" + is GroupRelayUpdated -> "groupRelayUpdated" is GroupLinkCreated -> "groupLinkCreated" is CRGroupLink -> "groupLink" is GroupLinkDeleted -> "groupLinkDeleted" @@ -6468,6 +6622,7 @@ sealed class CR { is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}") is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}") is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}") + is ChatRelayTestResult -> withUser(user, "relayProfile: $relayProfile\ntestFailure: $relayTestFailure") is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}" is UserServers -> withUser(user, "userServers: ${json.encodeToString(userServers)}") is UserServersValidation -> withUser(user, "serverErrors: ${json.encodeToString(serverErrors)}") @@ -6475,6 +6630,7 @@ sealed class CR { is ChatItemTTL -> withUser(user, json.encodeToString(chatItemTTL)) is NetworkConfig -> json.encodeToString(networkConfig) is ContactInfo -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") + is CRGroupInfo -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}") is GroupMemberInfo -> withUser(user, "group: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionStats: ${json.encodeToString(connectionStats_)}") is QueueInfoR -> withUser(user, "rcvMsgInfo: ${json.encodeToString(rcvMsgInfo)}\nqueueInfo: ${json.encodeToString(queueInfo)}\n") is ContactSwitchStarted -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionStats: ${json.encodeToString(connectionStats)}") @@ -6539,6 +6695,8 @@ sealed class CR { is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_") is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}") is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) + is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays") + is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember") @@ -6565,6 +6723,8 @@ sealed class CR { is JoinedGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is ConnectedToGroupMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member\nmemberContact: $memberContact") is GroupUpdated -> withUser(user, json.encodeToString(toGroup)) + is GroupLinkDataUpdated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays\nrelaysChanged: $relaysChanged") + is GroupRelayUpdated -> withUser(user, "groupInfo: $groupInfo\nmember: $member\ngroupRelay: $groupRelay") is GroupLinkCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is CRGroupLink -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink") is GroupLinkDeleted -> withUser(user, json.encodeToString(groupInfo)) @@ -6708,7 +6868,7 @@ sealed class ContactAddressPlan { @Serializable sealed class GroupLinkPlan { - @Serializable @SerialName("ok") class Ok(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() + @Serializable @SerialName("ok") class Ok(val groupSLinkInfo_: GroupShortLinkInfo? = null, val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan() @Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan() @Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan() @Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan() @@ -6958,6 +7118,7 @@ data class RemoteFile( val fileSource: CryptoFile ) +// Spec: spec/api.md#ChatError @Serializable sealed class ChatError { val string: String get() = when (this) { @@ -6999,6 +7160,7 @@ sealed class ChatErrorType { is UserUnknown -> "userUnknown" is ActiveUserExists -> "activeUserExists" is UserExists -> "userExists" + is ChatRelayExists -> "chatRelayExists" is DifferentActiveUser -> "differentActiveUser" is CantDeleteActiveUser -> "cantDeleteActiveUser" is CantDeleteLastUser -> "cantDeleteLastUser" @@ -7068,6 +7230,7 @@ sealed class ChatErrorType { is ConnectionIncognitoChangeProhibited -> "connectionIncognitoChangeProhibited" is ConnectionUserChangeProhibited -> "connectionUserChangeProhibited" is PeerChatVRangeIncompatible -> "peerChatVRangeIncompatible" + is RelayTestError -> "relayTestError $message" is InternalError -> "internalError" is CEException -> "exception $message" } @@ -7079,6 +7242,7 @@ sealed class ChatErrorType { @Serializable @SerialName("userUnknown") object UserUnknown: ChatErrorType() @Serializable @SerialName("activeUserExists") object ActiveUserExists: ChatErrorType() @Serializable @SerialName("userExists") class UserExists(val contactName: String): ChatErrorType() + @Serializable @SerialName("chatRelayExists") object ChatRelayExists: ChatErrorType() @Serializable @SerialName("differentActiveUser") class DifferentActiveUser(val commandUserId: Long, val activeUserId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteActiveUser") class CantDeleteActiveUser(val userId: Long): ChatErrorType() @Serializable @SerialName("cantDeleteLastUser") class CantDeleteLastUser(val userId: Long): ChatErrorType() @@ -7148,6 +7312,7 @@ sealed class ChatErrorType { @Serializable @SerialName("connectionIncognitoChangeProhibited") object ConnectionIncognitoChangeProhibited: ChatErrorType() @Serializable @SerialName("connectionUserChangeProhibited") object ConnectionUserChangeProhibited: ChatErrorType() @Serializable @SerialName("peerChatVRangeIncompatible") object PeerChatVRangeIncompatible: ChatErrorType() + @Serializable @SerialName("relayTestError") class RelayTestError(val message: String): ChatErrorType() @Serializable @SerialName("internalError") class InternalError(val message: String): ChatErrorType() @Serializable @SerialName("exception") class CEException(val message: String): ChatErrorType() } @@ -7158,6 +7323,7 @@ sealed class StoreError { get() = when (this) { is DuplicateName -> "duplicateName" is UserNotFound -> "userNotFound $userId" + is RelayUserNotFound -> "relayUserNotFound" is UserNotFoundByName -> "userNotFoundByName $contactName" is UserNotFoundByContactId -> "userNotFoundByContactId $contactId" is UserNotFoundByGroupId -> "userNotFoundByGroupId $groupId" @@ -7181,6 +7347,7 @@ sealed class StoreError { is MemberContactGroupMemberNotFound -> "memberContactGroupMemberNotFound $contactId" is GroupWithoutUser -> "groupWithoutUser" is DuplicateGroupMember -> "duplicateGroupMember" + is DuplicateMemberId -> "duplicateMemberId" is GroupAlreadyJoined -> "groupAlreadyJoined" is GroupInvitationNotFound -> "groupInvitationNotFound" is NoteFolderAlreadyExists -> "noteFolderAlreadyExists $noteFolderId" @@ -7221,6 +7388,9 @@ sealed class StoreError { is HostMemberIdNotFound -> "hostMemberIdNotFound $groupId" is ContactNotFoundByFileId -> "contactNotFoundByFileId $fileId" is NoGroupSndStatus -> "noGroupSndStatus $itemId $groupMemberId" + is UserChatRelayNotFound -> "userChatRelayNotFound $chatRelayId" + is GroupRelayNotFound -> "groupRelayNotFound $groupRelayId" + is GroupRelayNotFoundByMemberId -> "groupRelayNotFoundByMemberId $groupMemberId" is DuplicateGroupMessage -> "duplicateGroupMessage $groupId $sharedMsgId $authorGroupMemberId $authorGroupMemberId" is RemoteHostNotFound -> "remoteHostNotFound $remoteHostId" is RemoteHostUnknown -> "remoteHostUnknown" @@ -7236,6 +7406,7 @@ sealed class StoreError { @Serializable @SerialName("duplicateName") object DuplicateName: StoreError() @Serializable @SerialName("userNotFound") class UserNotFound(val userId: Long): StoreError() + @Serializable @SerialName("relayUserNotFound") object RelayUserNotFound: StoreError() @Serializable @SerialName("userNotFoundByName") class UserNotFoundByName(val contactName: String): StoreError() @Serializable @SerialName("userNotFoundByContactId") class UserNotFoundByContactId(val contactId: Long): StoreError() @Serializable @SerialName("userNotFoundByGroupId") class UserNotFoundByGroupId(val groupId: Long): StoreError() @@ -7259,6 +7430,7 @@ sealed class StoreError { @Serializable @SerialName("memberContactGroupMemberNotFound") class MemberContactGroupMemberNotFound(val contactId: Long): StoreError() @Serializable @SerialName("groupWithoutUser") object GroupWithoutUser: StoreError() @Serializable @SerialName("duplicateGroupMember") object DuplicateGroupMember: StoreError() + @Serializable @SerialName("duplicateMemberId") object DuplicateMemberId: StoreError() @Serializable @SerialName("groupAlreadyJoined") object GroupAlreadyJoined: StoreError() @Serializable @SerialName("groupInvitationNotFound") object GroupInvitationNotFound: StoreError() @Serializable @SerialName("noteFolderAlreadyExists") class NoteFolderAlreadyExists(val noteFolderId: Long): StoreError() @@ -7299,6 +7471,9 @@ sealed class StoreError { @Serializable @SerialName("hostMemberIdNotFound") class HostMemberIdNotFound(val groupId: Long): StoreError() @Serializable @SerialName("contactNotFoundByFileId") class ContactNotFoundByFileId(val fileId: Long): StoreError() @Serializable @SerialName("noGroupSndStatus") class NoGroupSndStatus(val itemId: Long, val groupMemberId: Long): StoreError() + @Serializable @SerialName("userChatRelayNotFound") class UserChatRelayNotFound(val chatRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFound") class GroupRelayNotFound(val groupRelayId: Long): StoreError() + @Serializable @SerialName("groupRelayNotFoundByMemberId") class GroupRelayNotFoundByMemberId(val groupMemberId: Long): StoreError() @Serializable @SerialName("duplicateGroupMessage") class DuplicateGroupMessage(val groupId: Long, val sharedMsgId: String, val authorGroupMemberId: Long?, val forwardedByGroupMemberId: Long?): StoreError() @Serializable @SerialName("remoteHostNotFound") class RemoteHostNotFound(val remoteHostId: Long): StoreError() @Serializable @SerialName("remoteHostUnknown") object RemoteHostUnknown: StoreError() @@ -7641,6 +7816,7 @@ sealed class RCErrorType { @Serializable @SerialName("syntax") data class SYNTAX(val syntaxErr: String): RCErrorType() } +// Spec: spec/database.md#ArchiveError @Serializable sealed class ArchiveError { val string: String get() = when (this) { @@ -7722,6 +7898,7 @@ sealed class RemoteCtrlError { @Serializable @SerialName("protocolError") object ProtocolError: RemoteCtrlError() } +// Spec: spec/services/notifications.md#NotificationsMode enum class NotificationsMode() { OFF, PERIODIC, SERVICE, /*INSTANT - for Firebase notifications */; diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index d0ce703033..36a7ae1a80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -14,12 +14,14 @@ import java.io.File import java.nio.ByteBuffer // ghc's rts +// Spec: spec/architecture.md#initHS external fun initHS() // android-support external fun pipeStdOutToSocket(socketName: String) : Int // SimpleX API typealias ChatCtrl = Long +// Spec: spec/architecture.md#chatMigrateInit external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array external fun chatCloseStore(ctrl: ChatCtrl): String external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String @@ -45,6 +47,7 @@ val appPreferences: AppPreferences val chatController: ChatController = ChatController +// Spec: spec/architecture.md#initChatControllerOnStart fun initChatControllerOnStart() { withLongRunningApi { if (appPreferences.chatStopped.get() && appPreferences.storeDBPassphrase.get() && ksDatabasePassword.get() != null) { @@ -55,6 +58,7 @@ fun initChatControllerOnStart() { } } +// Spec: spec/architecture.md#initChatController suspend fun initChatController(useKey: String? = null, confirmMigrations: MigrationConfirmation? = null, startChat: () -> CompletableDeferred = { CompletableDeferred(true) }) { Log.d(TAG, "initChatController") try { @@ -182,6 +186,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat } } +// Spec: spec/architecture.md#chatInitTemporaryDatabase fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: MigrationConfirmation = MigrationConfirmation.Error): Pair { val dbKey = key ?: randomDatabasePassword() Log.d(TAG, "chatInitTemporaryDatabase path: $dbPath") @@ -193,6 +198,7 @@ fun chatInitTemporaryDatabase(dbPath: String, key: String? = null, confirmation: return res to migrated[1] as ChatCtrl } +// Spec: spec/architecture.md#chatInitControllerRemovingDatabases fun chatInitControllerRemovingDatabases() { val dbPath = dbAbsolutePrefixPath // Remove previous databases, otherwise, can be .errorNotADatabase with null controller diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt index 0a4f670fe0..88d9fbb705 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt @@ -14,6 +14,7 @@ import java.net.URLEncoder import java.nio.file.Files import java.nio.file.StandardCopyOption +// Spec: spec/services/files.md#dataDir expect val dataDir: File expect val tmpDir: File expect val filesDir: File diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt index d906ef7baf..385120f18b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt @@ -13,6 +13,7 @@ enum class NotificationAction { ACCEPT_CONTACT_REQUEST } +// Spec: spec/services/notifications.md#ntfManager lateinit var ntfManager: NtfManager abstract class NtfManager { @@ -43,7 +44,7 @@ abstract class NtfManager { chatModel.chatId.value != cInfo.id || chatModel.remoteHostId() != rhId) ) { - displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem)) + displayNotification(user = user, chatId = cInfo.id, displayName = cInfo.displayName, msgText = hideSecrets(cItem, cInfo.isChannel)) } } @@ -118,7 +119,7 @@ abstract class NtfManager { } } - private fun hideSecrets(cItem: ChatItem): String { + private fun hideSecrets(cItem: ChatItem, isChannel: Boolean = false): String { val md = cItem.formattedText return if (md != null) { var res = "" @@ -129,9 +130,9 @@ abstract class NtfManager { } else { val mc = cItem.content.msgContent if (mc is MsgContent.MCReport) { - generalGetString(MR.strings.notification_group_report).format(cItem.text.ifEmpty { mc.reason.text }) + generalGetString(MR.strings.notification_group_report).format(cItem.text(isChannel).ifEmpty { mc.reason.text }) } else { - cItem.text + cItem.text(isChannel) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index df9af7fbf6..1b5a81a819 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -22,6 +22,7 @@ import chat.simplex.res.MR import kotlinx.serialization.Transient import java.util.UUID +// Spec: spec/services/theme.md#DefaultTheme enum class DefaultTheme { LIGHT, DARK, SIMPLEX, BLACK; @@ -47,6 +48,7 @@ enum class DefaultThemeMode { @SerialName("dark") DARK } +// Spec: spec/services/theme.md#AppColors @Stable class AppColors( title: Color, @@ -99,6 +101,7 @@ class AppColors( } } +// Spec: spec/services/theme.md#AppWallpaper @Stable class AppWallpaper( background: Color? = null, @@ -133,6 +136,7 @@ class AppWallpaper( } } +// Spec: spec/services/theme.md#ThemeColor enum class ThemeColor { PRIMARY, PRIMARY_VARIANT, SECONDARY, SECONDARY_VARIANT, BACKGROUND, SURFACE, TITLE, SENT_MESSAGE, SENT_QUOTE, RECEIVED_MESSAGE, RECEIVED_QUOTE, PRIMARY_VARIANT2, WALLPAPER_BACKGROUND, WALLPAPER_TINT; @@ -174,6 +178,7 @@ enum class ThemeColor { } } +// Spec: spec/services/theme.md#ThemeColors @Serializable data class ThemeColors( @SerialName("accent") @@ -214,6 +219,7 @@ data class ThemeColors( } } +// Spec: spec/services/theme.md#ThemeWallpaper @Serializable data class ThemeWallpaper ( val preset: String? = null, @@ -293,6 +299,7 @@ data class ThemesFile( val themes: List = emptyList() ) +// Spec: spec/services/theme.md#ThemeOverrides @Serializable data class ThemeOverrides ( val themeId: String = UUID.randomUUID().toString(), @@ -463,6 +470,7 @@ fun List.skipDuplicates(): List { return res } +// Spec: spec/services/theme.md#ThemeModeOverrides @Serializable data class ThemeModeOverrides ( val light: ThemeModeOverride? = null, @@ -474,6 +482,7 @@ data class ThemeModeOverrides ( } } +// Spec: spec/services/theme.md#ThemeModeOverride @Serializable data class ThemeModeOverride ( val mode: DefaultThemeMode = CurrentColors.value.base.mode, @@ -714,6 +723,7 @@ val BlackColorPaletteApp = AppColors( var systemInDarkThemeCurrently: Boolean = isInNightMode() +// Spec: spec/services/theme.md#CurrentColors val CurrentColors: MutableStateFlow = MutableStateFlow(ThemeManager.currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get())) @Composable @@ -758,6 +768,7 @@ fun reactOnDarkThemeChanges(isDark: Boolean) { } } +// Spec: spec/services/theme.md#SimpleXTheme @Composable fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) { // TODO: Fix preview working with dark/light theme diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt index 07f2b678cf..7d8c79b4a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt @@ -53,6 +53,7 @@ object ThemeManager { ?: ThemeWallpaper.from(PresetWallpaper.SCHOOL.toType(CurrentColors.value.base), null, null)) } + // Spec: spec/services/theme.md#currentColors fun currentColors(themeOverridesForType: WallpaperType?, perChatTheme: ThemeModeOverride?, perUserTheme: ThemeModeOverrides?, appSettingsTheme: List): ActiveTheme { val themeName = appPrefs.currentTheme.get()!! val nonSystemThemeName = nonSystemThemeName() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt index 8f5aba138d..7a92bc8c39 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -6,6 +6,7 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import kotlinx.coroutines.* +// Spec: spec/services/calls.md#ActiveCallView @Composable expect fun ActiveCallView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt index 4d8c1fae46..563f4c3b83 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt @@ -22,6 +22,7 @@ import chat.simplex.common.views.usersettings.ProfilePreview import chat.simplex.res.MR import kotlinx.datetime.Clock +// Spec: spec/services/calls.md#IncomingCallAlertView @Composable fun IncomingCallAlertView(invitation: RcvCallInvitation, chatModel: ChatModel) { val cm = chatModel.callManager diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 705fc6a28f..6fa99283d8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -46,6 +46,7 @@ data class Call( get() = localMediaSources.hasVideo || peerMediaSources.hasVideo } +// Spec: spec/services/calls.md#CallState enum class CallState { WaitCapabilities, InvitationSent, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 107d427556..6562d40cec 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -88,7 +88,8 @@ suspend fun processLoadedChat( val (newIds, _) = mapItemsToIds(chat.chatItems) val wasSize = newItems.size val (oldUnreadSplitIndex, newUnreadSplitIndex, trimmedIds, newSplits) = removeDuplicatesAndModifySplitsOnBeforePagination( - unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed + unreadAfterItemId, newItems, newIds, splits, visibleItemIndexesNonReversed, + selectionActive = chatState.selectionActive ) val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0) newItems.addAll(insertAt, chat.chatItems) @@ -177,13 +178,14 @@ private fun removeDuplicatesAndModifySplitsOnBeforePagination( newItems: SnapshotStateList, newIds: Set, splits: StateFlow>, - visibleItemIndexesNonReversed: () -> IntRange + visibleItemIndexesNonReversed: () -> IntRange, + selectionActive: Boolean = false ): ModifiedSplits { var oldUnreadSplitIndex: Int = -1 var newUnreadSplitIndex: Int = -1 val visibleItemIndexes = visibleItemIndexesNonReversed() var lastSplitIndexTrimmed = -1 - var allowedTrimming = true + var allowedTrimming = !selectionActive var index = 0 /** keep the newest [TRIM_KEEP_COUNT] items (bottom area) and oldest [TRIM_KEEP_COUNT] items, trim others */ val trimRange = visibleItemIndexes.last + TRIM_KEEP_COUNT .. newItems.size - TRIM_KEEP_COUNT diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index d98c041478..f9d32892ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -202,7 +202,8 @@ data class ActiveChatState ( // exclusive val unreadAfter: MutableStateFlow = MutableStateFlow(0), // exclusive - val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0), + @Volatile var selectionActive: Boolean = false ) { fun moveUnreadAfterItem(toItemId: Long?, nonReversedItems: List) { toItemId ?: return diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 7322e3b17d..117b8955a1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -93,6 +93,7 @@ fun ConnectInProgressView(s: String) { @Composable // staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat // to chat list smooth. Otherwise, chat view will become blank right before the transition starts +// Spec: spec/client/chat-view.md#ChatView fun ChatView( chatsCtx: ChatModel.ChatsContext, staleChatId: State, @@ -147,6 +148,7 @@ fun ChatView( val showCommandsMenu = rememberSaveable { mutableStateOf(false) } val contentFilter = rememberSaveable { mutableStateOf(null) } val availableContent = remember { mutableStateOf>(ContentFilter.initialList) } + val selectionManager = if (appPlatform.isDesktop) remember { SelectionManager() } else null if (appPlatform.isAndroid) { DisposableEffect(Unit) { @@ -177,7 +179,9 @@ fun ChatView( contentFilter.value = null availableContent.value = ContentFilter.initialList selectedChatItems.value = null - if (chatsCtx.secondaryContextFilter == null) { + selectionManager?.clearSelection() + val cInfo = activeChat.value?.chatInfo + if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { updateAvailableContent(chatRh, activeChat, availableContent) } if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.activeConn != null) { @@ -199,6 +203,24 @@ fun ChatView( chatModel.chatSubStatus.value = null } } + if (cInfo is ChatInfo.Group && cInfo.groupInfo.useRelays) { + withBGApi { + setGroupMembers(chatRh, cInfo.groupInfo, chatModel) + if (cInfo.groupInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = chatModel.controller.apiGetGroupRelays(cInfo.groupInfo.groupId) + withContext(Dispatchers.Main) { + ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays) + } + } else { + val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId) + if (gInfo != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(chatRh, gInfo) + } + } + } + } + } } } } @@ -225,6 +247,7 @@ fun ChatView( val clipboard = LocalClipboardManager.current CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), + LocalSelectionManager provides selectionManager, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -356,6 +379,7 @@ fun ChatView( chatModel.groupMembers.value = emptyList() chatModel.groupMembersIndexes.value = emptyMap() chatModel.membersLoaded.value = false + ChannelRelaysModel.reset() }, info = { if (ModalManager.end.hasModalsOpen()) { @@ -486,7 +510,7 @@ fun ChatView( } ModalManager.end.showModalCloseable(true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close, close) + GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } } } @@ -751,12 +775,15 @@ fun ChatView( changeNtfsState = { enabled, currentValue -> toggleNotifications(chatRh, chatInfo, enabled, chatModel, currentValue) }, onSearchValueChanged = onSearchValueChanged, closeSearch = { - onSearchValueChanged("") + if (chatModel.openAroundItemId.value == null) { + onSearchValueChanged("") + } showSearch.value = false searchText.value = "" contentFilter.value = null // Update available content types when search closes - if (chatsCtx.secondaryContextFilter == null) { + val cInfo = activeChat.value?.chatInfo + if (chatsCtx.secondaryContextFilter == null && (cInfo is ChatInfo.Direct || cInfo is ChatInfo.Group || cInfo is ChatInfo.Local)) { updateAvailableContent(chatRh, activeChat, availableContent) } }, @@ -805,16 +832,14 @@ fun ChatView( fun updateAvailableContent(chatRh: Long?, activeChat: State, availableContent: MutableState>) { withBGApi { - Log.e(TAG, "updateAvailableContent") val chatInfo = activeChat.value?.chatInfo - if (chatInfo == null) return@withBGApi + if (chatInfo == null || chatInfo !is ChatInfo.Direct && chatInfo !is ChatInfo.Group && chatInfo !is ChatInfo.Local) return@withBGApi val types = chatModel.controller.apiGetChatContentTypes(chatRh, chatInfo.chatType, chatInfo.apiId, null) if (activeChat.value?.chatInfo?.id != chatInfo.id) return@withBGApi if (types == null) { availableContent.value = ContentFilter.entries } else { val typeSet: Set = types.union(ContentFilter.alwaysShow) - Log.e(TAG, "updateAvailableContent $typeSet") availableContent.value = ContentFilter.entries.filter { it -> typeSet.contains(it.contentTag) } } } @@ -839,10 +864,14 @@ private fun connectingText(chatInfo: ChatInfo): String? { } is ChatInfo.Group -> - when (chatInfo.groupInfo.membership.memberStatus) { - GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null - GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) - else -> null + if (chatInfo.groupInfo.useRelays) { + null + } else { + when (chatInfo.groupInfo.membership.memberStatus) { + GroupMemberStatus.MemUnknown -> if (chatInfo.groupInfo.preparedGroup?.connLinkStartedConnection == true) generalGetString(MR.strings.group_connection_pending) else null + GroupMemberStatus.MemAccepted -> generalGetString(MR.strings.group_connection_pending) + else -> null + } } else -> null @@ -957,17 +986,26 @@ fun ChatLayout( val composeViewFocusRequester = remember { if (appPlatform.isDesktop) FocusRequester() else null } AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) { if (chat != null) { + val selectionManager = LocalSelectionManager.current + if (selectionManager != null) { + LaunchedEffect(selectionManager) { + snapshotFlow { selectionManager.selectionState != SelectionState.Idle } + .collect { chatsCtx.chatState.selectionActive = it } + } + } Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) { // disables scrolling to top of chat item on click inside the bubble - CompositionLocalProvider(LocalBringIntoViewSpec provides object : BringIntoViewSpec { - override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f - }) { + CompositionLocalProvider( + LocalBringIntoViewSpec provides object : BringIntoViewSpec { + override fun calculateScrollDistance(offset: Float, size: Float, containerSize: Float): Float = 0f + } + ) { ChatItemsList( chatsCtx, remoteHostId, chat, unreadCount, composeState, composeViewHeight, searchValue, useLinkPreviews, linkMode, scrollToItemId, selectedChatItems, showMemberInfo, showChatInfo = info, loadMessages, deleteMessage, deleteMessages, archiveReports, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, - setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, + setReaction, showItemDetails, markItemsRead, markChatRead, closeSearch, remember { { onComposed(it) } }, developerTools, showViaProxy, contentFilter, ) } if (chatInfo is ChatInfo.Group && composeState.value.message.text.isNotEmpty()) { @@ -990,6 +1028,13 @@ fun ChatLayout( CommandsMenuView(chatsCtx, chat, composeState, showCommandsMenu) } } + // Copy button inside TopStart-aligned wrapper — above messages, + // behind compose (ABPL paints compose after) and toolbars (outer Box paints after ABPL) + if (appPlatform.isDesktop) { + Box(Modifier.matchParentSize()) { + SelectionCopyButton() + } + } } } if (chatsCtx.contentTag == MsgContentTag.Report) { @@ -1142,6 +1187,7 @@ fun BoxScope.ChatInfoToolbar( val scope = rememberCoroutineScope() val showMenu = rememberSaveable { mutableStateOf(false) } val showContentFilterMenu = rememberSaveable { mutableStateOf(false) } + val showCallMenu = rememberSaveable { mutableStateOf(false) } val onBackClicked = { if (!showSearch.value) { @@ -1160,37 +1206,11 @@ fun BoxScope.ChatInfoToolbar( val activeCall by remember { chatModel.activeCall } val showContentFilterButton = availableContent.value.isNotEmpty() - val activeCallInChat = chatInfo is ChatInfo.Direct && activeCall?.contact?.id == chatInfo.id - - // Content filter button - shown in bar, or moved to menu during active call - if (showContentFilterButton) { - val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready - if (activeCallInChat) { - menuItems.add { - ItemAction( - stringResource(MR.strings.content_filter_menu_item), - painterResource(MR.images.ic_photo_library), - onClick = { - showMenu.value = false - showContentFilterMenu.value = true - } - ) - } - } else { - barButtons.add { - IconButton( - { showContentFilterMenu.value = true }, - enabled = enabled - ) { - Icon( - painterResource(MR.images.ic_photo_library), - null, - tint = MaterialTheme.colors.primary - ) - } - } - } - } + val canStartCall = chatInfo is ChatInfo.Direct && + chatInfo.contact.mergedPreferences.calls.enabled.forUser && + chatInfo.contact.ready && + chatInfo.contact.active && + activeCall == null // Chat-type specific buttons when (chatInfo) { @@ -1242,19 +1262,12 @@ fun BoxScope.ChatInfoToolbar( } } } - // Call buttons moved to menu - if (chatInfo.contact.mergedPreferences.calls.enabled.forUser && chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) { - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { - showMenu.value = false - startCall(CallMediaType.Audio) - }) - } - menuItems.add { - ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { - showMenu.value = false - startCall(CallMediaType.Video) - }) + // Call button always in toolbar; tap opens Audio/Video call submenu + if (canStartCall) { + barButtons.add(0) { + IconButton({ showCallMenu.value = true }) { + Icon(painterResource(MR.images.ic_call_500), null, tint = MaterialTheme.colors.primary) + } } } menuItems.add { @@ -1267,7 +1280,7 @@ fun BoxScope.ChatInfoToolbar( is ChatInfo.Group -> { // Add members / group link moved to menu if (chatInfo.groupInfo.canAddMembers) { - if (!chatInfo.incognito) { + if (!chatInfo.incognito && !chatInfo.groupInfo.useRelays) { menuItems.add { ItemAction(stringResource(MR.strings.icon_descr_add_members), painterResource(MR.images.ic_person_add_500), onClick = { showMenu.value = false @@ -1276,10 +1289,14 @@ fun BoxScope.ChatInfoToolbar( } } else { menuItems.add { - ItemAction(stringResource(MR.strings.group_link), painterResource(MR.images.ic_add_link), onClick = { - showMenu.value = false - openGroupLink(chatInfo.groupInfo) - }) + ItemAction( + stringResource(if (chatInfo.groupInfo.useRelays) MR.strings.channel_link else MR.strings.group_link), + painterResource(if (chatInfo.groupInfo.useRelays) MR.images.ic_link else MR.images.ic_add_link), + onClick = { + showMenu.value = false + openGroupLink(chatInfo.groupInfo) + } + ) } } } @@ -1293,6 +1310,26 @@ fun BoxScope.ChatInfoToolbar( else -> {} } + // Content filter button: always in bar on desktop and for groups; on Android for direct chats it + // goes into the three-dots menu UNLESS calls are unavailable, in which case it appears in the bar. + // Must be after chat-type buttons so call buttons appear before filter during active call. + if (showContentFilterButton && (appPlatform.isDesktop || chatInfo is ChatInfo.Group || + (appPlatform.isAndroid && chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null))) { + val enabled = chatInfo !is ChatInfo.Local || chatInfo.noteFolder.ready + barButtons.add { + IconButton( + { showContentFilterMenu.value = true }, + enabled = enabled + ) { + Icon( + painterResource(MR.images.ic_photo_library), + null, + tint = MaterialTheme.colors.primary + ) + } + } + } + val enableNtfs = chatInfo.chatSettings?.enableNtfs if (((chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) || chatInfo is ChatInfo.Group) && enableNtfs != null) { val ntfMode = remember { mutableStateOf(enableNtfs) } @@ -1313,6 +1350,53 @@ fun BoxScope.ChatInfoToolbar( } } + // Android only: for direct/local chats where the filter bar button is NOT shown, filter options go in the three-dots menu separated by a divider + if (appPlatform.isAndroid && chatInfo !is ChatInfo.Group && showContentFilterButton && + !(chatInfo is ChatInfo.Direct && !canStartCall && activeCall == null)) { + menuItems.add { Divider() } + availableContent.value.forEach { filter -> + menuItems.add { + val isSelected = contentFilter.value == filter + ItemAction( + stringResource(filter.label), + painterResource(if (isSelected) filter.iconFilled else filter.icon), + color = if (isSelected) MaterialTheme.colors.primary else Color.Unspecified, + onClick = { + showMenu.value = false + if (contentFilter.value == filter) return@ItemAction + contentFilter.value = filter + showSearch.value = true + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, filter.contentTag, "") + } + } + } + ) + } + } + if (showSearch.value) { + menuItems.add { + ItemAction( + stringResource(MR.strings.content_filter_all_messages), + painterResource(MR.images.ic_forum), + onClick = { + showMenu.value = false + contentFilter.value = null + showSearch.value = false + scope.launch { + val c = chatModel.getChat(chatInfo.id) + if (c != null) { + apiFindMessages(chatsCtx, c, null, "") + } + } + } + ) + } + } + } + if (menuItems.isNotEmpty()) { barButtons.add { IconButton({ showMenu.value = true }) { @@ -1422,9 +1506,45 @@ fun BoxScope.ChatInfoToolbar( contentFilterMenuItems.forEach { it() } } } + val callMenuWidth = remember { mutableStateOf(250.dp) } + val callMenuHeight = remember { mutableStateOf(0.dp) } + DefaultDropdownMenu( + showCallMenu, + modifier = Modifier.onSizeChanged { with(density) { + callMenuWidth.value = it.width.toDp().coerceAtLeast(250.dp) + if (oneHandUI.value && chatBottomBar.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) callMenuHeight.value = it.height.toDp() + } }, + offset = DpOffset(-callMenuWidth.value, if (oneHandUI.value && chatBottomBar.value) -callMenuHeight.value else AppBarHeight) + ) { + if (chatInfo is ChatInfo.Direct) { + val callMenuItems: List<@Composable () -> Unit> = buildList { + add { + ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Audio) + }) + } + add { + ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { + showCallMenu.value = false + startCall(CallMediaType.Video) + }) + } + } + if (oneHandUI.value && chatBottomBar.value) { + callMenuItems.asReversed().forEach { it() } + } else { + callMenuItems.forEach { it() } + } + } + } } } +fun subscriberCountStr(count: Long): String = + if (count == 1L) String.format(generalGetString(MR.strings.channel_subscriber_count_singular), count) + else String.format(generalGetString(MR.strings.channel_subscriber_count_plural), count) + @Composable fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)) { Row( @@ -1454,6 +1574,17 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo maxLines = 1, overflow = TextOverflow.Ellipsis ) } + val channelSubscriberCount = (cInfo as? ChatInfo.Group)?.let { g -> + if (g.groupInfo.useRelays) g.groupInfo.groupSummary.publicMemberCount?.takeIf { it > 0 } else null + } + if (channelSubscriberCount != null) { + Text( + subscriberCountStr(channelSubscriberCount), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 1, overflow = TextOverflow.Ellipsis + ) + } } val chatSubStatus = chatModel.chatSubStatus.value if ( @@ -1626,7 +1757,8 @@ fun BoxScope.ChatItemsList( closeSearch: () -> Unit, onComposed: suspend (chatId: String) -> Unit, developerTools: Boolean, - showViaProxy: Boolean + showViaProxy: Boolean, + contentFilter: State = remember { mutableStateOf(null) } ) { val chatInfo = chat.chatInfo val loadingTopItems = remember { mutableStateOf(false) } @@ -1646,7 +1778,7 @@ fun BoxScope.ChatItemsList( } } val searchValueIsEmpty = remember { derivedStateOf { searchValue.value.isEmpty() } } - val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() } } + val searchValueIsNotBlank = remember { derivedStateOf { searchValue.value.isNotBlank() || contentFilter.value != null } } val revealedItems = rememberSaveable(stateSaver = serializableSaver()) { mutableStateOf(setOf()) } // not using reversedChatItems inside to prevent possible derivedState bug in Compose when one derived state access can cause crash asking another derived state val mergedItems = remember { @@ -1681,7 +1813,17 @@ fun BoxScope.ChatItemsList( val hoveredItemId = remember { mutableStateOf(null as Long?) } val listState = rememberUpdatedState(rememberSaveable(chatInfo.id, searchValueIsEmpty.value, resetListState.value, saver = LazyListState.Saver) { val openAroundItemId = chatModel.openAroundItemId.value - val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: mergedItems.value.items.indexOfLast { it.hasUnread() } + val index = mergedItems.value.indexInParentItems[openAroundItemId] ?: run { + // scroll to first unread after last viewed item (items reversed: 0 = newest) + val viewedIdx = mergedItems.value.items.indexOfFirst { !it.hasUnread() } + if (viewedIdx > 0) { + viewedIdx - 1 + } else if (viewedIdx < 0) { + mergedItems.value.items.indexOfLast { it.hasUnread() } + } else { + 0 // viewed is bottom item, scroll to bottom + } + } val reportsState = reportsListState if (openAroundItemId != null) { highlightedItems.value += openAroundItemId @@ -1724,7 +1866,7 @@ fun BoxScope.ChatItemsList( val chatInfoUpdated = rememberUpdatedState(chatInfo) val scope = rememberCoroutineScope() val scrollToItem: (Long) -> Unit = remember { - scrollToItem(searchValue, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) + scrollToItem(chatsCtx, remoteHostIdUpdated, searchValue, contentFilter, loadingMoreItems, animatedScrollingInProgress, highlightedItems, chatInfoUpdated, maxHeight, scope, reversedChatItems, mergedItems, listState, loadMessages) } val scrollToQuotedItemFromItem: (Long) -> Unit = remember { findQuotedItemFromItem(chatsCtx, remoteHostIdUpdated, chatInfoUpdated, scope, scrollToItem, scrollToItemId) } if (chatsCtx.secondaryContextFilter == null) { @@ -1777,7 +1919,7 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { @@ -1791,7 +1933,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset) } } @@ -1811,7 +1953,7 @@ fun BoxScope.ChatItemsList( } false } - val swipeableModifier = SwipeToDismissModifier( + val swipeableModifier = if (appPlatform.isDesktop) Modifier else SwipeToDismissModifier( state = dismissState, directions = setOf(DismissDirection.EndToStart), swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, @@ -1912,7 +2054,7 @@ fun BoxScope.ChatItemsList( MemberImage(member) } Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { - ChatItemViewShortHand(cItem, itemSeparation, range, false) + ChatItemViewShortHand(cItem, itemSeparation, range, false, dismissState.offset.value) } } } @@ -1926,6 +2068,89 @@ fun BoxScope.ChatItemsList( Item() } } + } else { + ChatItemBox { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + Row( + Modifier + .padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) + .then(swipeableOrSelectionModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) + } + } + } + } else if (cItem.chatDir is CIDirection.ChannelRcv) { + if (showAvatar) { + Column( + Modifier + .padding(top = 8.dp) + .padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false)) + .fillMaxWidth() + .then(swipeableModifier), + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.Start + ) { + @Composable + fun ChannelNameAndRole() { + Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) { + Text( + chatInfo.groupInfo.chatViewName, + Modifier + .padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF) + .weight(1f, false), + fontSize = 13.5.sp, + color = MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + val chatItemTail = remember { appPreferences.chatItemTail.state } + val style = shapeStyle(cItem, chatItemTail.value, itemSeparation.largeGap, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Text( + generalGetString(MR.strings.channel_role_label), + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } + + @Composable + fun Item() { + ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) { + androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier, cItem.id, selectedChatItems) + } + Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() }) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage( + MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, + chatInfo.groupInfo.image, + chatInfo.groupInfo.chatIconName, + backgroundColor = MaterialTheme.colors.background + ) + } + Box(modifier = Modifier.padding(top = 2.dp, start = 4.dp).chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)) { + ChatItemViewShortHand(cItem, itemSeparation, range, false) + } + } + } + } + if (cItem.content.showMemberName) { + DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) { + ChannelNameAndRole() + Item() + } + } else { + Item() + } + } } else { ChatItemBox { AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { @@ -1952,7 +2177,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (selectionVisible) Modifier else swipeableModifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -1970,7 +2195,7 @@ fun BoxScope.ChatItemsList( .chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value) .then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier) ) { - ChatItemViewShortHand(cItem, itemSeparation, range) + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value) } } } @@ -2014,13 +2239,14 @@ fun BoxScope.ChatItemsList( val groupInfo = chatInfo.groupInfo when (groupInfo.businessChat?.chatType) { null -> { + val isChannel = groupInfo.useRelays if (groupInfo.nextConnectPrepared) { - generalGetString(MR.strings.chat_banner_join_group) + generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) } else { when (groupInfo.membership.memberStatus) { - GroupMemberStatus.MemInvited -> generalGetString(MR.strings.chat_banner_join_group) - GroupMemberStatus.MemCreator -> generalGetString(MR.strings.chat_banner_your_group) - else -> generalGetString(MR.strings.chat_banner_group) + GroupMemberStatus.MemInvited -> generalGetString(if (isChannel) MR.strings.chat_banner_join_channel else MR.strings.chat_banner_join_group) + GroupMemberStatus.MemCreator -> generalGetString(if (isChannel) MR.strings.chat_banner_your_channel else MR.strings.chat_banner_your_group) + else -> generalGetString(if (isChannel) MR.strings.chat_banner_channel else MR.strings.chat_banner_group) } } } @@ -2112,8 +2338,11 @@ fun BoxScope.ChatItemsList( } } + val manager = LocalSelectionManager.current + val modifier = if (appPlatform.isDesktop && manager != null) SelectionHandler(manager, listState, mergedItems, linkMode) else Modifier + LazyColumnWithScrollBar( - Modifier.align(Alignment.BottomCenter), + modifier.align(Alignment.BottomCenter), state = listState.value, contentPadding = PaddingValues( top = topPaddingToContent, @@ -2167,8 +2396,10 @@ fun BoxScope.ChatItemsList( itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { - if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) + } } if (last != null) { @@ -2864,7 +3095,10 @@ private fun lastFullyVisibleIemInListState(topPaddingToContentPx: State, de } private fun scrollToItem( + chatsCtx: ChatModel.ChatsContext, + remoteHostId: State, searchValue: State, + contentFilter: State, loadingMoreItems: MutableState, animatedScrollingInProgress: MutableState, highlightedItems: MutableState>, @@ -2879,8 +3113,13 @@ private fun scrollToItem( withApi { try { var index = mergedItems.value.indexInParentItems[itemId] ?: -1 - // Don't try to load messages while in search - if (index == -1 && searchValue.value.isNotBlank()) return@withApi + if (index == -1 && (searchValue.value.isNotBlank() || contentFilter.value != null)) { + val ci = chatInfo.value + apiLoadMessages(chatsCtx, remoteHostId.value, ci.chatType, ci.apiId, + ChatPagination.Around(itemId, ChatPagination.PRELOAD_COUNT * 2), + openAroundItemId = itemId) + return@withApi + } // setting it to 'loading' even if the item is loaded because in rare cases when the resulting item is near the top, scrolling to // it will trigger loading more items and will scroll to incorrect position (because of trimming) loadingMoreItems.value = true @@ -2965,7 +3204,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() ModalManager.end.showModalCloseable(true) { - GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null) + GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays) } } } @@ -3474,6 +3713,8 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa val sameMemberAndDirection = if (prevItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == prevItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && prevItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == prevItem.chatDir.sent val largeGap = !sameMemberAndDirection || (abs(prevItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) @@ -3491,12 +3732,26 @@ private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) { chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId + } else if (chatItem.chatDir is CIDirection.ChannelRcv && nextItem.chatDir is CIDirection.ChannelRcv) { + true } else chatItem.chatDir.sent == nextItem.chatDir.sent return !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60) } -private fun shouldShowAvatar(current: ChatItem, older: ChatItem?) = - current.chatDir is CIDirection.GroupRcv && (older == null || (older.chatDir !is CIDirection.GroupRcv || older.chatDir.groupMember.memberId != current.chatDir.groupMember.memberId)) +private fun shouldShowAvatar(current: ChatItem, older: ChatItem?): Boolean { + val oldIsGroupRcv = older?.chatDir is CIDirection.GroupRcv || older?.chatDir is CIDirection.ChannelRcv + val sameMember = when { + older?.chatDir is CIDirection.GroupRcv && current.chatDir is CIDirection.GroupRcv -> + older.chatDir.groupMember.memberId == current.chatDir.groupMember.memberId + older?.chatDir is CIDirection.ChannelRcv && current.chatDir is CIDirection.ChannelRcv -> true + else -> false + } + return when { + current.chatDir is CIDirection.GroupRcv -> older == null || !oldIsGroupRcv || !sameMember + current.chatDir is CIDirection.ChannelRcv -> older == null || !oldIsGroupRcv || !sameMember + else -> false + } +} @Preview/*( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index af4baad90f..10f426b152 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -30,8 +30,13 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.common.views.chat.group.relayConnStatus import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.newchat.RelayProgressIndicator +import chat.simplex.common.views.newchat.RelayStatusIndicator +import chat.simplex.common.views.newchat.relayDisplayName import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import kotlinx.coroutines.* @@ -47,6 +52,7 @@ import kotlin.math.min const val MAX_NUMBER_OF_MENTIONS = 3 +// Spec: spec/client/compose.md#ComposePreview @Serializable sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @@ -92,6 +98,7 @@ object ComposeMessageSerializer : KSerializer { decoder.decodeLong().let { value -> TextRange(unpackInt1(value), unpackInt2(value)) } } +// Spec: spec/client/compose.md#ComposeState @Serializable data class ComposeState( val message: ComposeMessage = ComposeMessage(), @@ -259,6 +266,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { } } +// Spec: spec/client/compose.md#AttachmentSelection @Composable expect fun AttachmentSelection( composeState: MutableState, @@ -341,6 +349,7 @@ suspend fun MutableState.processPickedMedia(uris: List, text: } } +// Spec: spec/client/compose.md#ComposeView @Composable fun ComposeView( rhId: Long?, @@ -486,6 +495,7 @@ fun ComposeView( type = cInfo.chatType, id = cInfo.apiId, scope = cInfo.groupChatScope(), + sendAsGroup = (cInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, live = live, ttl = ttl, composedMessages = listOf(ComposedMessage(file, quoted, mc, mentions)) @@ -584,15 +594,19 @@ fun ComposeView( val mc = checkLinkPreview() sending() val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get() - val groupInfo = chatModel.controller.apiConnectPreparedGroup( + val result = chatModel.controller.apiConnectPreparedGroup( rh = chat.remoteHostId, groupId = chat.chatInfo.apiId, incognito = incognito, msg = mc ) - if (groupInfo != null) { + if (result != null) { + val (groupInfo, relayResults) = result withContext(Dispatchers.Main) { chatsCtx.updateGroup(chat.remoteHostId, groupInfo) + chatModel.channelRelayHostnames.remove(groupInfo.groupId) + chatModel.groupMembers.value = relayResults.map { it.relayMember } + chatModel.populateGroupMembersIndexes() clearState() } } else { @@ -612,6 +626,7 @@ fun ComposeView( toChatType = chat.chatInfo.chatType, toChatId = chat.chatInfo.apiId, toScope = chat.chatInfo.groupChatScope(), + sendAsGroup = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { it.useRelays && it.membership.memberRole >= GroupMemberRole.Owner } ?: false, fromChatType = fromChatInfo.chatType, fromChatId = fromChatInfo.apiId, fromScope = fromChatInfo.groupChatScope(), @@ -1349,7 +1364,7 @@ fun ComposeView( icon: ImageResource, connect: () -> Unit ) { - var modifier = Modifier.height(60.dp).fillMaxWidth() + var modifier = Modifier.height(57.dp).fillMaxWidth() modifier = if (composeState.value.inProgress) modifier else modifier.clickable(onClick = { connect() }) Box( modifier, @@ -1370,7 +1385,7 @@ fun ComposeView( color = if (composeState.value.inProgress) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - if (composeState.value.progressByTimeout) { + if (composeState.value.progressByTimeout && chat.chatInfo.groupInfo_?.useRelays != true) { Box( Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING_HALF), contentAlignment = Alignment.CenterEnd @@ -1438,9 +1453,11 @@ fun ComposeView( composeState.value = composeState.value.copy(progressByTimeout = newProgressByTimeout) } + val relayListExpanded = remember { mutableStateOf(false) } + Column { val currentUser = chatModel.currentUser.value - if (chat.chatInfo.nextConnectPrepared && currentUser != null) { + if (chat.chatInfo.nextConnectPrepared && !composeState.value.inProgress && currentUser != null) { ComposeContextProfilePickerView( rhId = rhId, chat = chat, @@ -1448,6 +1465,35 @@ fun ComposeView( ) } + val gInfo = (chat.chatInfo as? ChatInfo.Group)?.groupInfo + if (gInfo != null && gInfo.useRelays + && gInfo.membership.memberStatus !in listOf(GroupMemberStatus.MemRejected, GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) + ) { + if (gInfo.membership.memberRole == GroupMemberRole.Owner) { + val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList() + val failedCount = relays.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = relays.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + if (relays.isNotEmpty() && activeCount < relays.size) { + OwnerChannelRelayBar(chatModel, relays, activeCount, failedCount, relayListExpanded) + } + } else { + val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted() + val relayMembers = chatModel.groupMembers.value + .filter { it.memberRole == GroupMemberRole.Relay } + .sortedBy { hostFromRelayLink(it.relayLink ?: "") } + val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress + val connectedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Ready } + val deletedCount = relayMembers.count { it.activeConn?.connStatus == ConnStatus.Deleted } + val failedCount = relayMembers.count { it.activeConn?.connFailedErr != null } + val errorCount = deletedCount + failedCount + val resolvedCount = connectedCount + deletedCount + val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size + if (total > 0 && (!showProgress || resolvedCount < total)) { + SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, errorCount, total, showProgress, relayListExpanded) + } + } + } + if ( chat.chatInfo is ChatInfo.Group && chatsCtx.secondaryContextFilter is SecondaryContextFilter.GroupChatScopeContext @@ -1502,9 +1548,10 @@ fun ComposeView( Divider() if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) { if (chat.chatInfo.groupInfo.businessChat == null) { + val isChannel = chat.chatInfo.groupInfo.useRelays ConnectButtonView( - text = stringResource(MR.strings.compose_view_join_group), - icon = MR.images.ic_group_filled, + text = stringResource(if (isChannel) MR.strings.compose_view_join_channel else MR.strings.compose_view_join_group), + icon = if (isChannel) MR.images.ic_bigtop_updates else MR.images.ic_group_filled, connect = { withApi { connectPreparedGroup() } } ) } else { @@ -1575,9 +1622,187 @@ fun ComposeView( } else { Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) { AttachmentAndCommandsButtons() - SendMsgView_(disableSendButton = disableSendButton) + val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi -> + if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner) generalGetString(MR.strings.compose_view_broadcast) + else null + } + SendMsgView_(disableSendButton = disableSendButton, placeholder = broadcastPlaceholder) } } } } } + +@Composable +private fun OwnerChannelRelayBar( + chatModel: ChatModel, + relays: List, + activeCount: Int, + failedCount: Int, + relayListExpanded: MutableState +) { + val total = relays.size + val sorted = relays.sortedBy { relayDisplayName(it) } + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + sorted.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + relayDisplayName(relay), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + RelayStatusIndicator(relay.relayStatus, connFailed = failedErr != null) + } + } + } + } +} + +@Composable +private fun SubscriberChannelRelayBar( + hostnames: List, + relayMembers: List, + connectedCount: Int, + errorCount: Int, + total: Int, + showProgress: Boolean, + relayListExpanded: MutableState +) { + Column(Modifier.background(MaterialTheme.colors.surface)) { + RelayBarHeader(relayListExpanded) { + if (showProgress && connectedCount + errorCount < total) { + RelayProgressIndicator(active = connectedCount, total = total) + } + val statusText = if (showProgress) { + if (errorCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_connected_with_errors), connectedCount, total, errorCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_connected), connectedCount, total) + } + } else { + String.format(generalGetString(MR.strings.relay_bar_count), total) + } + Text(statusText, modifier = Modifier.weight(1f), color = MaterialTheme.colors.secondary) + } + if (relayListExpanded.value) { + if (relayMembers.isEmpty()) { + hostnames.forEach { relay -> + RelayBarDetailRow { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relay)), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + } + } + } else { + relayMembers.forEach { m -> + val host = m.relayLink?.let { hostFromRelayLink(it) } + val failedErr = m.activeConn?.connFailedErr + RelayBarDetailRow( + onClick = if (failedErr != null) { + { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + } + } else null + ) { + Text( + String.format(generalGetString(MR.strings.via_relay_hostname), host ?: m.chatViewName), + color = MaterialTheme.colors.secondary, + fontSize = 12.sp + ) + Spacer(Modifier.weight(1f)) + val (statusText, statusColor) = relayConnStatus(m) + androidx.compose.foundation.Canvas(Modifier.size(8.dp)) { + drawCircle(color = statusColor) + } + Spacer(Modifier.width(4.dp)) + Text(statusText, color = MaterialTheme.colors.secondary, fontSize = 12.sp) + if (failedErr != null) { + Spacer(Modifier.width(4.dp)) + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } + } + } + } + } +} + +@Composable +private fun RelayBarHeader( + expanded: MutableState, + content: @Composable RowScope.() -> Unit +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(start = 12.dp, end = DEFAULT_PADDING_HALF, top = 8.dp, bottom = if (expanded.value) 4.dp else 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + content() + Icon( + painterResource(if (expanded.value) MR.images.ic_chevron_down else MR.images.ic_chevron_up), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } +} + +@Composable +private fun RelayBarDetailRow( + onClick: (() -> Unit)? = null, + content: @Composable RowScope.() -> Unit +) { + val modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 2.dp) + Row( + modifier = if (onClick != null) modifier.clickable(onClick = onClick) else modifier, + verticalAlignment = Alignment.CenterVertically + ) { + content() + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 467f1e52af..9184071c07 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -24,13 +24,13 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.model.ChatItem import chat.simplex.common.platform.* -import chat.simplex.common.views.usersettings.showInDevelopingAlert import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.painterResource import kotlinx.coroutines.* import java.net.URI +// Spec: spec/client/compose.md#SendMsgView @Composable fun SendMsgView( composeState: MutableState, @@ -312,21 +312,19 @@ private fun RecordVoiceView(recState: MutableState, stopRecOnNex LockToCurrentOrientationUntilDispose() StopRecordButton(stopRecordingAndAddAudio) } else { - val startRecording: () -> Unit = out@ { - if (appPlatform.isDesktop) { - return@out showInDevelopingAlert() + val startRecording: () -> Unit = { + val filePath = rec.start { progress: Int?, finished: Boolean -> + val state = recState.value + if (state is RecordingState.Started && progress != null) { + recState.value = if (!finished) + RecordingState.Started(state.filePath, progress) + else + RecordingState.Finished(state.filePath, progress) + } + } + if (filePath.isNotEmpty()) { + recState.value = RecordingState.Started(filePath = filePath) } - recState.value = RecordingState.Started( - filePath = rec.start { progress: Int?, finished: Boolean -> - val state = recState.value - if (state is RecordingState.Started && progress != null) { - recState.value = if (!finished) - RecordingState.Started(state.filePath, progress) - else - RecordingState.Finished(state.filePath, progress) - } - }, - ) } val interactionSource = interactionSourceWithTapDetection( onPress = { if (recState.value is RecordingState.NotStarted) startRecording() }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt new file mode 100644 index 0000000000..4447bf9da2 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt @@ -0,0 +1,520 @@ +package chat.simplex.common.views.chat + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clip +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.* +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.item.itemSegmentDisplayText +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +val SelectionHighlightColor = Color(0x4D0066FF) + +data class ItemContext( + val selectionIndex: Int = -1 +) + +val LocalItemContext = compositionLocalOf { ItemContext() } + +data class SelectionRange( + val startIndex: Int, + val startOffset: Int, + val endIndex: Int, + val endOffset: Int +) + +enum class SelectionState { Idle, Selecting, Selected } + +class SelectionManager { + var selectionState by mutableStateOf(SelectionState.Idle) + private set + + var range by mutableStateOf(null) + private set + + var anchorWindowY by mutableStateOf(0f) + private set + var anchorWindowX by mutableStateOf(0f) + private set + var focusWindowY by mutableStateOf(0f) + var focusWindowX by mutableStateOf(0f) + var viewportWidth by mutableStateOf(0f) + var viewportHeight by mutableStateOf(0f) + var viewportTop by mutableStateOf(0f) + var viewportBottom by mutableStateOf(0f) + var viewportPosition by mutableStateOf(Offset.Zero) + var focusCharRect by mutableStateOf(Rect.Zero) // X: absolute window, Y: relative to item + var listState: State? = null + var onCopySelection: (() -> Unit)? = null + private var autoScrollJob: Job? = null + + fun startSelection(startIndex: Int, anchorY: Float, anchorX: Float) { + range = SelectionRange(startIndex, -1, startIndex, -1) + selectionState = SelectionState.Selecting + anchorWindowY = anchorY + anchorWindowX = anchorX + } + + fun setAnchorOffset(offset: Int) { + val r = range ?: return + range = r.copy(startOffset = offset) + } + + fun updateFocusIndex(index: Int) { + val r = range ?: return + range = r.copy(endIndex = index) + } + + fun updateFocusOffset(offset: Int, charRect: Rect = Rect.Zero) { + val r = range ?: return + range = r.copy(endOffset = offset) + focusCharRect = charRect + } + + fun endSelection() { + autoScrollJob?.cancel() + autoScrollJob = null + selectionState = SelectionState.Selected + } + + // Snaps boundary offsets to include full transformed segments (mentions, links with showText). + fun snapSelection(items: List, linkMode: SimplexLinkMode) { + val r = range ?: return + val startCi = items.getOrNull(r.startIndex)?.newest()?.item + val endCi = items.getOrNull(r.endIndex)?.newest()?.item + // expandRight: snap in the direction that grows the selection + val startExpandRight = if (r.startIndex == r.endIndex) r.startOffset > r.endOffset else r.startIndex < r.endIndex + val endExpandRight = if (r.startIndex == r.endIndex) r.endOffset > r.startOffset else r.endIndex < r.startIndex + val snappedStart = if (startCi != null && r.startOffset >= 0) + snapOffset(startCi, r.startOffset, linkMode, expandRight = startExpandRight) + else r.startOffset + val snappedEnd = if (endCi != null && r.endOffset >= 0) + snapOffset(endCi, r.endOffset, linkMode, expandRight = endExpandRight) + else r.endOffset + if (snappedStart != r.startOffset || snappedEnd != r.endOffset) { + range = r.copy(startOffset = snappedStart, endOffset = snappedEnd) + } + } + + fun clearSelection() { + range = null + selectionState = SelectionState.Idle + } + + // Computes copy button position relative to the viewport (called during layout phase). + // Dragging down: button below focus char (top-left at char's bottom-right corner). + // Dragging up: button above focus char (bottom-right at char's top-left corner). + // focusCharRect X is absolute window coords, Y is relative to item. + fun copyButtonOffset(draggingDown: Boolean, gap: Float, buttonSize: IntSize): IntOffset { + val r = range ?: return IntOffset.Zero + val ls = listState?.value ?: return IntOffset.Zero + val itemInfo = ls.layoutInfo.visibleItemsInfo.find { it.index == r.endIndex } + ?: return IntOffset(-10000, -10000) // focus item scrolled off screen + // Item top in viewport coords (reversed layout: viewportEnd - offset - size) + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + val cr = focusCharRect + val vp = viewportPosition + // Convert from window coords to viewport-relative + val charX = (if (draggingDown) cr.right else cr.left) - vp.x + val charY = itemWindowY + (if (draggingDown) cr.bottom else cr.top) - vp.y + // Anchor button corner at char corner with gap + val x = if (draggingDown) charX else (charX - buttonSize.width).coerceAtLeast(0f) + val y = if (draggingDown) charY + gap else charY - buttonSize.height - gap + val clampedX = x.coerceIn(0f, (viewportWidth - buttonSize.width).coerceAtLeast(0f)) + return IntOffset(clampedX.toInt(), y.toInt()) + } + + fun startDragSelection(localStart: Offset, windowStart: Offset, focusRequester: FocusRequester) { + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localStart.y) ?: return + startSelection(idx, windowStart.y, windowStart.x) + focusWindowY = windowStart.y + focusWindowX = windowStart.x + try { focusRequester.requestFocus() } catch (_: Exception) {} + } + + fun updateDragFocus(windowPos: Offset, localY: Float) { + focusWindowY = windowPos.y + focusWindowX = windowPos.x + val ls = listState?.value ?: return + val idx = resolveIndexAtY(ls, localY) ?: return + updateFocusIndex(idx) + } + + fun updateAutoScroll(draggingDown: Boolean, pointerY: Float, scope: CoroutineScope) { + val edgeDistance = if (draggingDown) viewportBottom - pointerY else pointerY - viewportTop + if (edgeDistance !in 0f..AUTO_SCROLL_ZONE_PX) { + autoScrollJob?.cancel() + autoScrollJob = null + return + } + if (autoScrollJob?.isActive == true) return + val ls = listState ?: return + autoScrollJob = scope.launch { + while (isActive && selectionState == SelectionState.Selecting) { + val curEdge = if (draggingDown) viewportBottom - focusWindowY else focusWindowY - viewportTop + if (curEdge >= AUTO_SCROLL_ZONE_PX) break + val fraction = 1f - (curEdge / AUTO_SCROLL_ZONE_PX).coerceIn(0f, 1f) + val speed = MIN_SCROLL_SPEED + (MAX_SCROLL_SPEED - MIN_SCROLL_SPEED) * fraction + ls.value.scrollBy(if (draggingDown) -speed else speed) + delay(16) + } + } + } + + fun getSelectedCopiedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val sel = selectedRange(range, idx) ?: return@mapNotNull null + selectedItemCopiedText(ci, sel, linkMode) + }.reversed().joinToString("\n") + } +} + +// Returns the character range selected within a given item. +// Offsets are cursor positions (between characters), so the selected characters +// are those between min and max cursors: range is min..(max - 1). +// In reversed layout: higher index = higher on screen. +// startIndex/startOffset = anchor, endIndex/endOffset = focus. +fun selectedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + return when { + // Single-item selection: characters between the two cursor positions + index == r.startIndex && index == r.endIndex -> + if (r.startOffset < 0 || r.endOffset < 0 || r.startOffset == r.endOffset) null + else minOf(r.startOffset, r.endOffset) .. (maxOf(r.startOffset, r.endOffset) - 1) + // Anchor item in multi-item selection: from cursor to end, or from start to cursor + index == r.startIndex -> + if (r.startOffset < 0) null + else if (r.startIndex > r.endIndex) r.startOffset until Int.MAX_VALUE + else 0 until r.startOffset + // Focus item in multi-item selection: symmetric to anchor + index == r.endIndex -> + if (r.endOffset < 0) null + else if (r.endIndex < r.startIndex) 0 until r.endOffset + else r.endOffset until Int.MAX_VALUE + // Interior items: fully selected + else -> 0 until Int.MAX_VALUE + } +} + +// Extracts source text for the selected range within one item. +// Selection offsets are in display-text space. For transformed segments (mentions, links with showText), +// the full source is emitted if any part is selected. For untransformed segments, partial substring works. +private fun selectedItemCopiedText(ci: ChatItem, sel: IntRange, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text.substring( + sel.first.coerceAtMost(ci.text.length), + (sel.last + 1).coerceAtMost(ci.text.length) + ) + val sb = StringBuilder() + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + val overlapStart = maxOf(displayOffset, sel.first) + val overlapEnd = minOf(displayEnd, sel.last + 1) + if (overlapStart < overlapEnd) { + if (ft.text.length == segDisplay.length) { + sb.append(ft.text, overlapStart - displayOffset, overlapEnd - displayOffset) + } else { + sb.append(ft.text) + } + } + displayOffset = displayEnd + } + return sb.toString() +} + +// Snaps a boundary offset to include full transformed segments. +private fun snapOffset(ci: ChatItem, offset: Int, linkMode: SimplexLinkMode, expandRight: Boolean): Int { + val formattedText = ci.formattedText ?: return offset + var displayOffset = 0 + for (ft in formattedText) { + val segDisplay = itemSegmentDisplayText(ft, ci, linkMode) + val displayEnd = displayOffset + segDisplay.length + if (offset > displayOffset && offset < displayEnd && ft.text.length != segDisplay.length) { + return if (expandRight) displayEnd else displayOffset + } + displayOffset = displayEnd + } + return offset +} + +val LocalSelectionManager = staticCompositionLocalOf { null } + +private const val AUTO_SCROLL_ZONE_PX = 40f +private const val MIN_SCROLL_SPEED = 2f +private const val MAX_SCROLL_SPEED = 20f + +@Composable +fun BoxScope.SelectionHandler( + manager: SelectionManager, + listState: State, + mergedItems: State, + linkMode: SimplexLinkMode +): Modifier { + val touchSlop = LocalViewConfiguration.current.touchSlop + val clipboard = LocalClipboardManager.current + val focusRequester = remember { FocusRequester() } + val scope = rememberCoroutineScope() + + // Re-evaluate focus index on scroll during active drag + LaunchedEffect(manager) { + snapshotFlow { listState.value.firstVisibleItemScrollOffset } + .collect { + if (manager.selectionState == SelectionState.Selecting) { + val idx = resolveIndexAtY(listState.value, manager.focusWindowY - manager.viewportPosition.y) + if (idx != null) manager.updateFocusIndex(idx) + } + } + } + + manager.listState = listState + manager.onCopySelection = { + clipboard.setText(AnnotatedString(manager.getSelectedCopiedText(mergedItems.value.items, linkMode))) + manager.clearSelection() + showToast(generalGetString(MR.strings.copied)) + } + + return Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + if (manager.selectionState == SelectionState.Selected + && (event.isCtrlPressed || event.isMetaPressed) + && event.key == Key.C + && event.type == KeyEventType.KeyDown + ) { + manager.onCopySelection?.invoke() + true + } else false + } + .onGloballyPositioned { + val pos = it.positionInWindow() + val bounds = it.boundsInWindow() + manager.viewportTop = bounds.top + manager.viewportBottom = bounds.bottom + manager.viewportWidth = bounds.right - bounds.left + manager.viewportHeight = bounds.bottom - bounds.top + manager.viewportPosition = pos + } + .pointerInput(manager) { + awaitEachGesture { + var initialEvent: PointerInputChange + // Wait for press, skip hovers + do { initialEvent = awaitPointerEvent(PointerEventPass.Initial).changes.first() } while (!initialEvent.pressed) + val localStart = initialEvent.position + val windowStart = localStart + manager.viewportPosition + if (manager.selectionState == SelectionState.Selected) initialEvent.consume() + var totalDrag = Offset.Zero + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial).changes.first() + when (manager.selectionState) { + SelectionState.Idle -> { + if (!event.pressed) return@awaitEachGesture + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + event.consume() + } + } + SelectionState.Selected -> { + if (!event.pressed) { + manager.clearSelection() + return@awaitEachGesture + } + event.consume() + totalDrag += event.positionChange() + if (totalDrag.getDistance() > touchSlop) { + manager.startDragSelection(localStart, windowStart, focusRequester) + } + } + SelectionState.Selecting -> { + if (!event.pressed) { + manager.endSelection() + manager.snapSelection(mergedItems.value.items, linkMode) + return@awaitEachGesture + } + val windowPos = event.position + manager.viewportPosition + manager.updateDragFocus(windowPos, event.position.y) + event.consume() + manager.updateAutoScroll(windowPos.y > windowStart.y, windowPos.y, scope) + } + } + } + } + } +} + +private fun resolveIndexAtY(listState: LazyListState, localY: Float): Int? { + val reversedY = listState.layoutInfo.viewportEndOffset - localY + val idx = listState.layoutInfo.visibleItemsInfo.find { item -> + reversedY >= item.offset && reversedY < item.offset + item.size + }?.index + return idx +} + +class ItemSelection( + val highlightRange: IntRange?, + val positionModifier: Modifier, + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? +) + +// Sets up selection tracking for a text item: anchor/focus offset resolution, +// highlight range computation, and position/layout result capture. +@Composable +fun setupItemSelection(selectionManager: SelectionManager?, selectionIndex: Int, isLive: Boolean): ItemSelection { + val boundsState = remember { mutableStateOf(null) } + val layoutResultState = remember { mutableStateOf(null) } + + if (selectionManager != null && selectionIndex >= 0 && !isLive) { + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(selectionManager.anchorWindowX - bounds.left, selectionManager.anchorWindowY - bounds.top) + ) + selectionManager.setAnchorOffset(offset) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY to selectionManager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + val charBox = layout.getBoundingBox(offset.coerceIn(0, layout.layoutInput.text.length - 1)) + val ls = selectionManager.listState?.value + val itemInfo = ls?.layoutInfo?.visibleItemsInfo?.find { it.index == selectionIndex } + val charRect = if (ls != null && itemInfo != null) { + val itemWindowY = (ls.layoutInfo.viewportEndOffset - itemInfo.offset - itemInfo.size).toFloat() + Rect( + left = bounds.left + charBox.left, + top = bounds.top + charBox.top - itemWindowY, + right = bounds.left + charBox.right, + bottom = bounds.top + charBox.bottom - itemWindowY + ) + } else Rect.Zero + selectionManager.updateFocusOffset(offset, charRect) + } + } + } + } + + val highlightRange = if (selectionManager != null && selectionIndex >= 0) { + remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) } }.value + } else null + + val positionModifier = if (selectionManager != null) { + Modifier.onGloballyPositioned { + val pos = it.positionInWindow() + boundsState.value = Rect(pos.x, pos.y, pos.x + it.size.width, pos.y + it.size.height) + } + } else Modifier + + val onTextLayoutResult: ((TextLayoutResult) -> Unit)? = if (selectionManager != null) { + { layoutResultState.value = it } + } else null + + return ItemSelection(highlightRange, positionModifier, onTextLayoutResult) +} + +// Sets up full-item selection for emoji items (no character-level tracking). +@Composable +fun setupEmojiSelection(selectionManager: SelectionManager?, selectionIndex: Int, textLength: Int): Boolean { + if (selectionManager == null || selectionIndex < 0) return false + + val isAnchor = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.startIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + selectionManager.setAnchorOffset(0) + } + + val isFocus = remember(selectionIndex) { + derivedStateOf { selectionManager.range?.endIndex == selectionIndex && selectionManager.selectionState == SelectionState.Selecting } + } + if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { selectionManager.focusWindowY } + .collect { selectionManager.updateFocusOffset(textLength) } + } + } + + return remember(selectionIndex) { derivedStateOf { selectedRange(selectionManager.range, selectionIndex) != null } }.value +} + +@Composable +fun SelectionCopyButton() { + val manager = LocalSelectionManager.current ?: return + val range = manager.range ?: return + if (manager.selectionState != SelectionState.Selected || manager.focusCharRect == Rect.Zero) return + val draggingDown = range.startIndex > range.endIndex || (range.startIndex == range.endIndex && range.startOffset < range.endOffset) + val gap = with(LocalDensity.current) { 4.dp.toPx() } + var buttonSize by remember { mutableStateOf(IntSize.Zero) } + Row( + Modifier + .offset { manager.copyButtonOffset(draggingDown, gap, buttonSize) } + .onSizeChanged { buttonSize = it } + .background(MaterialTheme.colors.surface, RoundedCornerShape(20.dp)) + .border(1.dp, MaterialTheme.colors.onSurface.copy(alpha = 0.12f), RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(20.dp)) + .clickable { manager.onCopySelection?.invoke() } + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_content_copy), null, Modifier.size(16.dp), tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(6.dp)) + Text(generalGetString(MR.strings.copy_verb), color = MaterialTheme.colors.primary) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt new file mode 100644 index 0000000000..0cf3a3c96f --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelMembersView.kt @@ -0,0 +1,119 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.subscriberCountStr +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelMembersView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember) -> Unit +) { + BackHandler(onBack = close) + val members = remember { chatModel.groupMembers }.value + .filter { m -> + m.memberStatus != GroupMemberStatus.MemLeft + && m.memberStatus != GroupMemberStatus.MemRemoved + && m.memberRole != GroupMemberRole.Relay + } + + ColumnWithScrollBar { + val title = if (groupInfo.isOwner) { + generalGetString(MR.strings.channel_members_title_subscribers) + } else { + generalGetString(MR.strings.channel_members_section_owners) + } + AppBarTitle(title) + + if (groupInfo.isOwner) { + val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() + SectionView(title = subscriberCountStr(subscriberCount).uppercase()) { + SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + ChannelMemberRow(groupInfo.membership, user = true, showRole = true) + } + members.forEachIndexed { index, member -> + Divider() + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner) + } + } + } + } else { + val owners = members.filter { it.memberRole >= GroupMemberRole.Owner } + SectionView(title = generalGetString(MR.strings.channel_members_section_owners)) { + owners.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + ChannelMemberRow(member, user = false, showRole = false) + } + } + } + } + SectionBottomSpacer() + } +} + +@Composable +private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (member.verified) { + MemberVerifiedShield() + } + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = if (member.memberIncognito) Indigo else Color.Unspecified + ) + } + if (user) { + Text( + generalGetString(MR.strings.channel_member_you), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary + ) + } + } + if (showRole) { + Text( + member.memberRole.text, + color = MaterialTheme.colors.secondary + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt new file mode 100644 index 0000000000..e8f2a36fff --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -0,0 +1,167 @@ +package chat.simplex.common.views.chat.group + +import SectionBottomSpacer +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.setGroupMembers +import chat.simplex.common.views.helpers.* +import chat.simplex.res.MR + +@Composable +fun ChannelRelaysView( + rhId: Long?, + groupInfo: GroupInfo, + chatModel: ChatModel, + close: () -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + BackHandler(onBack = close) + var groupRelays by remember { mutableStateOf>(emptyList()) } + + LaunchedEffect(Unit) { + setGroupMembers(rhId, groupInfo, chatModel) + if (groupInfo.isOwner) { + groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId) + } + } + + ChannelRelaysLayout( + groupInfo = groupInfo, + chatModel = chatModel, + groupRelays = groupRelays, + showMemberInfo = showMemberInfo + ) +} + +@Composable +private fun ChannelRelaysLayout( + groupInfo: GroupInfo, + chatModel: ChatModel, + groupRelays: List, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit +) { + val relayMembers = remember { chatModel.groupMembers }.value + .filter { it.memberRole == GroupMemberRole.Relay } + + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.channel_relays_title)) + + if (relayMembers.isEmpty()) { + SectionView { + SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { + Text( + generalGetString(MR.strings.no_chat_relays), + color = MaterialTheme.colors.secondary + ) + } + } + } else { + SectionView { + relayMembers.forEachIndexed { index, member -> + if (index > 0) { + Divider() + } + SectionItemView( + click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + val statusText = if (groupInfo.isOwner) { + ownerRelayStatusText(member, groupRelays) + } else { + subscriberRelayStatusText(member) + } + RelayMemberRow(member, statusText) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages)) + } + SectionBottomSpacer() + } +} + +@Composable +private fun RelayMemberRow(member: GroupMember, statusText: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + MemberProfileImage(size = 38.dp, member) + Spacer(Modifier.width(2.dp)) + Column(Modifier.weight(1f)) { + Text( + member.chatViewName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colors.onBackground + ) + Text( + statusText, + maxLines = 1, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + } + } +} + +private fun subscriberRelayStatusText(member: GroupMember): String { + return if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + relayConnStatus(member).first + } +} + +private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.relay_conn_status_failed) + } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { + generalGetString(MR.strings.member_info_member_inactive) + } else { + groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus?.text + ?: relayConnStatus(member).first + } +} + +fun relayConnStatus(member: GroupMember): Pair { + return when (member.activeConn?.connStatus) { + is ConnStatus.Ready -> generalGetString(MR.strings.relay_conn_status_connected) to Color.Green + is ConnStatus.Deleted -> generalGetString(MR.strings.relay_conn_status_deleted) to Color.Red + is ConnStatus.Failed -> generalGetString(MR.strings.relay_conn_status_failed) to Color.Red + else -> generalGetString(MR.strings.relay_conn_status_connecting) to WarningYellow + } +} + +fun hostFromRelayLink(link: String): String { + val ft = parseToMarkdown(link) + if (ft != null) { + for (f in ft) { + val format = f.format + if (format is Format.SimplexLink) { + val host = format.smpHosts.firstOrNull() + if (host != null) return host + } + } + } + return link +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 3f80361249..78eb31ccbe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -5,6 +5,7 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable +import SectionItemViewSpaceBetween import SectionSpacer import SectionTextFooter import SectionView @@ -41,6 +42,7 @@ import chat.simplex.common.views.chat.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.database.TtlOptions +import chat.simplex.common.views.newchat.SimpleXLinkQRCode import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* @@ -114,7 +116,7 @@ fun ModalData.GroupChatInfoView( } } }, - showMemberInfo = { member -> + showMemberInfo = { member, groupRelay -> withBGApi { val r = chatModel.controller.apiGroupMemberInfo(rhId, groupInfo.groupId, member.groupMemberId) val stats = r?.second @@ -126,7 +128,7 @@ fun ModalData.GroupChatInfoView( } ModalManager.end.showModalCloseable(true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> - GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, closeCurrent) { + GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() close() } @@ -165,7 +167,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated) } + ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -175,9 +177,14 @@ fun ModalData.GroupChatInfoView( fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { val chatInfo = chat.chatInfo - val titleId = if (groupInfo.businessChat == null) MR.strings.delete_group_question else MR.strings.delete_chat_question + val titleId = if (groupInfo.useRelays) MR.strings.delete_channel_question + else if (groupInfo.businessChat == null) MR.strings.delete_group_question + else MR.strings.delete_chat_question val messageId = - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.membership.memberCurrent) MR.strings.delete_channel_for_all_subscribers_cannot_undo_warning + else MR.strings.delete_channel_for_self_cannot_undo_warning + } else if (groupInfo.businessChat == null) { if (groupInfo.membership.memberCurrent) MR.strings.delete_group_for_all_members_cannot_undo_warning else MR.strings.delete_group_for_self_cannot_undo_warning } else { @@ -209,8 +216,12 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl } fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { - val titleId = if (groupInfo.businessChat == null) MR.strings.leave_group_question else MR.strings.leave_chat_question - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.leave_channel_question + else if (groupInfo.businessChat == null) MR.strings.leave_group_question + else MR.strings.leave_chat_question + val messageId = if (groupInfo.useRelays) + MR.strings.you_will_stop_receiving_messages_from_this_channel_chat_history_will_be_preserved + else if (groupInfo.businessChat == null) MR.strings.you_will_stop_receiving_messages_from_this_group_chat_history_will_be_preserved else MR.strings.you_will_stop_receiving_messages_from_this_chat_chat_history_will_be_preserved @@ -229,12 +240,16 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl } private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) { - val messageId = if (groupInfo.businessChat == null) + val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question + else MR.strings.button_remove_member_question + val messageId = if (groupInfo.useRelays) + MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone + else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone else MR.strings.member_will_be_removed_from_chat_cannot_be_undone AlertManager.shared.showAlertDialogButtonsColumn( - generalGetString(MR.strings.button_remove_member_question), + generalGetString(titleId), generalGetString(messageId), buttons = { Column { @@ -358,6 +373,22 @@ fun AddGroupMembersButton( ) } +@Composable +fun ChannelLinkActionButton( + modifier: Modifier, + groupInfo: GroupInfo, + manageGroupLink: () -> Unit +) { + InfoViewActionButton( + modifier = modifier, + icon = painterResource(MR.images.ic_link), + title = stringResource(MR.strings.action_button_channel_link), + disabled = !groupInfo.ready, + disabledLook = !groupInfo.ready, + onClick = manageGroupLink + ) +} + @Composable fun UserSupportChatButton( chat: Chat, @@ -409,7 +440,7 @@ fun ModalData.GroupChatInfoLayout( appBar: MutableState<@Composable (BoxScope.() -> Unit)?>, scrollToItemId: MutableState, addMembers: () -> Unit, - showMemberInfo: (GroupMember) -> Unit, + showMemberInfo: (GroupMember, GroupRelay?) -> Unit, editGroupProfile: () -> Unit, addOrEditWelcomeMessage: () -> Unit, openMemberSupport: () -> Unit, @@ -478,14 +509,19 @@ fun ModalData.GroupChatInfoLayout( Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { + val showThreeButtons = if (groupInfo.useRelays) groupInfo.isOwner else groupInfo.canAddMembers Row( Modifier - .widthIn(max = if (groupInfo.canAddMembers) 320.dp else 230.dp) + .widthIn(max = if (showThreeButtons) 320.dp else 230.dp) .padding(horizontal = DEFAULT_PADDING), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically ) { - if (groupInfo.canAddMembers) { + if (groupInfo.useRelays && groupInfo.isOwner) { + SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) + ChannelLinkActionButton(modifier = Modifier.fillMaxWidth(0.5f), groupInfo, manageGroupLink) + MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) + } else if (!groupInfo.useRelays && groupInfo.canAddMembers) { SearchButton(modifier = Modifier.fillMaxWidth(0.33f), chat, groupInfo, close, onSearchClicked) AddGroupMembersButton(modifier = Modifier.fillMaxWidth(0.5f), chat, groupInfo) MuteButton(modifier = Modifier.fillMaxWidth(1f), chat, groupInfo) @@ -498,59 +534,100 @@ fun ModalData.GroupChatInfoLayout( SectionSpacer() - var anyTopSectionRowShow = false - SectionView { - if (groupInfo.canAddMembers && groupInfo.businessChat == null) { - anyTopSectionRowShow = true - if (groupLink == null) { - CreateGroupLinkButton(manageGroupLink) - } else { - GroupLinkButton(manageGroupLink) + if (groupInfo.useRelays && groupInfo.membership.memberIncognito) { + SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionItemViewSpaceBetween { + Text(generalGetString(MR.strings.incognito_random_profile)) + Text(groupInfo.membership.chatViewName, color = Indigo) } } - if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { - anyTopSectionRowShow = true - MemberSupportButton(chat, openMemberSupport) + SectionDividerSpaced() + } + + var anyTopSectionRowShow = false + val channelLink = groupInfo.groupProfile.publicGroup?.groupLink + if (groupInfo.useRelays) { + SectionView { + if (groupInfo.isOwner && groupLink != null) { + anyTopSectionRowShow = true + ChannelLinkButton(manageGroupLink) + } else if (channelLink != null) { + anyTopSectionRowShow = true + ChannelLinkQRCodeSection(channelLink) + } + if (groupInfo.isOwner || activeSortedMembers.any { it.memberRole >= GroupMemberRole.Owner }) { + anyTopSectionRowShow = true + ChannelMembersButton(chat.remoteHostId, groupInfo, showMemberInfo) + } } - if (groupInfo.canModerate) { - anyTopSectionRowShow = true - GroupReportsButton(chat) { - scope.launch { - showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + if (!groupInfo.isOwner && channelLink != null) { + SectionTextFooter(stringResource(MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect)) + } + } else { + SectionView { + if (groupInfo.canAddMembers && groupInfo.businessChat == null) { + anyTopSectionRowShow = true + if (groupLink == null) { + CreateGroupLinkButton(manageGroupLink) + } else { + GroupLinkButton(manageGroupLink) } } - } - if ( - groupInfo.membership.memberActive && - (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) - ) { - anyTopSectionRowShow = true - UserSupportChatButton(chat, groupInfo, scrollToItemId) + if (groupInfo.businessChat == null && groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { + anyTopSectionRowShow = true + MemberSupportButton(chat, openMemberSupport) + } + if (groupInfo.canModerate) { + anyTopSectionRowShow = true + GroupReportsButton(chat) { + scope.launch { + showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo) + } + } + } + if ( + groupInfo.membership.memberActive && + (groupInfo.membership.memberRole < GroupMemberRole.Moderator || groupInfo.membership.supportChat != null) + ) { + anyTopSectionRowShow = true + UserSupportChatButton(chat, groupInfo, scrollToItemId) + } } } + val showEditSection = (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) + || groupInfo.groupProfile.description != null + || !groupInfo.useRelays if (anyTopSectionRowShow) { SectionDividerSpaced(maxBottomPadding = false) } - - SectionView { - if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { - EditGroupProfileButton(editGroupProfile) + if (showEditSection) { + SectionView { + if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { + val editProfileTitleId = if (groupInfo.useRelays) MR.strings.button_edit_channel_profile else MR.strings.button_edit_group_profile + EditGroupProfileButton(editProfileTitleId, editGroupProfile) + } + if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { + AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + } + if (!groupInfo.useRelays) { + val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences + GroupPreferencesButton(prefsTitleId, openPreferences) + } } - if (groupInfo.groupProfile.description != null || (groupInfo.isOwner && groupInfo.businessChat?.chatType == null)) { - AddOrEditWelcomeMessage(groupInfo.groupProfile.description, addOrEditWelcomeMessage) + if (!groupInfo.useRelays) { + val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs + SectionTextFooter(stringResource(footerId)) } - val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences - GroupPreferencesButton(prefsTitleId, openPreferences) + SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } - val footerId = if (groupInfo.businessChat == null) MR.strings.only_group_owners_can_change_prefs else MR.strings.only_chat_owners_can_change_prefs - SectionTextFooter(stringResource(footerId)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) SectionView { - if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { - SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) - } else { - SendReceiptsOptionDisabled() + if (!groupInfo.useRelays) { + if (activeSortedMembers.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) { + SendReceiptsOption(currentUser, sendReceipts, setSendReceipts) + } else { + SendReceiptsOptionDisabled() + } } WallpaperButton { ModalManager.end.showModal { @@ -566,7 +643,7 @@ fun ModalData.GroupChatInfoLayout( } SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers @@ -589,7 +666,7 @@ fun ModalData.GroupChatInfoLayout( } } } - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { items(filteredMembers.value, key = { it.groupMemberId }) { member -> Divider() val showMenu = remember { mutableStateOf(false) } @@ -601,7 +678,7 @@ fun ModalData.GroupChatInfoLayout( toggleItemSelection(member.groupMemberId, selectedItems) } } else { - showMemberInfo(member) + showMemberInfo(member, null) } }, longClick = { showMenu.value = true }, @@ -622,18 +699,30 @@ fun ModalData.GroupChatInfoLayout( } } item { - if (!groupInfo.nextConnectPrepared) { + if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) } SectionView { + if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { + ChannelRelaysButton(chat.remoteHostId, groupInfo, showMemberInfo) + } ClearChatButton(clearChat) if (groupInfo.canDelete) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_delete_group else MR.strings.button_delete_chat + val titleId = if (groupInfo.useRelays) MR.strings.button_delete_channel + else if (groupInfo.businessChat == null) MR.strings.button_delete_group + else MR.strings.button_delete_chat DeleteGroupButton(titleId, deleteGroup) } if (groupInfo.membership.memberCurrentOrPending) { - val titleId = if (groupInfo.businessChat == null) MR.strings.button_leave_group else MR.strings.button_leave_chat - LeaveGroupButton(titleId, leaveGroup) + val hasOtherOwner = activeSortedMembers.any { + it.memberRole == GroupMemberRole.Owner && it.groupMemberId != groupInfo.membership.groupMemberId + } + if (!groupInfo.useRelays || !groupInfo.isOwner || hasOtherOwner) { + val titleId = if (groupInfo.useRelays) MR.strings.button_leave_channel + else if (groupInfo.businessChat == null) MR.strings.button_leave_group + else MR.strings.button_leave_chat + LeaveGroupButton(titleId, leaveGroup) + } } } @@ -775,6 +864,17 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { modifier = Modifier.combinedClickable(onClick = copyDisplayName, onLongClick = copyDisplayName).onRightClick(copyDisplayName) ) ChatInfoDescription(cInfo, displayName, copyNameToClipboard) + if (groupInfo.useRelays) { + val count = groupInfo.groupSummary.publicMemberCount + if (count != null && count > 0) { + Text( + subscriberCountStr(count), + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(bottom = 2.dp) + ) + } + } } } @@ -878,9 +978,11 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr } fun memberConnStatus(): String { - return if (member.activeConn?.connDisabled == true) { - generalGetString(MR.strings.member_info_member_disabled) + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) } else if (member.activeConn?.connDisabled == true) { + generalGetString(MR.strings.member_info_member_disabled) + } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) } else { member.memberStatus.shortText @@ -1014,10 +1116,72 @@ private fun CreateGroupLinkButton(onClick: () -> Unit) { } @Composable -fun EditGroupProfileButton(onClick: () -> Unit) { +private fun ChannelLinkButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_link), + stringResource(MR.strings.channel_link), + onClick, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelLinkQRCodeSection(groupLink: String) { + val clipboard = LocalClipboardManager.current + SimpleXLinkQRCode(connReq = groupLink) + SectionItemView({ + clipboard.shareText(simplexChatLink(groupLink)) + }) { + Icon(painterResource(MR.images.ic_share), null, tint = MaterialTheme.colors.primary) + Spacer(Modifier.width(8.dp)) + Text(stringResource(MR.strings.share_link), color = MaterialTheme.colors.primary) + } +} + +@Composable +private fun ChannelMembersButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + val title = if (groupInfo.isOwner) { + stringResource(MR.strings.channel_members_title_subscribers) + } else { + stringResource(MR.strings.channel_members_section_owners) + } + SettingsActionItem( + painterResource(MR.images.ic_group), + title, + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelMembersView(rhId, groupInfo, chatModel, close) { member -> showMemberInfo(member, null) } + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +private fun ChannelRelaysButton(rhId: Long?, groupInfo: GroupInfo, showMemberInfo: (GroupMember, GroupRelay?) -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + stringResource(MR.strings.button_channel_relays), + click = { + withBGApi { + setGroupMembers(rhId, groupInfo, chatModel) + ModalManager.end.showModalCloseable(true) { close -> + ChannelRelaysView(rhId, groupInfo, chatModel, close, showMemberInfo) + } + } + }, + iconColor = MaterialTheme.colors.secondary + ) +} + +@Composable +fun EditGroupProfileButton(titleId: StringResource = MR.strings.button_edit_group_profile, onClick: () -> Unit) { SettingsActionItem( painterResource(MR.images.ic_edit), - stringResource(MR.strings.button_edit_group_profile), + stringResource(titleId), onClick, iconColor = MaterialTheme.colors.secondary ) @@ -1145,7 +1309,7 @@ fun PreviewGroupChatInfoLayout() { appBar = remember { mutableStateOf(null) }, scrollToItemId = remember { mutableStateOf(null) }, addMembers = {}, - showMemberInfo = {}, + showMemberInfo = { _, _ -> }, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openMemberSupport = {}, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt index 5a94e7d505..c9745359b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt @@ -32,6 +32,7 @@ fun GroupLinkView( groupLink: GroupLink?, onGroupLinkUpdated: ((GroupLink?) -> Unit)?, creatingGroup: Boolean = false, + isChannel: Boolean = false, close: (() -> Unit)? = null ) { var groupLinkVar by rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(groupLink) } @@ -122,6 +123,7 @@ fun GroupLinkView( groupInfo, groupLinkMemberRole, creatingLink, + isChannel = isChannel, createLink = ::createLink, showAddShortLinkAlert = ::showAddShortLinkAlert, updateLink = { @@ -168,6 +170,7 @@ fun GroupLinkLayout( groupInfo: GroupInfo, groupLinkMemberRole: MutableState, creatingLink: Boolean, + isChannel: Boolean = false, createLink: () -> Unit, showAddShortLinkAlert: ((() -> Unit)?) -> Unit, updateLink: () -> Unit, @@ -185,9 +188,9 @@ fun GroupLinkLayout( } ColumnWithScrollBar { - AppBarTitle(stringResource(MR.strings.group_link)) + AppBarTitle(stringResource(if (isChannel) MR.strings.channel_link else MR.strings.group_link)) Text( - stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), + stringResource(if (isChannel) MR.strings.you_can_share_channel_link_anybody_will_be_able_to_connect else MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect), Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = 12.dp), lineHeight = 22.sp ) @@ -208,7 +211,9 @@ fun GroupLinkLayout( } } } else { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + if (!isChannel) { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { if (!initialLaunch) { @@ -218,12 +223,12 @@ fun GroupLinkLayout( } val showShortLink = remember { mutableStateOf(true) } Spacer(Modifier.height(DEFAULT_PADDING_HALF)) - if (groupLink.connLinkContact.connShortLink == null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = false) - } else { - SectionViewWithButton(titleButton = { ToggleShortLinkButton(showShortLink) }) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } + SectionViewWithButton( + titleButton = + if (!isChannel && groupLink.connLinkContact.connShortLink != null) { + { ToggleShortLinkButton(showShortLink) } + } else null) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) } Row( horizontalArrangement = Arrangement.spacedBy(10.dp), @@ -235,7 +240,7 @@ fun GroupLinkLayout( stringResource(MR.strings.share_link), icon = painterResource(MR.images.ic_share), click = { - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { showAddShortLinkAlert { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } @@ -246,7 +251,7 @@ fun GroupLinkLayout( ) if (creatingGroup && close != null) { ContinueButton(close) - } else { + } else if (!isChannel) { SimpleButton( stringResource(MR.strings.delete_link), icon = painterResource(MR.images.ic_delete), @@ -255,7 +260,7 @@ fun GroupLinkLayout( ) } } - if (groupLink.shouldBeUpgraded) { + if (!isChannel && groupLink.shouldBeUpgraded) { AddShortLinkButton(text = stringResource(MR.strings.upgrade_group_link)) { showAddShortLinkAlert(null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index f09d2f44bb..fc5d697f4f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -50,6 +50,7 @@ fun GroupMemberInfoView( connectionCode: String?, chatModel: ChatModel, openedFromSupportChat: Boolean, + groupRelay: GroupRelay? = null, close: () -> Unit, closeAll: () -> Unit, // Close all open windows up to ChatView ) { @@ -90,6 +91,7 @@ fun GroupMemberInfoView( newRole, developerTools, connectionCode, + groupRelay = groupRelay, getContactChat = { chatModel.getContactChat(it) }, openDirectChat = { contactId -> scope.launch { @@ -311,6 +313,7 @@ fun GroupMemberInfoLayout( newRole: MutableState, developerTools: Boolean, connectionCode: String?, + groupRelay: GroupRelay? = null, getContactChat: (Long) -> Chat?, openDirectChat: (Long) -> Unit, createMemberContact: () -> Unit, @@ -365,7 +368,7 @@ fun GroupMemberInfoLayout( @Composable fun ModeratorDestructiveSection() { val canBlockForAll = member.canBlockForAll(groupInfo) - val canRemove = member.canBeRemoved(groupInfo) + val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { SectionDividerSpaced(maxBottomPadding = false) SectionView { @@ -380,7 +383,7 @@ fun GroupMemberInfoLayout( if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) { DeleteMemberMessagesButton(deleteMemberMessages) } else { - RemoveMemberButton(removeMember) + RemoveMemberButton(groupInfo.useRelays, removeMember) } } } @@ -417,77 +420,80 @@ fun GroupMemberInfoLayout( val contactId = member.memberContactId - Box( - Modifier.fillMaxWidth(), - contentAlignment = Alignment.Center - ) { - Row( - Modifier - .widthIn(max = 320.dp) - .padding(horizontal = DEFAULT_PADDING), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically + if (!groupInfo.useRelays) { + Box( + Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - val knownChat = if (contactId != null) knownDirectChat(contactId) else null - if (knownChat != null) { - val (chat, contact) = knownChat - val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } + Row( + Modifier + .widthIn(max = 320.dp) + .padding(horizontal = DEFAULT_PADDING), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically + ) { + val knownChat = if (contactId != null) knownDirectChat(contactId) else null + if (knownChat != null) { + val (chat, contact) = knownChat + val knownContactConnectionStats: MutableState = remember { mutableStateOf(null) } - LaunchedEffect(contact.contactId) { - withBGApi { - val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) - if (contactInfo != null) { - knownContactConnectionStats.value = contactInfo.first + LaunchedEffect(contact.contactId) { + withBGApi { + val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, chat.chatInfo.apiId) + if (contactInfo != null) { + knownContactConnectionStats.value = contactInfo.first + } } } - } - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) - AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) - VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) - } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { - if (contactId != null) { - OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group - } else { - OpenChatButton( - modifier = Modifier.fillMaxWidth(0.33f), - disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), - onClick = { createMemberContact() } - ) + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contact.contactId) }) + AudioCallButton(modifier = Modifier.fillMaxWidth(0.5f), chat, contact, knownContactConnectionStats) + VideoButton(modifier = Modifier.fillMaxWidth(1f), chat, contact, knownContactConnectionStats) + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { + if (contactId != null) { + OpenChatButton(modifier = Modifier.fillMaxWidth(0.33f), onClick = { openDirectChat(contactId) }) // legacy - only relevant for direct contacts created when joining group + } else { + OpenChatButton( + modifier = Modifier.fillMaxWidth(0.33f), + disabledLook = !(member.sendMsgEnabled || (member.activeConn?.connectionStats?.ratchetSyncAllowed ?: false)), + onClick = { createMemberContact() } + ) + } + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showSendMessageToEnableCallsAlert() + }) + } else { // no known contact chat && directMessages are off + val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) + InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { + showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) + }) } - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showSendMessageToEnableCallsAlert() - }) - } else { // no known contact chat && directMessages are off - val messageId = if (groupInfo.businessChat == null) MR.strings.direct_messages_are_prohibited_in_group else MR.strings.direct_messages_are_prohibited_in_chat - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.33f), painterResource(MR.images.ic_chat_bubble), generalGetString(MR.strings.info_view_message_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_send_message_to_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(0.5f), painterResource(MR.images.ic_call), generalGetString(MR.strings.info_view_call_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) - InfoViewActionButton(modifier = Modifier.fillMaxWidth(1f), painterResource(MR.images.ic_videocam), generalGetString(MR.strings.info_view_video_button), disabled = false, disabledLook = true, onClick = { - showDirectMessagesProhibitedAlert(generalGetString(MR.strings.cant_call_member_alert_title), messageId) - }) } } - } - SectionSpacer() + SectionSpacer() + } if (member.memberActive) { SectionView { if ( !openedFromSupportChat && groupInfo.membership.memberRole >= GroupMemberRole.Moderator && + member.memberRole != GroupMemberRole.Relay && (member.memberRole < GroupMemberRole.Moderator || member.supportChat != null) ) { SupportChatButton() } - if (connectionCode != null) { + if (connectionCode != null && !(groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay)) { VerifyCodeButton(member.verified, verifyClicked) } if (cStats != null && cStats.ratchetSyncAllowed) { @@ -517,15 +523,46 @@ fun GroupMemberInfoLayout( SectionDividerSpaced() } - SectionView(title = stringResource(MR.strings.member_info_section_title_member)) { - val titleId = if (groupInfo.businessChat == null) MR.strings.info_row_group else MR.strings.info_row_chat + val memberSectionTitle = if (groupInfo.useRelays) { + when (member.memberRole) { + GroupMemberRole.Relay -> stringResource(MR.strings.member_info_section_title_relay) + GroupMemberRole.Owner -> stringResource(MR.strings.member_info_section_title_owner) + else -> stringResource(MR.strings.member_info_section_title_subscriber) + } + } else { + stringResource(MR.strings.member_info_section_title_member) + } + SectionView(title = memberSectionTitle) { + val titleId = if (groupInfo.useRelays) MR.strings.info_row_channel + else if (groupInfo.businessChat == null) MR.strings.info_row_group + else MR.strings.info_row_chat InfoRow(stringResource(titleId), groupInfo.displayName) - val roles = remember { member.canChangeRoleTo(groupInfo) } - if (roles != null) { - RoleSelectionRow(roles, newRole, onRoleSelected) + if (!groupInfo.useRelays) { + val roles = remember { member.canChangeRoleTo(groupInfo) } + if (roles != null) { + RoleSelectionRow(roles, newRole, onRoleSelected) + } else { + InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) + } } else { InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text) } + val relayLink = member.relayLink + if (relayLink != null) { + InfoRow(stringResource(MR.strings.info_row_relay_link), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayLink))) + } + val relayAddress = groupRelay?.userChatRelay?.address + if (relayAddress != null) { + InfoRow(stringResource(MR.strings.info_row_relay_address), String.format(generalGetString(MR.strings.via_relay_hostname), hostFromRelayLink(relayAddress))) + val clipboard = LocalClipboardManager.current + ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) } + } + } + if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) { + SectionTextFooter( + if (groupInfo.isOwner) stringResource(MR.strings.relay_section_footer_owner) + else stringResource(MR.strings.relay_section_footer_subscriber) + ) } if (cStats != null) { SectionDividerSpaced() @@ -562,9 +599,22 @@ fun GroupMemberInfoLayout( } } + val connFailedErr = member.activeConn?.connFailedErr + if (connFailedErr != null) { + SectionDividerSpaced() + SectionView(title = stringResource(MR.strings.info_row_connection_failed), icon = painterResource(MR.images.ic_warning), iconTint = Color.Red, leadingIcon = true) { + SectionItemView { + Text( + connFailedErr, + color = MaterialTheme.colors.secondary + ) + } + } + } + if (groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ModeratorDestructiveSection() - } else { + } else if (!groupInfo.useRelays) { NonAdminBlockSection() } @@ -580,18 +630,20 @@ fun GroupMemberInfoLayout( else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel) InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc) } - SectionItemView({ - withBGApi { - val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) - if (info != null) { - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.message_queue_info), - text = queueInfoText(info) - ) + if (!groupInfo.useRelays || member.memberRole == GroupMemberRole.Relay) { + SectionItemView({ + withBGApi { + val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId) + if (info != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.message_queue_info), + text = queueInfoText(info) + ) + } } + }) { + Text(stringResource(MR.strings.info_row_debug_delivery)) } - }) { - Text(stringResource(MR.strings.info_row_debug_delivery)) } } } @@ -695,10 +747,11 @@ fun UnblockForAllButton(onClick: () -> Unit) { } @Composable -fun RemoveMemberButton(onClick: () -> Unit) { +fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) { + val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member SettingsActionItem( painterResource(MR.images.ic_delete), - stringResource(MR.strings.button_remove_member), + stringResource(label), click = onClick, textColor = Color.Red, iconColor = Color.Red, @@ -716,6 +769,17 @@ fun DeleteMemberMessagesButton(onClick: () -> Unit) { ) } +@Composable +fun ShareRelayAddressButton(onClick: () -> Unit) { + SettingsActionItem( + painterResource(MR.images.ic_share_filled), + stringResource(MR.strings.share_relay_address), + onClick, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) +} + @Composable fun OpenChatButton( modifier: Modifier, @@ -900,8 +964,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem } fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.block_subscriber_for_all_question else MR.strings.block_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.block_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.block_for_all), onConfirm = { @@ -924,8 +989,9 @@ fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, memberIds: List, onSuc } fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) { + val titleId = if (gInfo.useRelays) MR.strings.unblock_subscriber_for_all_question else MR.strings.unblock_for_all_question AlertManager.shared.showAlertDialog( - title = generalGetString(MR.strings.unblock_for_all_question), + title = generalGetString(titleId), text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName), confirmText = generalGetString(MR.strings.unblock_for_all), onConfirm = { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index b8db5969a1..ddf0456822 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -43,7 +43,7 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> fun savePrefs(afterSave: () -> Unit = {}) { withBGApi { val gp = gInfo.groupProfile.copy(groupPreferences = preferences.toGroupPreferences()) - val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays) if (g != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, g) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt index f15f70673a..d144065399 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt @@ -32,10 +32,11 @@ import java.net.URI fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: () -> Unit) { GroupProfileLayout( close = close, + groupInfo = groupInfo, groupProfile = groupInfo.groupProfile, saveProfile = { p -> withBGApi { - val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p) + val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, p, groupInfo.useRelays) if (gInfo != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, gInfo) @@ -50,9 +51,11 @@ fun GroupProfileView(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl @Composable fun GroupProfileLayout( close: () -> Unit, + groupInfo: GroupInfo, groupProfile: GroupProfile, saveProfile: (GroupProfile) -> Unit, ) { + val isChannel = groupInfo.useRelays val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) val displayName = rememberSaveable { mutableStateOf(groupProfile.displayName) } val fullName = rememberSaveable { mutableStateOf(groupProfile.fullName) } @@ -71,7 +74,7 @@ fun GroupProfileLayout( if (dataUnchanged || !canUpdateProfile(displayName.value, shortDescr.value, groupProfile)) { close() } else { - showUnsavedChangesAlert({ + showUnsavedChangesAlert(isChannel, { saveProfile( groupProfile.copy( displayName = displayName.value.trim(), @@ -103,7 +106,11 @@ fun GroupProfileLayout( Modifier.fillMaxWidth() .padding(horizontal = DEFAULT_PADDING) ) { - ReadableText(MR.strings.group_profile_is_stored_on_members_devices, TextAlign.Center) + ReadableText( + if (isChannel) MR.strings.channel_profile_is_stored_on_subscribers_devices + else MR.strings.group_profile_is_stored_on_members_devices, + TextAlign.Center + ) Box( Modifier .fillMaxWidth() @@ -122,7 +129,7 @@ fun GroupProfileLayout( } Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text( - stringResource(MR.strings.group_display_name_field), + stringResource(if (isChannel) MR.strings.channel_display_name_field else MR.strings.group_display_name_field), fontSize = 16.sp ) if (!isValidNewProfileName(displayName.value, groupProfile)) { @@ -136,7 +143,7 @@ fun GroupProfileLayout( if (groupProfile.fullName.trim().isNotEmpty() && groupProfile.fullName.trim() != groupProfile.displayName.trim()) { Spacer(Modifier.height(DEFAULT_PADDING)) Text( - stringResource(MR.strings.group_full_name_field), + stringResource(if (isChannel) MR.strings.channel_full_name_field else MR.strings.group_full_name_field), fontSize = 16.sp, modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) ) @@ -164,9 +171,10 @@ fun GroupProfileLayout( Spacer(Modifier.height(DEFAULT_PADDING)) val enabled = !dataUnchanged && canUpdateProfile(displayName.value, shortDescr.value, groupProfile) + val saveProfileLabel = if (isChannel) MR.strings.save_channel_profile else MR.strings.save_group_profile if (enabled) { Text( - stringResource(MR.strings.save_group_profile), + stringResource(saveProfileLabel), modifier = Modifier.clickable { saveProfile( groupProfile.copy( @@ -181,7 +189,7 @@ fun GroupProfileLayout( ) } else { Text( - stringResource(MR.strings.save_group_profile), + stringResource(saveProfileLabel), color = MaterialTheme.colors.secondary ) } @@ -204,10 +212,10 @@ private fun canUpdateProfile(displayName: String, shortDescr: String, groupProfi private fun isValidNewProfileName(displayName: String, groupProfile: GroupProfile): Boolean = displayName == groupProfile.displayName || isValidDisplayName(displayName.trim()) -private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { +private fun showUnsavedChangesAlert(isChannel: Boolean, save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.save_preferences_question), - confirmText = generalGetString(MR.strings.save_and_notify_group_members), + confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members), dismissText = generalGetString(MR.strings.exit_without_saving), onConfirm = save, onDismiss = revert, @@ -224,6 +232,7 @@ fun PreviewGroupProfileLayout() { SimpleXTheme { GroupProfileLayout( close = {}, + groupInfo = GroupInfo.sampleData, groupProfile = GroupProfile.sampleData, saveProfile = { _ -> } ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt index 48171bfeb7..7c9db58316 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt @@ -34,7 +34,7 @@ fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> fun saveAdmission(afterSave: () -> Unit = {}) { withBGApi { val gp = gInfo.groupProfile.copy(memberAdmission = admission) - val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp) + val g = m.controller.apiUpdateGroup(rhId, gInfo.groupId, gp, gInfo.useRelays) if (g != null) { withContext(Dispatchers.Main) { chatModel.chatsContext.updateGroup(rhId, g) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index e696128288..c3cf954ab6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -162,7 +162,9 @@ private fun ModalData.MemberSupportViewLayout( @Composable fun SupportChatRow(member: GroupMember) { fun memberStatus(): String { - return if (member.activeConn?.connDisabled == true) { + return if (member.activeConn?.connStatus is ConnStatus.Failed) { + generalGetString(MR.strings.member_info_member_failed) + } else if (member.activeConn?.connDisabled == true) { generalGetString(MR.strings.member_info_member_disabled) } else if (member.activeConn?.connInactive == true) { generalGetString(MR.strings.member_info_member_inactive) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt index 1e99c7f527..927e9940b5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt @@ -45,7 +45,7 @@ fun GroupWelcomeView(m: ChatModel, rhId: Long?, groupInfo: GroupInfo, close: () welcome = null } val groupProfileUpdated = gInfo.groupProfile.copy(description = welcome) - val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated) + val res = m.controller.apiUpdateGroup(rhId, gInfo.groupId, groupProfileUpdated, gInfo.useRelays) if (res != null) { gInfo = res withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt index 1be2110b1f..064b5370bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt @@ -26,7 +26,7 @@ import chat.simplex.common.ui.theme.DEFAULT_MAX_IMAGE_WIDTH import chat.simplex.common.views.chat.chatViewScrollState import chat.simplex.res.MR import dev.icerock.moko.resources.StringResource -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.* @Composable fun CIImageView( @@ -38,6 +38,7 @@ fun CIImageView( receiveFile: (Long) -> Unit ) { val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) } + val previewBitmap = remember(image) { base64ToBitmap(image) } @Composable fun progressIndicator() { CircularProgressIndicator( @@ -144,7 +145,7 @@ fun CIImageView( .privacyBlur(!smallView, blurred, scrollState = chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }), contentAlignment = Alignment.Center ) { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (fileSource != null) { openFile(fileSource) } @@ -178,14 +179,16 @@ fun CIImageView( Box( Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID) + .then( + if (!smallView) { + val w = if (previewBitmap.width * 0.97 <= previewBitmap.height) imageViewFullWidth() * 0.75f else DEFAULT_MAX_IMAGE_WIDTH + Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat()) + } else Modifier + ) .desktopModifyBlurredState(!smallView, blurred, showMenu), contentAlignment = Alignment.TopEnd ) { - val res: MutableState?> = remember { - mutableStateOf( - if (chatModel.connectedToRemote()) null else runBlocking { imageAndFilePath(file) } - ) - } + val res: MutableState?> = remember { mutableStateOf(null) } if (chatModel.connectedToRemote()) { LaunchedEffect(file, CIFile.cachedRemoteFileRequests.toList()) { withBGApi { @@ -195,9 +198,9 @@ fun CIImageView( } } } else { - KeyChangeEffect(file) { + LaunchedEffect(file) { if (res.value == null || res.value!!.third != getLoadedFilePath(file)) { - res.value = imageAndFilePath(file) + res.value = withContext(Dispatchers.IO) { imageAndFilePath(file) } } } } @@ -206,7 +209,7 @@ fun CIImageView( val (imageBitmap, data, _) = loaded SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) } else { - imageView(base64ToBitmap(image), onClick = { + imageView(previewBitmap, onClick = { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 758980059d..d2d0a91c9b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.* import androidx.compose.ui.graphics.* @@ -47,8 +48,8 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary) -fun chatEventText(ci: ChatItem): AnnotatedString = - chatEventText(ci.content.text, ci.timestampText) +fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString = + chatEventText(ci.content.text(isChannel), ci.timestampText) fun chatEventText(eventText: String, ts: String): AnnotatedString = buildAnnotatedString { @@ -61,6 +62,7 @@ data class ChatItemReactionMenuItem ( val onClick: (() -> Unit)? ) +// Spec: spec/client/chat-view.md#ChatItemView @Composable fun ChatItemView( chatsCtx: ChatModel.ChatsContext, @@ -108,6 +110,7 @@ fun ChatItemView( showTimestamp: Boolean, itemSeparation: ItemSeparation, preview: Boolean = false, + swipeOffset: Float = 0f, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current @@ -297,8 +300,11 @@ fun ChatItemView( } Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { - Row(verticalAlignment = Alignment.CenterVertically) { - val bubbleInteractionSource = remember { MutableInteractionSource() } + val canReply = (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && + cInfo !is ChatInfo.Local && !cItem.isReport && !cItem.meta.isLive && cItem.meta.itemDeleted == null + Box { + Row(verticalAlignment = Alignment.CenterVertically) { + val bubbleInteractionSource = remember { MutableInteractionSource() } val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() if (cItem.chatDir.sent) { GoToItemButton(true, bubbleHovered) @@ -606,7 +612,7 @@ fun ChatItemView( return if (count <= 1) { null } else if (ns.isEmpty()) { - generalGetString(MR.strings.rcv_group_events_count).format(count) + generalGetString(if (cInfo.isChannel) MR.strings.rcv_channel_events_count else MR.strings.rcv_group_events_count).format(count) } else if (count > ns.size) { members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size) } else { @@ -623,9 +629,9 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") - }.plus(chatEventText(cItem)) + }.plus(chatEventText(cItem, cInfo.isChannel)) } else { - chatEventText(cItem) + chatEventText(cItem, cInfo.isChannel) } } @@ -637,7 +643,7 @@ fun ChatItemView( @Composable fun PendingReviewEventItemView() { Text( buildAnnotatedString { - withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text) } + withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) } }, Modifier.padding(horizontal = 6.dp, vertical = 6.dp) ) @@ -799,6 +805,15 @@ fun ChatItemView( if (!cItem.chatDir.sent) { GoToItemButton(false, bubbleHovered) } + } + if (canReply && swipeOffset < 0) { + Icon( + painterResource(MR.images.ic_reply), + contentDescription = null, + modifier = Modifier.align(Alignment.CenterEnd).offset(x = 26.dp).size(18.dp).alpha(minOf(1f, -swipeOffset / 30f)), + tint = MaterialTheme.colors.secondary + ) + } } if (cItem.content.msgContent != null && (cItem.meta.itemDeleted == null || revealed.value) && cItem.reactions.isNotEmpty()) { ChatItemReactions() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 7aca0466f9..3bcd02411f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -1,9 +1,11 @@ package chat.simplex.common.views.chat.item +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.Text -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle @@ -12,6 +14,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont +import chat.simplex.common.views.chat.* import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -19,11 +22,20 @@ val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiF @Composable fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) { + val emojiText = chatItem.content.text.trim() + val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) + Column( Modifier.padding(vertical = 8.dp, horizontal = 12.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - EmojiText(chatItem.content.text) + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { + EmojiText(chatItem.content.text) + } CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index f36da6c908..8aab0bbbb6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.unit.* import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.ComposeState +import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -144,7 +144,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), @@ -156,7 +156,7 @@ fun FramedItemView( Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) } - val imageBitmap = base64ToBitmap(qi.content.image) + val imageBitmap = remember(qi.content.image) { base64ToBitmap(qi.content.image) } Image( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), @@ -368,9 +368,11 @@ fun CIMarkdownText( showTimestamp: Boolean, prefix: AnnotatedString? = null ) { - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { - val chatInfo = chat.chatInfo - val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val chatInfo = chat.chatInfo + val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text + val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true) + + Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp).then(selection.positionModifier)) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, @@ -379,7 +381,9 @@ fun CIMarkdownText( chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null }, - uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix + uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, + selectionRange = selection.highlightRange, + onTextLayoutResult = selection.onTextLayoutResult ) } } @@ -437,7 +441,10 @@ fun PriorityLayout( ) { measureable, constraints -> // Find important element which should tell what max width other elements can use // Expecting only one such element. Can be less than one but not more - val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(constraints) + // Max image height for chat item display, taller images are cropped + val maxImageHeight = (constraints.maxWidth * 2.33f).toInt().coerceAtMost(constraints.maxHeight) + val imageConstraints = constraints.copy(maxHeight = maxImageHeight) + val imagePlaceable = measureable.firstOrNull { it.layoutId == priorityLayoutId }?.measure(imageConstraints) val placeables: List = measureable.map { if (it.layoutId == priorityLayoutId) imagePlaceable!! diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 60595fc255..9e8583a79b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -10,6 +10,7 @@ import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.* import androidx.compose.ui.platform.* @@ -22,6 +23,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.CurrentColors +import chat.simplex.common.views.chat.SelectionHighlightColor import chat.simplex.common.views.helpers.* import chat.simplex.res.* import kotlinx.coroutines.* @@ -55,6 +57,35 @@ private fun typingIndicator(recent: Boolean, typingIdx: Int): AnnotatedString = private fun typing(w: FontWeight = FontWeight.Light): AnnotatedString = AnnotatedString(".", SpanStyle(fontWeight = w)) +// Display text for a single formatted segment — must be coordinated with MarkdownText. +fun itemSegmentDisplayText(ft: FormattedText, ci: ChatItem, linkMode: SimplexLinkMode): String = + when (ft.format) { + is Format.Mention -> { + val mention = ci.mentions?.get(ft.format.memberName) + if (mention?.memberRef != null) { + val name = if (mention.memberRef.localAlias.isNullOrEmpty()) mention.memberRef.displayName + else "${mention.memberRef.localAlias} (${mention.memberRef.displayName})" + mentionText(name) + } else if (mention != null) mentionText(ft.format.memberName) + else ft.text + } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { + val t = ft.format.showText + ?: if (linkMode == SimplexLinkMode.DESCRIPTION) ft.format.linkType.description else null + if (t != null) "$t ${ft.format.viaHosts}" else ft.text + } + is Format.Command -> ft.text + else -> ft.text + } + +// Full display text for a chat item — joins segment display texts. +fun itemDisplayText(ci: ChatItem, linkMode: SimplexLinkMode): String { + val formattedText = ci.formattedText ?: return ci.text + return formattedText.joinToString("") { itemSegmentDisplayText(it, ci, linkMode) } +} + +// Text transformations in MarkdownText must match itemSegmentDisplayText above @Composable fun MarkdownText ( text: CharSequence, @@ -77,7 +108,9 @@ fun MarkdownText ( onLinkLongClick: (link: String) -> Unit = {}, showViaProxy: Boolean = false, showTimestamp: Boolean = true, - prefix: AnnotatedString? = null + prefix: AnnotatedString? = null, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null ) { val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr @@ -126,19 +159,27 @@ fun MarkdownText ( ) } if (formattedText == null) { + var selectableEnd = 0 val annotatedText = buildAnnotatedString { inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (prefix != null) append(prefix) if (text is String) append(text) else if (text is AnnotatedString) append(text) + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } else { + var selectableEnd = 0 var hasLinks = false var hasSecrets = false var hasCommands = false @@ -153,6 +194,7 @@ fun MarkdownText ( is Format.Italic -> withStyle(ft.format.style) { append(ft.text) } is Format.StrikeThrough -> withStyle(ft.format.style) { append(ft.text) } is Format.Snippet -> withStyle(ft.format.style) { append(ft.text) } + is Format.Small -> withStyle(ft.format.style) { append(ft.text) } is Format.Colored -> withStyle(ft.format.style) { append(ft.text) } is Format.Secret -> { val ftStyle = ft.format.style @@ -246,6 +288,7 @@ fun MarkdownText ( is Format.Unknown -> append(ft.text) } } + selectableEnd = this.length if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } @@ -254,9 +297,10 @@ fun MarkdownText ( withStyle(reserveTimestampStyle) { append("\n" + metaText) } else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } + val clampedRange = selectionRange?.let { it.first .. minOf(it.last, selectableEnd) } if ((hasLinks && uriHandler != null) || hasSecrets || (hasCommands && sendCommandMsg != null)) { val icon = remember { mutableStateOf(PointerIcon.Default) } - ClickableText(annotatedText, style = style, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, + ClickableText(annotatedText, style = style, selectionRange = clampedRange, modifier = modifier.pointerHoverIcon(icon.value), maxLines = maxLines, overflow = overflow, onLongClick = { offset -> if (hasLinks) { val withAnnotation: (String, (Range) -> Unit) -> Unit = { tag, f -> @@ -299,10 +343,15 @@ fun MarkdownText ( annotatedText.hasStringAnnotations(tag = "WEB_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "SIMPLEX_URL", start = offset, end = offset) || annotatedText.hasStringAnnotations(tag = "OTHER_URL", start = offset, end = offset) - } + }, + onTextLayout = { onTextLayoutResult?.invoke(it) } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + if (onTextLayoutResult != null) { + SelectableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, selectionRange = clampedRange, onTextLayoutResult = onTextLayoutResult) + } else { + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) + } } } } @@ -313,6 +362,7 @@ fun ClickableText( text: AnnotatedString, modifier: Modifier = Modifier, style: TextStyle = TextStyle.Default, + selectionRange: IntRange? = null, softWrap: Boolean = true, overflow: TextOverflow = TextOverflow.Clip, maxLines: Int = Int.MAX_VALUE, @@ -355,7 +405,7 @@ fun ClickableText( BasicText( text = text, - modifier = modifier.then(pressIndicator), + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)).then(pressIndicator), style = style, softWrap = softWrap, overflow = overflow, @@ -367,6 +417,42 @@ fun ClickableText( ) } +@Composable +private fun SelectableText( + text: AnnotatedString, + style: TextStyle, + modifier: Modifier = Modifier, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, + selectionRange: IntRange? = null, + onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null +) { + val layoutResult = remember { mutableStateOf(null) } + + BasicText( + text = text, + modifier = modifier.then(selectionHighlight(selectionRange, text.length, layoutResult)), + style = style, + maxLines = maxLines, + overflow = overflow, + onTextLayout = { + layoutResult.value = it + onTextLayoutResult?.invoke(it) + } + ) +} + +private fun selectionHighlight(selectionRange: IntRange?, textLength: Int, layoutResult: State): Modifier = + if (selectionRange != null) { + Modifier.drawBehind { + layoutResult.value?.let { result -> + if (selectionRange.first <= selectionRange.last && selectionRange.last + 1 <= textLength) { + drawPath(result.getPathForRange(selectionRange.first, selectionRange.last + 1), SelectionHighlightColor) + } + } + } + } else Modifier + fun openBrowserAlert(uri: String, uriHandler: UriHandler) { val (res, err) = sanitizeUri(uri) if (res == null) { @@ -445,4 +531,4 @@ private fun isRtl(s: CharSequence): Boolean { return false } -private fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" +fun mentionText(name: String): String = if (name.contains(" @")) "@'$name'" else "@$name" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 014a180712..0cec9ab773 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -32,6 +32,7 @@ import chat.simplex.res.MR import kotlinx.coroutines.* import kotlinx.datetime.Clock +// Spec: spec/client/chat-list.md#ChatListNavLinkView @Composable fun ChatListNavLinkView(chat: Chat, nextChatSelected: State) { val showMenu = remember { mutableStateOf(false) } @@ -315,7 +316,7 @@ fun GroupMenuItems( } } GroupMemberStatus.MemAccepted -> { - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { @@ -337,7 +338,7 @@ fun GroupMenuItems( } } ClearChatAction(chat, showMenu) - if (groupInfo.membership.memberCurrentOrPending) { + if (groupInfo.membership.memberCurrentOrPending && !(groupInfo.useRelays && groupInfo.isOwner)) { LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu) } if (groupInfo.canDelete) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt index 2109e21bfe..a42f66c6cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt @@ -122,6 +122,7 @@ fun ToggleChatListCard() { } } +// Spec: spec/client/chat-list.md#ChatListView @Composable fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow, setPerformLA: (Boolean) -> Unit, stopped: Boolean) { val oneHandUI = remember { appPrefs.oneHandUI.state } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 4280845867..f5e0389043 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -35,6 +35,7 @@ import chat.simplex.common.views.chat.item.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource +// Spec: spec/client/chat-list.md#ChatPreviewView @Composable fun ChatPreviewView( chat: Chat, @@ -240,7 +241,7 @@ fun ChatPreviewView( Text(previewText.first, color = previewText.second) } else if (ci != null && showChatPreviews) { val (text: CharSequence, inlineTextContent) = when { - ci.meta.itemDeleted == null -> ci.text to null + ci.meta.itemDeleted == null -> ci.text(chat.chatInfo.isChannel) to null else -> markedDeletedText(ci, chat.chatInfo) to null } val formattedText = when { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt index 8dfe138da1..c6cc887655 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt @@ -43,6 +43,7 @@ import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +// Spec: spec/client/chat-list.md#TagListView @Composable fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) { val userTags = remember { chatModel.userTags } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt index ed74e083e7..a02e0dc768 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.* private val USER_PICKER_SECTION_SPACING = 32.dp +// Spec: spec/client/chat-list.md#UserPicker @Composable fun UserPicker( chatModel: ChatModel, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt index 9264ca69af..4aeb929624 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -117,12 +118,25 @@ fun DatabaseErrorView( OpenDatabaseDirectoryButton() } is MigrationError.Downgrade -> { + val warnings = downMigrationWarnings(err.downMigrations).reversed() DatabaseErrorDetails(MR.strings.database_downgrade) { TextButton({ callRunChat(confirmMigrations = MigrationConfirmation.YesUpDown) }, Modifier.align(Alignment.CenterHorizontally), enabled = !progressIndicator.value) { Text(generalGetString(MR.strings.downgrade_and_open_chat)) } Spacer(Modifier.height(20.dp)) + Icon( + painterResource(MR.images.ic_warning_filled), + contentDescription = null, + Modifier.size(40.dp).align(Alignment.CenterHorizontally), + tint = Color.Red + ) + Spacer(Modifier.height(12.dp)) Text(generalGetString(MR.strings.database_downgrade_warning), fontWeight = FontWeight.Bold) + if (warnings.isNotEmpty()) { + warnings.forEach { warning -> + Text(warning, fontWeight = FontWeight.Bold) + } + } FileNameText(status.dbFile) MigrationsText(err.downMigrations) AppVersionText() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index dc0a86b8fb..34d8099951 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -272,6 +272,7 @@ class AlertManager { profileName: String, profileFullName: String, profileImage: @Composable () -> Unit, + subtitle: String? = null, confirmText: String = generalGetString(MR.strings.connect_plan_open_chat), onConfirm: () -> Unit, dismissText: String = generalGetString(MR.strings.cancel_verb), @@ -317,6 +318,17 @@ class AlertManager { modifier = Modifier.fillMaxWidth() ) } + if (subtitle != null) { + Spacer(Modifier.height(DEFAULT_PADDING_HALF)) + Text( + subtitle, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.secondary, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + ) + } } Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt index 4827e6ae61..e584fcc11c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.helpers import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.res.MR import kotlinx.serialization.* import java.io.File import java.security.SecureRandom @@ -74,6 +75,7 @@ object DatabaseUtils { } } +// Spec: spec/database.md#DBMigrationResult @Serializable sealed class DBMigrationResult { @Serializable @SerialName("ok") object OK: DBMigrationResult() @@ -107,6 +109,15 @@ data class UpMigration( // val withDown: Boolean ) +fun downMigrationWarnings(downMigrations: List): List { + val warnings = listOf( + "20260222_chat_relays" to MR.strings.down_migration_warning_chat_relays + ) + return warnings.mapNotNull { (key, res) -> + if (downMigrations.contains(key)) generalGetString(res) else null + } +} + @Serializable sealed class MTRError { @Serializable @SerialName("noDown") class NoDown(val dbMigrations: List): MTRError() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt index da16e2b7e7..e8070b5c76 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/TextEditor.kt @@ -32,7 +32,8 @@ fun TextEditor( placeholder: String? = null, contentPadding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), isValid: (String) -> Boolean = { true }, - focusRequester: FocusRequester? = null + focusRequester: FocusRequester? = null, + enabled: Boolean = true ) { var valid by rememberSaveable { mutableStateOf(true) } var focused by rememberSaveable { mutableStateOf(false) } @@ -64,6 +65,7 @@ fun TextEditor( value = value.value, onValueChange = { value.value = it }, modifier = if (focusRequester == null) textFieldModifier else textFieldModifier.focusRequester(focusRequester), + enabled = enabled, textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground, lineHeight = 22.sp), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.None, @@ -83,7 +85,7 @@ fun TextEditor( leadingIcon = null, trailingIcon = null, singleLine = false, - enabled = true, + enabled = enabled, isError = false, interactionSource = remember { MutableInteractionSource() }, colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index db1a0be9da..c4821d1a20 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -114,6 +114,7 @@ fun annotatedStringResource(id: StringResource, vararg args: Any?): AnnotatedStr expect fun SetupClipboardListener() // maximum image file size to be auto-accepted +// Spec: spec/services/files.md#MAX_IMAGE_SIZE const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 @@ -129,6 +130,8 @@ const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE expect fun getAppFileUri(fileName: String): URI +expect fun clearImageCaches() + // https://developer.android.com/training/data-storage/shared/documents-files#bitmap expect suspend fun getLoadedImage(file: CIFile?): Pair? @@ -422,6 +425,7 @@ fun deleteAppFiles() { } catch (e: java.lang.Exception) { Log.e(TAG, "Util deleteAppFiles error: ${e.stackTraceToString()}") } + clearImageCaches() } fun directoryFileCountAndSize(dir: String): Pair { // count, size in bytes diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 6199621c39..cabfbf031e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -474,7 +474,9 @@ private fun MutableState.MigrationConfirmationView(status: DB Tuple4( generalGetString(MR.strings.database_downgrade), generalGetString(MR.strings.downgrade_and_open_chat), - generalGetString(MR.strings.database_downgrade_warning), + (listOf(generalGetString(MR.strings.database_downgrade_warning)) + + downMigrationWarnings(err.downMigrations).reversed()) + .joinToString("\n"), MigrationConfirmation.YesUpDown ) is MigrationError.Error -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt new file mode 100644 index 0000000000..cf10f5e545 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -0,0 +1,611 @@ +package chat.simplex.common.views.newchat + +import SectionItemView +import SectionTextFooter +import SectionView +import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatController.getUserServers +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.chat.group.GroupLinkView +import chat.simplex.common.views.chatlist.openGroupChat +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.* +import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView +import chat.simplex.common.views.chat.group.hostFromRelayLink +import chat.simplex.res.MR +import java.net.URI +import dev.icerock.moko.resources.compose.painterResource +import kotlinx.coroutines.* + +@Composable +fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit) { + val view = LocalMultiplatformView() + val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden) + val scope = rememberCoroutineScope() + val displayName = rememberSaveable { mutableStateOf("") } + val chosenImage = rememberSaveable { mutableStateOf(null) } + val profileImage = rememberSaveable { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val hasRelays = rememberSaveable { mutableStateOf(true) } + val groupInfo = remember { mutableStateOf(null) } + val groupLink = rememberSaveable(stateSaver = GroupLink.nullableStateSaver) { mutableStateOf(null) } + val groupRelays = remember { mutableStateOf>(emptyList()) } + val creationInProgress = rememberSaveable { mutableStateOf(false) } + val showLinkStep = rememberSaveable { mutableStateOf(false) } + val relayListExpanded = rememberSaveable { mutableStateOf(false) } + + val gInfo = groupInfo.value + if (showLinkStep.value && gInfo != null) { + LinkStepView(chatModel, gInfo, groupLink, closeAll) + } else if (gInfo != null) { + ProgressStepView( + chatModel, gInfo, groupRelays, relayListExpanded, + onLinkReady = if (appPlatform.isDesktop) { + { + chatModel.creatingChannelId.value = null + closeAll() + withBGApi { + openGroupChat(null, gInfo.groupId) + ModalManager.end.showModalCloseable(true) { close -> + GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, close = close) + } + } + } + } else { + { showLinkStep.value = true } + }, + cancelChannelCreation = { + chatModel.creatingChannelId.value = null + ChannelRelaysModel.reset() + closeAll() + withBGApi { + try { + chatModel.controller.apiDeleteChat(rh = null, type = ChatType.Group, id = gInfo.apiId) + withContext(Dispatchers.Main) { + chatModel.chatsContext.removeChat(null, gInfo.id) + } + } catch (e: Exception) { + Log.e(TAG, "cancelChannelCreation error: ${e.message}") + } + } + } + ) + } else { + ProfileStepView( + chatModel = chatModel, + displayName = displayName, + profileImage = profileImage, + chosenImage = chosenImage, + focusRequester = focusRequester, + hasRelays = hasRelays, + creationInProgress = creationInProgress, + bottomSheetModalState = bottomSheetModalState, + scope = scope, + view = view, + close = close, + createChannel = { + hideKeyboard(view) + val trimmedName = displayName.value.trim() + displayName.value = trimmedName + val profile = GroupProfile( + displayName = trimmedName, + fullName = "", + shortDescr = null, + image = profileImage.value, + groupPreferences = GroupPreferences(history = GroupPreference(GroupFeatureEnabled.ON)) + ) + creationInProgress.value = true + withBGApi { + try { + val enabledRelays = chooseRandomRelays() + val relayIds = enabledRelays.mapNotNull { it.chatRelayId } + if (relayIds.isEmpty()) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + hasRelays.value = false + } + return@withBGApi + } + val result = chatModel.controller.apiNewPublicGroup( + rh = null, + incognito = false, + relayIds = relayIds, + groupProfile = profile + ) + if (result != null) { + val (gI, gL, gR) = result + withContext(Dispatchers.Main) { + chatModel.chatsContext.updateGroup(rhId = null, gI) + chatModel.creatingChannelId.value = gI.id + groupInfo.value = gI + groupLink.value = gL + groupRelays.value = gR.sortedBy { relayDisplayName(it) } + ChannelRelaysModel.set(gI.groupId, gR) + creationInProgress.value = false + } + } else { + withContext(Dispatchers.Main) { creationInProgress.value = false } + } + } catch (e: Exception) { + withContext(Dispatchers.Main) { + creationInProgress.value = false + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.error_creating_channel), + text = e.message + ) + } + } + } + } + ) + } +} + +private const val maxRelays = 3 + +private suspend fun chooseRandomRelays(): List { + val servers = getUserServers(rh = null) ?: return emptyList() + // Operator relays are grouped per operator; custom relays (null operator) + // are treated independently to maximize trust distribution. + val operatorGroups = mutableListOf>() + var customRelays = mutableListOf() + for (op in servers) { + val relays = op.chatRelays.filter { it.enabled && !it.deleted && it.chatRelayId != null } + if (relays.isEmpty()) continue + if (op.operator != null) { + operatorGroups.add(relays.shuffled()) + } else { + customRelays = relays.shuffled().toMutableList() + } + } + val selected = mutableListOf() + // Prefer at least one custom relay when available - + // user's own infrastructure for trust distribution. + if (customRelays.isNotEmpty()) { + selected.add(customRelays.removeAt(0)) + if (selected.size >= maxRelays) return selected + } + // Round-robin across shuffled groups to distribute relays across operators. + val groups = (operatorGroups + customRelays.map { listOf(it) }).shuffled() + val maxDepth = groups.maxOfOrNull { it.size } ?: 0 + for (depth in 0 until maxDepth) { + for (group in groups) { + if (depth < group.size) { + selected.add(group[depth]) + if (selected.size >= maxRelays) return selected + } + } + } + return selected +} + +private suspend fun checkHasRelays(): Boolean { + val servers = try { getUserServers(rh = null) } catch (_: Exception) { null } ?: return false + return servers.any { op -> + op.chatRelays.any { it.enabled && !it.deleted && it.chatRelayId != null } + } +} + +@Composable +private fun ProfileStepView( + chatModel: ChatModel, + displayName: MutableState, + profileImage: MutableState, + chosenImage: MutableState, + focusRequester: FocusRequester, + hasRelays: MutableState, + creationInProgress: MutableState, + bottomSheetModalState: ModalBottomSheetState, + scope: CoroutineScope, + view: Any?, + close: () -> Unit, + createChannel: () -> Unit +) { + LaunchedEffect(Unit) { + hasRelays.value = checkHasRelays() + } + + ModalBottomSheetLayout( + scrimColor = Color.Black.copy(alpha = 0.12F), + modifier = Modifier.imePadding(), + sheetContent = { + GetImageBottomSheet( + chosenImage, + onImageChange = { bitmap -> profileImage.value = resizeImageToStrSize(cropToSquare(bitmap), maxDataSize = 12500) }, + hideBottomSheet = { + scope.launch { bottomSheetModalState.hide() } + } + ) + }, + sheetState = bottomSheetModalState, + sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) + ) { + ModalView(close = close) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.create_channel_title)) + Box( + Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + contentAlignment = Alignment.Center + ) { + Box(contentAlignment = Alignment.TopEnd) { + Box(contentAlignment = Alignment.Center) { + ProfileImage(108.dp, image = profileImage.value) + EditImageButton { scope.launch { bottomSheetModalState.show() } } + } + if (profileImage.value != null) { + DeleteImageButton { profileImage.value = null } + } + } + } + Row( + Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + generalGetString(MR.strings.channel_display_name_field), + fontSize = 16.sp + ) + if (!isValidDisplayName(displayName.value.trim())) { + Spacer(Modifier.size(DEFAULT_PADDING_HALF)) + IconButton({ showInvalidNameAlert(mkValidName(displayName.value.trim()), displayName) }, Modifier.size(20.dp)) { + Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error) + } + } + } + Box(Modifier.padding(horizontal = DEFAULT_PADDING)) { + ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester) + } + Spacer(Modifier.height(8.dp)) + + SettingsActionItem( + painterResource(MR.images.ic_wifi_tethering), + generalGetString(MR.strings.configure_relays), + click = { + ModalManager.start.showCustomModal { close -> + NetworkAndServersView(close) + } + }, + textColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange, + iconColor = if (hasRelays.value) MaterialTheme.colors.primary else WarningOrange + ) + + val canCreate = canCreateProfile(displayName.value) && hasRelays.value && !creationInProgress.value + SettingsActionItem( + painterResource(MR.images.ic_check), + generalGetString(MR.strings.create_channel_button), + click = createChannel, + textColor = MaterialTheme.colors.primary, + iconColor = MaterialTheme.colors.primary, + disabled = !canCreate + ) + + SectionTextFooter( + if (!hasRelays.value) { + generalGetString(MR.strings.enable_at_least_one_chat_relay) + } else { + val name = chatModel.currentUser.value?.displayName ?: "" + String.format(generalGetString(MR.strings.your_profile_shared_with_channel_relays), name) + } + ) + + LaunchedEffect(Unit) { + delay(1000) + focusRequester.requestFocus() + } + } + } + } +} + +@Composable +private fun ProgressStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupRelays: MutableState>, + relayListExpanded: MutableState, + onLinkReady: () -> Unit, + cancelChannelCreation: () -> Unit +) { + val failedCount = groupRelays.value.count { relayMemberConnFailed(chatModel, it) != null } + val activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val total = groupRelays.value.size + + if (appPlatform.isDesktop) { + DisposableEffect(Unit) { + chatModel.centerPanelBackgroundClickHandler = { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.cancel_creating_channel_question), + confirmText = generalGetString(MR.strings.cancel_creating_channel_confirm), + onConfirm = cancelChannelCreation, + dismissText = generalGetString(MR.strings.wait_verb), + destructive = true, + ) + true + } + onDispose { + chatModel.centerPanelBackgroundClickHandler = null + } + } + } + + LaunchedEffect(gInfo.groupId) { + snapshotFlow { ChannelRelaysModel.groupRelays.toList() } + .collect { relays -> + if (ChannelRelaysModel.groupId.value != gInfo.groupId) return@collect + groupRelays.value = relays.sortedBy { relayDisplayName(it) } + if (relays.all { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null }) { + onLinkReady() + ChannelRelaysModel.reset() + } + } + } + + ModalView( + close = cancelChannelCreation, + showClose = false, + endButtons = { + TextButton(onClick = cancelChannelCreation) { + Text(generalGetString(MR.strings.cancel_verb)) + } + } + ) { + ColumnWithScrollBar { + AppBarTitle(generalGetString(MR.strings.creating_channel)) + + Box( + Modifier.fillMaxWidth().padding(bottom = 8.dp), + contentAlignment = Alignment.Center + ) { + ProfileImage(108.dp, image = gInfo.groupProfile.image) + } + Text( + gInfo.groupProfile.displayName, + style = MaterialTheme.typography.h6, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + textAlign = TextAlign.Center + ) + + SectionView { + SectionItemView(click = { relayListExpanded.value = !relayListExpanded.value }) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (activeCount + failedCount < total) { + RelayProgressIndicator(active = activeCount, total = total) + } + val statusText = if (failedCount > 0) { + String.format(generalGetString(MR.strings.relay_bar_active_with_failures), activeCount, total, failedCount) + } else { + String.format(generalGetString(MR.strings.relay_bar_active), activeCount, total) + } + Text(statusText, modifier = Modifier.weight(1f)) + Icon( + painterResource(if (relayListExpanded.value) MR.images.ic_chevron_up else MR.images.ic_chevron_down), + contentDescription = null, + tint = MaterialTheme.colors.secondary, + modifier = Modifier.size(20.dp) + ) + } + } + if (relayListExpanded.value) { + groupRelays.value.forEach { relay -> + val failedErr = relayMemberConnFailed(chatModel, relay) + if (failedErr != null) { + SectionItemView( + click = { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_connection_failed), + text = failedErr + ) + }, + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = true) + } + } else { + SectionItemView( + minHeight = 30.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp) + ) { + RelayRow(relay, connFailed = false) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + SectionView { + val enabled = activeCount > 0 + SettingsActionItem( + painterResource(MR.images.ic_link), + generalGetString(MR.strings.channel_link), + click = { + if (activeCount >= total) { + onLinkReady() + } else if (activeCount > 0) { + val alertText = String.format( + generalGetString(MR.strings.channel_will_start_with_relays), + activeCount, total + ) + if (activeCount + failedCount < total) { + AlertManager.shared.showAlertDialogButtons( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + buttons = { + Row(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.SpaceBetween) { + TextButton(onClick = { AlertManager.shared.hideAlert() }) { + Text(generalGetString(MR.strings.wait_verb)) + } + TextButton(onClick = { + AlertManager.shared.hideAlert() + onLinkReady() + }) { + Text(generalGetString(MR.strings.proceed_verb)) + } + } + } + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.not_all_relays_connected), + text = alertText, + confirmText = generalGetString(MR.strings.proceed_verb), + onConfirm = { onLinkReady() } + ) + } + } + }, + textColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + iconColor = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + disabled = !enabled + ) + } + } + } +} + +private fun relayMemberConnFailed(chatModel: ChatModel, relay: GroupRelay): String? { + return chatModel.groupMembers.value + .firstOrNull { it.groupMemberId == relay.groupMemberId } + ?.activeConn?.connFailedErr +} + +@Composable +private fun RelayRow(relay: GroupRelay, connFailed: Boolean) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text(relayDisplayName(relay)) + RelayStatusIndicator(relay.relayStatus, connFailed = connFailed) + } +} + +@Composable +private fun LinkStepView( + chatModel: ChatModel, + gInfo: GroupInfo, + groupLink: MutableState, + closeAll: () -> Unit +) { + val close: () -> Unit = { + chatModel.creatingChannelId.value = null + withBGApi { + delay(500) + withContext(Dispatchers.Main) { + ModalManager.start.closeModals() + openGroupChat(null, gInfo.groupId) + } + } + } + ModalView(close = close, showClose = false) { + GroupLinkView( + chatModel = chatModel, + rhId = null, + groupInfo = gInfo, + groupLink = groupLink.value, + onGroupLinkUpdated = { groupLink.value = it }, + creatingGroup = true, + isChannel = true, + close = close + ) + } +} + +fun relayDisplayName(relay: GroupRelay): String { + if (relay.userChatRelay.displayName.isNotEmpty()) return relay.userChatRelay.displayName + relay.userChatRelay.domains.firstOrNull()?.let { return it } + relay.relayLink?.let { return hostFromRelayLink(it) } + return "relay ${relay.groupRelayId}" +} + + +@Composable +fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false) { + val color = if (connFailed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow + val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else status.text + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Canvas(Modifier.size(8.dp)) { + drawCircle(color = color) + } + Text( + text, + fontSize = 12.sp, + color = MaterialTheme.colors.secondary + ) + if (connFailed) { + Icon( + painterResource(MR.images.ic_error), + contentDescription = null, + tint = MaterialTheme.colors.primary, + modifier = Modifier.size(14.dp) + ) + } + } +} + +@Composable +fun RelayProgressIndicator(active: Int, total: Int) { + if (active == 0) { + CircularProgressIndicator( + Modifier.size(20.dp), + strokeWidth = 2.5.dp + ) + } else { + val progress = active.toFloat() / total.coerceAtLeast(1).toFloat() + Box(Modifier.size(20.dp)) { + Canvas(Modifier.fillMaxSize()) { + // Background circle + drawCircle( + color = Color.Gray.copy(alpha = 0.3f), + style = Stroke(width = 2.5.dp.toPx()) + ) + // Progress arc + drawArc( + color = Color(0xFF2196F3), // accent blue + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + style = Stroke(width = 2.5.dp.toPx(), cap = StrokeCap.Round) + ) + } + } + } +} + +@Preview +@Composable +fun PreviewAddChannelView() { + SimpleXTheme { + AddChannelView(chatModel = ChatModel, close = {}, closeAll = {}) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt index e8084e055a..0494cbb463 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt @@ -56,7 +56,7 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c } } else { ModalManager.end.showModalCloseable(true) { close -> - GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close) + GroupLinkView(chatModel, rhId, groupInfo, groupLink = null, onGroupLinkUpdated = null, creatingGroup = true, close = close) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 434cb6ce27..68c7d5b3f1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.unit.dp import dev.icerock.moko.resources.compose.stringResource import chat.simplex.common.model.* import chat.simplex.common.platform.* +import chat.simplex.common.views.chat.subscriberCountStr import chat.simplex.common.views.chatlist.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -28,6 +29,15 @@ suspend fun planAndConnect( filterKnownContact: ((Contact) -> Unit)? = null, filterKnownGroup: ((GroupInfo) -> Unit)? = null, ): CompletableDeferred { + val link = strHasSingleSimplexLink(shortOrFullLink.trim()) + if (link?.format is Format.SimplexLink && (link.format as Format.SimplexLink).linkType == SimplexLinkType.relay) { + AlertManager.privacySensitive.showAlertMsg( + generalGetString(MR.strings.relay_address_alert_title), + generalGetString(MR.strings.relay_address_alert_message), + ) + cleanup?.invoke() + return CompletableDeferred(false) + } connectProgressManager.cancelConnectProgress() val inProgress = mutableStateOf(true) connectProgressManager.startConnectProgress(generalGetString(MR.strings.loading_profile)) { @@ -203,6 +213,7 @@ private suspend fun planAndConnectTask( showPrepareGroupAlert( rhId, connectionLink, + connectionPlan.groupLinkPlan.groupSLinkInfo_, connectionPlan.groupLinkPlan.groupSLinkData_, close, cleanup @@ -421,52 +432,79 @@ fun ownGroupLinkConfirmConnect( close: (() -> Unit)?, cleanup: (() -> Unit)?, ) { - AlertManager.privacySensitive.showAlertDialogButtonsColumn( - title = generalGetString(MR.strings.connect_plan_join_your_group), - text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, - buttons = { - Column { - // Open group - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - openKnownGroup(chatModel, rhId, close, groupInfo) - cleanup?.invoke() - }) { - Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) - } - // Use current profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + if (groupInfo.useRelays) { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_channel_vName), groupInfo.displayName), + buttons = { + Column { + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_channel), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) - } - // Use new incognito profile - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - withBGApi { - connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } - }) { - Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) } - // Cancel - SectionItemView({ - AlertManager.privacySensitive.hideAlert() - cleanup?.invoke() - }) { - Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } else { + AlertManager.privacySensitive.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.connect_plan_join_your_group), + text = String.format(generalGetString(MR.strings.connect_plan_this_is_your_link_for_group_vName), groupInfo.displayName) + linkText, + buttons = { + Column { + // Open group + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + openKnownGroup(chatModel, rhId, close, groupInfo) + cleanup?.invoke() + }) { + Text(generalGetString(MR.strings.connect_plan_open_group), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } + // Use current profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = false, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Use new incognito profile + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + withBGApi { + connectViaUri(chatModel, rhId, connectionLink, incognito = true, connectionPlan, close, cleanup) + } + }) { + Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error) + } + // Cancel + SectionItemView({ + AlertManager.privacySensitive.hideAlert() + cleanup?.invoke() + }) { + Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } - } - }, - onDismissRequest = cleanup, - hostDevice = hostDevice(rhId), - ) + }, + onDismissRequest = cleanup, + hostDevice = hostDevice(rhId), + ) + } } private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (() -> Unit)?, groupInfo: GroupInfo) { + val subscriberCount = if (groupInfo.useRelays) groupInfo.groupSummary.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupInfo.groupProfile.displayName, profileFullName = groupInfo.groupProfile.fullName, @@ -477,8 +515,11 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( icon = groupInfo.chatIconName ) }, + subtitle = subscriberCount, confirmText = generalGetString( - if (groupInfo.businessChat == null) { + if (groupInfo.useRelays) { + if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel + } else if (groupInfo.businessChat == null) { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group } else { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat @@ -544,21 +585,39 @@ fun showPrepareContactAlert( fun showPrepareGroupAlert( rhId: Long?, connectionLink: CreatedConnLink, + groupShortLinkInfo: GroupShortLinkInfo?, groupShortLinkData: GroupShortLinkData, close: (() -> Unit)?, cleanup: (() -> Unit)? ) { + val isChannel = !(groupShortLinkInfo?.direct ?: true) + val subscriberCount = if (isChannel) groupShortLinkData.publicGroupData?.publicMemberCount?.let { subscriberCountStr(it) } else null AlertManager.privacySensitive.showOpenChatAlert( profileName = groupShortLinkData.groupProfile.displayName, profileFullName = groupShortLinkData.groupProfile.fullName, - profileImage = { ProfileImage(size = alertProfileImageSize, image = groupShortLinkData.groupProfile.image, icon = MR.images.ic_supervised_user_circle_filled) }, - confirmText = generalGetString(MR.strings.connect_plan_open_new_group), + profileImage = { + ProfileImage( + size = alertProfileImageSize, + image = groupShortLinkData.groupProfile.image, + icon = if (isChannel) MR.images.ic_bigtop_updates_padded else MR.images.ic_supervised_user_circle_filled + ) + }, + subtitle = subscriberCount, + confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { - val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, groupShortLinkData) + val directLink = groupShortLinkInfo?.direct ?: true + val chat = chatModel.controller.apiPrepareGroup(rhId, connectionLink, directLink = directLink, groupShortLinkData) if (chat != null) { withContext(Dispatchers.Main) { + val relays = groupShortLinkInfo?.groupRelays + if (!relays.isNullOrEmpty()) { + val chatInfo = chat.chatInfo + if (chatInfo is ChatInfo.Group) { + chatModel.channelRelayHostnames[chatInfo.groupInfo.groupId] = relays + } + } ChatController.chatModel.chatsContext.addChat(chat) openChat_(chatModel, rhId, close, chat) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt index ef6e426141..292aa10f70 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatSheet.kt @@ -63,6 +63,9 @@ fun ModalData.NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) { createGroup = { ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) } }, + createChannel = { + ModalManager.start.showCustomModal { close -> AddChannelView(chatModel, close, closeAll) } + }, rh = rh, close = close ) @@ -110,6 +113,7 @@ private fun ModalData.NewChatSheetLayout( addContact: () -> Unit, scanPaste: () -> Unit, createGroup: () -> Unit, + createChannel: () -> Unit, close: () -> Unit, ) { val oneHandUI = remember { appPrefs.oneHandUI.state } @@ -193,6 +197,11 @@ private fun ModalData.NewChatSheetLayout( painterResource(MR.images.ic_group), stringResource(MR.strings.create_group_button), createGroup, + ), + Triple( + painterResource(MR.images.ic_bigtop_updates), + stringResource(MR.strings.create_channel_beta_button), + createChannel, ) ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt new file mode 100644 index 0000000000..1c68e780dc --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ChatRelayView.kt @@ -0,0 +1,416 @@ +package chat.simplex.common.views.usersettings.networkAndServers + +import SectionBottomSpacer +import SectionDividerSpaced +import SectionItemView +import SectionItemViewSpaceBetween +import SectionTextFooter +import SectionView +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.sp +import androidx.compose.ui.graphics.Color +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.platform.* +import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.* +import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.usersettings.PreferenceToggle +import chat.simplex.res.MR +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +@Composable +fun ShowRelayTestStatus(relay: UserChatRelay, modifier: Modifier = Modifier) = + when (relay.tested) { + true -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = SimplexGreen) + false -> Icon(painterResource(MR.images.ic_close), null, modifier, tint = MaterialTheme.colors.error) + else -> Icon(painterResource(MR.images.ic_check), null, modifier, tint = Color.Transparent) + } + +fun validRelayName(name: String): Boolean = + name.isNotEmpty() && isValidDisplayName(name) + +fun showInvalidRelayNameAlert(name: MutableState) { + val validName = mkValidName(name.value) + if (validName.isEmpty()) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_name) + ) + } else { + AlertManager.shared.showAlertDialog( + title = generalGetString(MR.strings.invalid_name), + text = String.format(generalGetString(MR.strings.correct_name_to), validName), + onConfirm = { + name.value = validName + } + ) + } +} + +fun validRelayAddress(address: String): Boolean { + val parsedMd = parseToMarkdown(address) + return parsedMd != null && + parsedMd.size == 1 && + parsedMd.first().format is Format.SimplexLink && + (parsedMd.first().format as Format.SimplexLink).linkType == SimplexLinkType.relay +} + +fun addChatRelay( + relay: UserChatRelay, + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>?, + rhId: Long?, + close: () -> Unit +) { + val nameEmpty = relay.displayName.trim().isEmpty() + val addressEmpty = relay.address.trim().isEmpty() + if (nameEmpty && addressEmpty) { + close() + } else if (!validRelayName(relay.displayName)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else if (!validRelayAddress(relay.address)) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } else { + val i = userServers.value.indexOfFirst { it.operator == null } + if (i != -1) { + val updatedUserServers = userServers.value.toMutableList() + val operatorServers = updatedUserServers[i] + updatedUserServers[i] = operatorServers.copy( + chatRelays = operatorServers.chatRelays + relay + ) + userServers.value = updatedUserServers + withBGApi { + validateServers_(rhId, userServers.value, serverErrors, serverWarnings) + } + close() + } else { // Shouldn't happen + close() + AlertManager.shared.showAlertMsg(title = generalGetString(MR.strings.error_adding_relay)) + } + } +} + +@Composable +fun ChatRelayView( + relay: UserChatRelay, + onDelete: () -> Unit, + onUpdate: (UserChatRelay) -> Unit, + close: () -> Unit +) { + val relayToEdit = remember { mutableStateOf(relay) } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + if (relayToEdit.value.address == relay.address) { + relayToEdit.value = relayToEdit.value.copy(tested = relay.tested, relayProfile = relay.relayProfile) + } else { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + } + + ModalView( + close = { + val validName = validRelayName(relayToEdit.value.displayName) + val validAddress = validRelayAddress(relayToEdit.value.address) + if (validName && validAddress) { + onUpdate(relayToEdit.value) + close() + } else if (!validName) { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_name), + text = generalGetString(MR.strings.check_relay_name) + ) + } else { + close() + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.invalid_relay_address), + text = generalGetString(MR.strings.check_relay_address) + ) + } + } + ) { + ChatRelayLayout( + relayToEdit, + onDelete = onDelete + ) + } +} + +@Composable +private fun ChatRelayLayout( + relay: MutableState, + onDelete: (() -> Unit)? +) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.chat_relay)) + if (relay.value.preset) { + PresetRelay(relay, testing) + } else { + CustomRelay(relay, onDelete, testing) + } + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +@Composable +private fun PresetRelay(relay: MutableState, testing: MutableState) { + SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SelectionContainer { + Text( + relay.value.address, + Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp), + color = MaterialTheme.colors.secondary + ) + } + } + SectionDividerSpaced() + SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionItemView { + Text(relay.value.displayName) + } + } + SectionDividerSpaced() + UseRelaySection(relay, testing = testing) +} + +@Composable +private fun CustomRelay( + relay: MutableState, + onDelete: (() -> Unit)?, + testing: MutableState +) { + val relayName = remember { mutableStateOf(relay.value.displayName) } + val relayAddress = remember { mutableStateOf(relay.value.address) } + val validName = remember { derivedStateOf { validRelayName(relayName.value) } } + val validAddress = remember { derivedStateOf { validRelayAddress(relayAddress.value) } } + + LaunchedEffect(Unit) { + snapshotFlow { relayName.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copyWithName(it) } + } + LaunchedEffect(Unit) { + snapshotFlow { relay.value.displayName } + .distinctUntilChanged() + .collect { relayName.value = it } + } + LaunchedEffect(Unit) { + snapshotFlow { relayAddress.value } + .distinctUntilChanged() + .collect { relay.value = relay.value.copy(address = it) } + } + + SectionView( + stringResource(MR.strings.your_relay_address).uppercase(), + icon = painterResource(MR.images.ic_error), + iconTint = if (!validAddress.value) MaterialTheme.colors.error else Color.Transparent, + ) { + TextEditor( + relayAddress, + Modifier.height(144.dp) + ) + } + SectionDividerSpaced(maxTopPadding = true) + + Column { + val iconSize = with(LocalDensity.current) { 21.sp.toDp() } + Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(MR.strings.your_relay_name).uppercase(), + color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp + ) + IconButton( + onClick = { if (!validName.value) showInvalidRelayNameAlert(relayName) }, + enabled = !validName.value, + modifier = Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize) + ) { + Icon( + painterResource(MR.images.ic_error), null, + tint = if (!validName.value) MaterialTheme.colors.error else Color.Transparent + ) + } + } + Column(Modifier.fillMaxWidth()) { + TextEditor( + relayName, + Modifier, + placeholder = generalGetString(MR.strings.enter_relay_name), + enabled = relay.value.tested != true + ) + } + } + if (relay.value.tested != true) { + SectionTextFooter(annotatedStringResource(MR.strings.test_relay_to_retrieve_name)) + } + SectionDividerSpaced(maxTopPadding = true) + + UseRelaySection(relay, validAddress.value, testing) + + if (onDelete != null) { + SectionDividerSpaced() + SectionView { + SectionItemView(onDelete) { + Text(stringResource(MR.strings.delete_relay), color = MaterialTheme.colors.error) + } + } + } +} + +@Composable +private fun UseRelaySection( + relay: MutableState, + valid: Boolean = true, + testing: MutableState +) { + val scope = rememberCoroutineScope() + SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionItemViewSpaceBetween( + click = { + testing.value = true + relay.value = relay.value.copy(tested = null) + scope.launch { + val f = testRelayConnection(relay) + if (f != null) { + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.relay_test_failed_alert), + text = f.localizedDescription + ) + } + testing.value = false + } + }, + disabled = !valid || testing.value + ) { + Text( + stringResource(MR.strings.test_relay), + color = if (valid && !testing.value) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary + ) + ShowRelayTestStatus(relay.value) + } + + val enabled = rememberUpdatedState(relay.value.enabled) + PreferenceToggle( + stringResource(MR.strings.use_for_new_channels), + checked = enabled.value + ) { + relay.value = relay.value.copy(enabled = it) + } + } +} + +@Composable +fun ChatRelayViewLink( + relay: UserChatRelay, + duplicateRelayAddresses: Set, + onClick: () -> Unit +) { + SectionItemView(onClick) { + Box(Modifier.width(16.dp)) { + when { + relay.address in duplicateRelayAddresses -> InvalidServer() + !relay.enabled -> Icon(painterResource(MR.images.ic_do_not_disturb_on), null, tint = MaterialTheme.colors.secondary) + else -> ShowRelayTestStatus(relay) + } + } + Spacer(Modifier.padding(horizontal = 4.dp)) + val displayName = relay.displayName.ifEmpty { relay.domains.firstOrNull() ?: relay.address } + if (relay.enabled) { + Text(displayName, color = MaterialTheme.colors.onBackground, maxLines = 1) + } else { + Text(displayName, maxLines = 1, color = MaterialTheme.colors.secondary) + } + } +} + +@Composable +fun ModalData.NewChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + rhId: Long?, + close: () -> Unit +) { + val relayToEdit = remember { + mutableStateOf( + UserChatRelay( + chatRelayId = null, address = "", relayProfile = RelayProfile(displayName = "", fullName = ""), domains = emptyList(), + preset = false, tested = null, enabled = true, deleted = false + ) + ) + } + + LaunchedEffect(Unit) { + snapshotFlow { relayToEdit.value.address } + .distinctUntilChanged() + .collect { + relayToEdit.value = relayToEdit.value.copy(tested = null) + } + } + + ModalView(close = { + addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) + }) { + NewChatRelayLayout(relayToEdit) + } +} + +@Composable +private fun NewChatRelayLayout(relay: MutableState) { + val testing = remember { mutableStateOf(false) } + Box { + ColumnWithScrollBar { + AppBarTitle(stringResource(MR.strings.new_chat_relay)) + CustomRelay(relay, onDelete = null, testing = testing) + SectionBottomSpacer() + } + if (testing.value) { + DefaultProgressView(null) + } + } +} + +suspend fun testRelayConnection(relay: MutableState): RelayTestFailure? = + try { + val (relayProfile, testFailure) = chatModel.controller.testChatRelay(chatModel.remoteHostId(), relay.value.address) + if (testFailure != null) { + relay.value = relay.value.copy(tested = false) + testFailure + } else { + relay.value = relay.value.copy(tested = true).let { + if (relayProfile != null) it.copyWithName(relayProfile.displayName) else it + } + null + } + } catch (e: Exception) { + Log.e(TAG, "testRelayConnection ${e.stackTraceToString()}") + relay.value = relay.value.copy(tested = false) + null + } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt index 26ecf151ff..bbd2a0af49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt @@ -54,6 +54,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { val currUserServers = remember { stateGetOrPut("currUserServers") { emptyList() } } val userServers = remember { stateGetOrPut("userServers") { emptyList() } } val serverErrors = remember { stateGetOrPut("serverErrors") { emptyList() } } + val serverWarnings = remember { stateGetOrPut("serverWarnings") { emptyList() } } val proxyPort = remember { derivedStateOf { appPrefs.networkProxy.state.value.port } } fun onClose(close: () -> Unit): Boolean = if (!serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value)) { @@ -91,6 +92,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, toggleSocksProxy = { enable -> val def = NetCfg.defaults val proxyDef = NetCfg.proxyDefaults @@ -158,6 +160,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onionHosts: MutableState, currUserServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, userServers: MutableState>, toggleSocksProxy: (Boolean) -> Unit, ) { @@ -209,7 +212,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (!chatModel.desktopNoUserNoRemote) { SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { userServers.value.forEachIndexed { index, srv -> - srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, currentRemoteHost?.remoteHostId) } + srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } } if (conditionsAction != null && anyOperatorEnabled.value) { @@ -234,6 +237,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { YourServersView( userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = nullOperatorIndex, rhId = currentRemoteHost?.remoteHostId ) @@ -284,6 +288,12 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { ServersErrorFooter(generalGetString(MR.strings.errors_in_servers_configuration)) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced() @@ -664,6 +674,7 @@ private fun ServerOperatorRow( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { SectionItemView( @@ -673,6 +684,7 @@ private fun ServerOperatorRow( currUserServers, userServers, serverErrors, + serverWarnings, index, rhId ) @@ -848,6 +860,30 @@ fun ServersErrorFooter(errStr: String) { } } +@Composable +fun ServersWarningFooter(warnStr: String) { + Row( + Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painterResource(MR.images.ic_warning), + contentDescription = stringResource(MR.strings.server_warning), + tint = WarningOrange, + modifier = Modifier + .size(19.sp.toDp()) + .offset(x = 2.sp.toDp()) + ) + TextIconSpaced() + Text( + warnStr, + color = MaterialTheme.colors.secondary, + lineHeight = 18.sp, + fontSize = 14.sp + ) + } +} + private fun showUnsavedChangesAlert(save: () -> Unit, revert: () -> Unit) { AlertManager.shared.showAlertDialogStacked( title = generalGetString(MR.strings.smp_save_servers_question), @@ -887,11 +923,13 @@ fun updateOperatorsConditionsAcceptance(usvs: MutableState, - serverErrors: MutableState> + serverErrors: MutableState>, + serverWarnings: MutableState>? = null ) { try { - val errors = chatController.validateServers(rhId, userServersToValidate) ?: return + val (errors, warnings) = chatController.validateServers(rhId, userServersToValidate) ?: return serverErrors.value = errors + serverWarnings?.value = warnings } catch (ex: Exception) { Log.e(TAG, ex.stackTraceToString()) } @@ -914,6 +952,15 @@ fun globalServersError(serverErrors: List): String? { return null } +fun globalServersWarning(serverWarnings: List): String? { + for (warn in serverWarnings) { + if (warn.globalWarning != null) { + return warn.globalWarning + } + } + return null +} + fun globalSMPServersError(serverErrors: List): String? { for (err in serverErrors) { if (err.globalSMPError != null) { @@ -943,6 +990,9 @@ fun findDuplicateHosts(serverErrors: List): Set { return duplicateHostsList.toSet() } +fun findDuplicateRelayAddresses(serverErrors: List): Set = + serverErrors.mapNotNull { (it as? UserServersError.DuplicateChatRelayAddress)?.duplicateAddress }.toSet() + private suspend fun saveServers( rhId: Long?, currUserServers: MutableState>, @@ -987,7 +1037,8 @@ fun PreviewNetworkAndServersLayout() { toggleSocksProxy = {}, currUserServers = remember { mutableStateOf(emptyList()) }, userServers = remember { mutableStateOf(emptyList()) }, - serverErrors = remember { mutableStateOf(emptyList()) } + serverErrors = remember { mutableStateOf(emptyList()) }, + serverWarnings = remember { mutableStateOf(emptyList()) } ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt index 6a999aa89d..a3a843d034 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NewServerView.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.* fun ModalData.NewServerView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long?, close: () -> Unit ) { @@ -28,6 +29,7 @@ fun ModalData.NewServerView( newServer.value, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -101,6 +103,7 @@ fun addServer( server: UserServer, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>? = null, rhId: Long?, close: () -> Unit ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt index c619ae6ebc..1449e0cd0d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt @@ -47,6 +47,7 @@ fun OperatorView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -57,7 +58,7 @@ fun OperatorView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -68,9 +69,10 @@ fun OperatorView( currUserServers, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -87,6 +89,7 @@ fun OperatorView( fun navigateToProtocolView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, serverIndex: Int, @@ -100,6 +103,7 @@ fun navigateToProtocolView( serverProtocol = protocol, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, onDelete = { if (protocol == ServerProtocol.SMP) { deleteSMPServer(userServers, operatorIndex, serverIndex) @@ -130,11 +134,42 @@ fun navigateToProtocolView( } } +fun navigateToChatRelayView( + userServers: MutableState>, + serverErrors: MutableState>, + serverWarnings: MutableState>, + operatorIndex: Int, + relayIndex: Int, + relay: UserChatRelay, + rhId: Long? +) { + ModalManager.start.showCustomModal { close -> + ChatRelayView( + relay = relay, + onDelete = { + deleteChatRelay(userServers, operatorIndex, relayIndex) + close() + }, + onUpdate = { updatedRelay -> + userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + chatRelays = this[operatorIndex].chatRelays.toMutableList().apply { + this[relayIndex] = updatedRelay + } + ) + } + }, + close = close + ) + } +} + @Composable fun OperatorViewLayout( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -170,15 +205,21 @@ fun OperatorViewLayout( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId ) } val serversErr = globalServersError(serverErrors.value) + val serversWarn = globalServersWarning(serverWarnings.value) if (serversErr != null) { SectionCustomFooter { ServersErrorFooter(serversErr) } + } else if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } } else { val footerText = when (val c = operator.conditionsAcceptance) { is ConditionsAcceptance.Accepted -> if (c.acceptedAt != null) { @@ -194,6 +235,21 @@ fun OperatorViewLayout( } if (operator.enabled) { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> + if (!relay.deleted) { + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, index, relay, rhId) + } + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { @@ -387,21 +443,30 @@ fun OperatorViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) } SectionBottomSpacer() @@ -458,6 +523,7 @@ private fun UseOperatorToggle( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -485,6 +551,7 @@ private fun UseOperatorToggle( currUserServers = currUserServers, userServers = userServers, serverErrors = serverErrors, + serverWarnings = serverWarnings, operatorIndex = operatorIndex, rhId = rhId, close = close @@ -510,6 +577,7 @@ private fun SingleOperatorUsageConditionsView( currUserServers: MutableState>, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long?, close: () -> Unit diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt index ccad962313..01630a2b52 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServerView.kt @@ -36,6 +36,7 @@ fun ProtocolServerView( serverProtocol: ServerProtocol, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, onDelete: () -> Unit, onUpdate: (UserServer) -> Unit, close: () -> Unit, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt index 63bf8b1dc4..b232c7994e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.launch fun ModalData.YourServersView( userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, rhId: Long? ) { @@ -40,7 +41,7 @@ fun ModalData.YourServersView( LaunchedEffect(userServers) { snapshotFlow { userServers.value } .collect { updatedServers -> - validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors) + validateServers_(rhId = rhId, userServersToValidate = updatedServers, serverErrors = serverErrors, serverWarnings = serverWarnings) } } @@ -51,9 +52,10 @@ fun ModalData.YourServersView( scope, userServers, serverErrors, + serverWarnings, operatorIndex, navigateToProtocolView = { serverIndex, server, protocol -> - navigateToProtocolView(userServers, serverErrors, operatorIndex, rhId, serverIndex, server, protocol) + navigateToProtocolView(userServers, serverErrors, serverWarnings, operatorIndex, rhId, serverIndex, server, protocol) }, currentUser, rhId, @@ -72,6 +74,7 @@ fun YourServersViewLayout( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, operatorIndex: Int, navigateToProtocolView: (Int, UserServer, ServerProtocol) -> Unit, currentUser: User?, @@ -81,7 +84,21 @@ fun YourServersViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { + if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { + val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) + SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> + if (relay.deleted) return@forEachIndexed + ChatRelayViewLink(relay, duplicateRelayAddresses) { + navigateToChatRelayView(userServers, serverErrors, serverWarnings, operatorIndex, i, relay, rhId) + } + } + } + SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages_in_channels)) + } + if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { + SectionDividerSpaced() SectionView(generalGetString(MR.strings.message_servers).uppercase()) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed @@ -150,7 +167,8 @@ fun YourServersViewLayout( if ( userServers.value[operatorIndex].smpServers.any { !it.deleted } || - userServers.value[operatorIndex].xftpServers.any { !it.deleted } + userServers.value[operatorIndex].xftpServers.any { !it.deleted } || + userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) } @@ -159,7 +177,7 @@ fun YourServersViewLayout( SettingsActionItem( painterResource(MR.images.ic_add), stringResource(MR.strings.smp_servers_add), - click = { showAddServerDialog(scope, userServers, serverErrors, rhId) }, + click = { showAddServerDialog(scope, userServers, serverErrors, serverWarnings, rhId) }, disabled = testing.value, textColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary, iconColor = if (testing.value) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -171,6 +189,12 @@ fun YourServersViewLayout( ServersErrorFooter(serversErr) } } + val serversWarn = globalServersWarning(serverWarnings.value) + if (serversWarn != null) { + SectionCustomFooter { + ServersWarningFooter(serversWarn) + } + } SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) SectionView { @@ -178,21 +202,30 @@ fun YourServersViewLayout( testing = testing, smpServers = userServers.value[operatorIndex].smpServers, xftpServers = userServers.value[operatorIndex].xftpServers, - ) { p, l -> - when (p) { - ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { - this[operatorIndex] = this[operatorIndex].copy( - xftpServers = l - ) - } + chatRelays = userServers.value[operatorIndex].chatRelays, + onUpdate = { p, l -> + when (p) { + ServerProtocol.XFTP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + xftpServers = l + ) + } - ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + ServerProtocol.SMP -> userServers.value = userServers.value.toMutableList().apply { + this[operatorIndex] = this[operatorIndex].copy( + smpServers = l + ) + } + } + }, + onUpdateRelays = { relays -> + userServers.value = userServers.value.toMutableList().apply { this[operatorIndex] = this[operatorIndex].copy( - smpServers = l + chatRelays = relays ) } } - } + ) HowToButton() } @@ -204,16 +237,20 @@ fun YourServersViewLayout( fun TestServersButton( smpServers: List, xftpServers: List, + chatRelays: List = emptyList(), testing: MutableState, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? = null ) { val scope = rememberCoroutineScope() - val disabled = derivedStateOf { (smpServers.none { it.enabled } && xftpServers.none { it.enabled }) || testing.value } + val disabled = derivedStateOf { + (smpServers.none { it.enabled } && xftpServers.none { it.enabled } && chatRelays.filter { !it.deleted }.none { it.enabled }) || testing.value + } SectionItemView( { scope.launch { - testServers(testing, smpServers, xftpServers, chatModel, onUpdate) + testServers(testing, smpServers, xftpServers, chatRelays, chatModel, onUpdate, onUpdateRelays) } }, disabled = disabled.value @@ -226,6 +263,7 @@ fun showAddServerDialog( scope: CoroutineScope, userServers: MutableState>, serverErrors: MutableState>, + serverWarnings: MutableState>, rhId: Long? ) { AlertManager.shared.showAlertDialogButtonsColumn( @@ -235,7 +273,7 @@ fun showAddServerDialog( SectionItemView({ AlertManager.shared.hideAlert() ModalManager.start.showCustomModal { close -> - NewServerView(userServers, serverErrors, rhId, close) + NewServerView(userServers, serverErrors, serverWarnings, rhId, close) } }) { Text(stringResource(MR.strings.smp_servers_enter_manually), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) @@ -250,6 +288,7 @@ fun showAddServerDialog( server, userServers, serverErrors, + serverWarnings, rhId, close = close ) @@ -260,6 +299,14 @@ fun showAddServerDialog( Text(stringResource(MR.strings.smp_servers_scan_qr), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) } } + SectionItemView({ + AlertManager.shared.hideAlert() + ModalManager.start.showCustomModal { close -> + NewChatRelayView(userServers, serverErrors, serverWarnings, rhId, close) + } + }) { + Text(stringResource(MR.strings.chat_relay), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary) + } } } ) @@ -303,20 +350,28 @@ private suspend fun testServers( testing: MutableState, smpServers: List, xftpServers: List, + chatRelays: List, m: ChatModel, - onUpdate: (ServerProtocol, List) -> Unit + onUpdate: (ServerProtocol, List) -> Unit, + onUpdateRelays: ((List) -> Unit)? ) { + val relaysResetStatus = resetRelayTestStatus(chatRelays) + onUpdateRelays?.invoke(relaysResetStatus) val smpResetStatus = resetTestStatus(smpServers) onUpdate(ServerProtocol.SMP, smpResetStatus) val xftpResetStatus = resetTestStatus(xftpServers) onUpdate(ServerProtocol.XFTP, xftpResetStatus) testing.value = true + val relayFailures = runRelaysTest(relaysResetStatus) { onUpdateRelays?.invoke(it) } val smpFailures = runServersTest(smpResetStatus, m) { onUpdate(ServerProtocol.SMP, it) } val xftpFailures = runServersTest(xftpResetStatus, m) { onUpdate(ServerProtocol.XFTP, it) } testing.value = false - val fs = smpFailures + xftpFailures - if (fs.isNotEmpty()) { - val msg = fs.map { it.key + ": " + it.value.localizedDescription }.joinToString("\n") + val failures = mutableListOf() + failures += relayFailures.map { (name, f) -> "$name: ${f.localizedDescription}" } + failures += smpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + failures += xftpFailures.map { (srv, f) -> "$srv: ${f.localizedDescription}" } + if (failures.isNotEmpty()) { + val msg = failures.joinToString("\n") AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.smp_servers_test_failed), text = generalGetString(MR.strings.smp_servers_test_some_failed) + "\n" + msg @@ -354,6 +409,37 @@ private suspend fun runServersTest(servers: List, m: ChatModel, onUp return fs } +private fun resetRelayTestStatus(relays: List): List { + val copy = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + copy.removeAt(index) + copy.add(index, relay.copy(tested = null)) + } + } + return copy +} + +private suspend fun runRelaysTest(relays: List, onUpdated: (List) -> Unit): Map { + val fs: MutableMap = mutableMapOf() + val updatedRelays = ArrayList(relays) + for ((index, relay) in relays.withIndex()) { + if (relay.enabled && !relay.deleted) { + interruptIfCancelled() + val relayState = mutableStateOf(relay) + val f = testRelayConnection(relayState) + updatedRelays.removeAt(index) + updatedRelays.add(index, relayState.value) + onUpdated(updatedRelays.toList()) + if (f != null) { + val name = relayState.value.displayName.ifEmpty { relayState.value.domains.firstOrNull() ?: relayState.value.address } + fs[name] = f + } + } + } + return fs +} + fun deleteXFTPServer( userServers: MutableState>, operatorServersIndex: Int, @@ -405,3 +491,28 @@ fun deleteSMPServer( } } } + +fun deleteChatRelay( + userServers: MutableState>, + operatorServersIndex: Int, + relayIndex: Int +) { + val relay = userServers.value[operatorServersIndex].chatRelays[relayIndex] + if (relay.chatRelayId == null) { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this.removeAt(relayIndex) + } + ) + } + } else { + userServers.value = userServers.value.toMutableList().apply { + this[operatorServersIndex] = this[operatorServersIndex].copy( + chatRelays = this[operatorServersIndex].chatRelays.toMutableList().apply { + this[relayIndex] = this[relayIndex].copy(deleted = true) + } + ) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml index 41f454b2dd..2a76fa292a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -2522,4 +2522,23 @@ البصمة في عنوان الخادم لا تتطابق مع الشهادة: %1$s. لا اشتراك أنت غير متصل بالخادم المستخدم لاستقبال الرسائل من هذا الاتصال (لا يوجد اشتراك). + احذف رسائل العضو + حذف رسائل العضو؟ + احذف الرسائل + ستُحذف رسائل العضو - ولا يمكن التراجع عن ذلك! + أزل واحذف الرسائل + كل الرسائل + فشل الاتصال + فشل + ملفات + تصفية + صور + روابط + ابحث عن ملفات + ابحث عن صور + ابحث عن روابط + ابحث عن فيديوهات + ابحث عن رسائل صوتية + فيديوهات + رسائل صوتية diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 4d71073dac..609b6c4b3e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -54,6 +54,7 @@ %d messages blocked by admin sending files is not supported yet receiving files is not supported yet + Voice recording is not supported on your platform you unknown message format invalid message format @@ -100,7 +101,7 @@ SimpleX one-time invitation SimpleX group link SimpleX channel link - SimpleX relay link + SimpleX relay address via %1$s SimpleX links Description @@ -141,6 +142,8 @@ No servers to receive files. For chat profile %s: Errors in servers configuration. + No chat relays enabled. + Server warning Error accepting conditions Spam Content violates conditions of use @@ -514,8 +517,11 @@ Your contact Bot Tap Join group + Tap Join channel Your group + Your channel Group + Channel Business connection Your business contact @@ -561,6 +567,8 @@ Report sent to moderators You can view your reports in Chat with admins. Join group + Join channel + Broadcast Add message Connect Send contact request? @@ -575,7 +583,9 @@ not synchronized contact disabled you are observer + you are subscriber Please contact group admin. + channel request to join rejected group is deleted removed from group @@ -1161,6 +1171,7 @@ Save and notify contact Save and notify contacts Save and notify group members + Save and notify channel subscribers Exit without saving @@ -1638,6 +1649,7 @@ different migration in the app/database: %s / %s Migrations: %s Warning: you may lose some data! + If you joined or created channels, they will stop working permanently. Chat is stopped @@ -1655,8 +1667,10 @@ You joined this group. Connecting to inviting group member. Leave Leave group? + Leave channel? Leave chat? You will stop receiving messages from this group. Chat history will be preserved. + You will stop receiving messages from this channel. Chat history will be preserved. You will stop receiving messages from this chat. Chat history will be preserved. Invite members Group inactive @@ -1693,7 +1707,9 @@ removed %1$s removed you deleted group + deleted channel updated group profile + updated channel profile invited via your group link requested connection New member wants to join the group. @@ -1704,6 +1720,7 @@ you removed %1$s you left group profile updated + channel profile updated you accepted this member Please wait for group moderators to review your request to join the group. @@ -1712,6 +1729,7 @@ %s, %s and %s connected %s, %s and %d other members connected %d group events + %d channel events and %d other events %s and %s %s, %s and %d members @@ -1755,6 +1773,7 @@ moderator admin owner + relay rejected @@ -1803,24 +1822,32 @@ %1$s MEMBERS you: %1$s Delete group + Delete channel Delete chat Delete group? + Delete channel? Delete chat? Group will be deleted for all members - this cannot be undone! + Channel will be deleted for all subscribers - this cannot be undone! Chat will be deleted for all members - this cannot be undone! Group will be deleted for you - this cannot be undone! + Channel will be deleted for you - this cannot be undone! Chat will be deleted for you - this cannot be undone! Leave group + Leave channel Leave chat Edit group profile + Edit channel profile Add welcome message Welcome message Group link + Channel link Create group link Create link Delete link? Delete link You can share a link or a QR code - anybody will be able to join the group. You won\'t lose members of the group if you later delete it. + You can share a link or a QR code - anybody will be able to join the channel. All group members will remain connected. Error creating group link Error updating group link @@ -1837,7 +1864,10 @@ Receipts are disabled This group has over %1$d members, delivery receipts are not sent. Invite + Link Chat with admins + Channel members + Chat relays FOR CONSOLE @@ -1872,6 +1902,7 @@ Remove member? + Remove subscriber? Remove members? Delete member messages? Remove member @@ -1879,6 +1910,7 @@ Chat with member Send direct message Member will be removed from group - this cannot be undone! + Subscriber will be removed from channel - this cannot be undone! Members will be removed from group - this cannot be undone! Member will be removed from chat - this cannot be undone! Members will be removed from chat - this cannot be undone! @@ -1906,6 +1938,7 @@ Blocked by admin blocked disabled + failed inactive MEMBER Role @@ -1924,6 +1957,7 @@ Group Chat Connection + CONNECTION FAILED direct indirect (%1$s) Message queue info @@ -1970,6 +2004,7 @@ Fully decentralized – visible only to members. Enter group name: Group full name: + Channel full name: Short description: Description too large Your chat profile will be sent to group members @@ -1978,8 +2013,11 @@ Group profile is stored on members\' devices, not on the servers. + Channel profile is stored on subscribers\' devices and on the chat relays. Save group profile + Save channel profile Error saving group profile + Error saving channel profile Preset servers @@ -2792,4 +2830,107 @@ You can mention up to %1$s members per message! + + + Subscribers + Owners + %1$d subscriber + %1$d subscribers + you + + + Chat relay + New chat relay + Preset relay name + Preset relay address + Your relay name + Your relay address + Enter relay name… + Use relay + Test relay + Use for new channels + Delete relay + Test relay to retrieve its name.]]> + Relay test failed! + Get link + Decode link + Connect + Wait response + Verify + Test failed at step %s. + Server requires authorization to connect to relay, check password. + Invalid relay name! + Check relay name and try again. + Invalid relay address! + Check relay address and try again. + Error adding relay + + + Chat relays + Chat relays forward messages in channels you create. + + + Chat relays + No chat relays + Chat relays forward messages to channel subscribers. + connected + connecting + deleted + failed + new + invited + accepted + active + + + %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relays active + %1$d/%2$d relays connected, %3$d errors + %1$d/%2$d relays connected + %1$d relays + + + RELAY + OWNER + SUBSCRIBER + Channel + Relay link + Relay address + via %1$s + Share relay address + Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel. + You connected to the channel via this relay link. + Remove subscriber + Block subscriber for all? + + + Create public channel + Create public channel + Create public channel (BETA) + Channel name + Creating channel + Error creating channel + Cancel creating channel? + Cancel + Enable at least one chat relay to create a channel. + Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. + Configure relays + failed + Relay connection failed + Not all relays connected + Wait + Proceed + Channel will start working with %1$d of %2$d relays. Proceed? + + + Relay address + This is a chat relay address, it cannot be used to connect. + Open channel + Open new channel + Your channel + %1$s!]]> + Error opening channel + + + Unblock subscriber for all? \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml index a3b97f0be2..5c8f73bf93 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1555,12 +1555,12 @@ Obrint la base de dades… Comproveu que l\'enllaç SimpleX sigui correcte. l\'enviament de fitxers encara no està suportat - Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte. + Intentant connectar-se al servidor utilitzat per rebre missatges d\'aquesta connexió. S\'està provant de connectar-se al servidor utilitzat per rebre missatges d\'aquest contacte (error: %1$s). Usar perfil actual Usar nou perfil incògnit Error aplicació - Esteu connectat al servidor utilitzat per rebre missatges d\'aquest contacte. + Esteu connectat al servidor utilitzat per rebre missatges d\'aquesta connexió. El teu perfil s\'enviarà al contacte del qual has rebut aquest enllaç. Heu compartit una ruta de fitxer no vàlida. Informeu-ne als desenvolupadors de l\'aplicació. Us connectareu amb tots els membres del grup. @@ -2501,4 +2501,11 @@ L\'empremta digital a l\'adreça del servidor de destinació no coincideix amb el certificat: %1$s. L\'empremta digital a l\'adreça del servidor de reenviament no coincideix amb el certificat: %1$s. L\'empremta digital a l\'adreça del servidor no coincideix amb el certificat: %1$s. + cap subscripció + No esteu connectat(da) al servidor que s\'utilitza per rebre missatges d\'aquesta connexió (sense subscripció). + Suprimir missatges de membre + Suprimir missatges de membre? + Suprimir missatges + Els missatges de membre s\'eliminaran; això no es pot desfer! + Eliminar membre i els seus missatges diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml index 423f0e135f..b559431261 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -2213,7 +2213,7 @@ Chyba dočasného souboru Přesunout sezení TCP připojení - Použité servery + Použít servery Použit %s Pro příjem Systém @@ -2517,7 +2517,7 @@ Pro odeslání příkazů musíte být připojen. Pro použití jiného profilu po pokusu o připojení, smažte chat a znovu použijte odkaz. Aktualizovat vaši adresu - Povýšení + Povýšit Povýšit adresu? Povýšit odkaz skupiny Povýšit odkaz skupiny? @@ -2528,4 +2528,26 @@ Váš kontakt Vaše skupina Váš profil + Všechny zprávy + Smazat zprávy člena + Smazat zprávy člena? + Smazat zprávy + Soubory + Filtr + Obrázky + Odkazy + Zprávy člena budou smazány - nemůže být zrušeno! + bez předplatného + Odebrat a smazat zprávy + Hledat soubory + Hledat obrázky + Hledat odkazy + Hledat videa + Hledat hlasové zprávy + Videa + Hlasové zprávy + Nejste připojen k serveru, který se používá k přijímání zpráv z tohoto připojení (bez předplatného). + Připojení selhalo + selhal + Pokud jste se připojili k nějakým kanálům nebo je vytvořili, přestanou trvale fungovat. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml index 30e557a4e3..38507cc228 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -859,4 +859,6 @@ Tilslutning af opkald … Tilslutning af opkald Tilslutning (introduceret) + Overfør fra en anden enhed på den nye enhed og scan QR-koden.]]> + Overfør fra en anden enhed diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml index e038207801..5d3d9ac90e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -2612,4 +2612,24 @@ Fingerabdruck in der Serveradresse stimmt nicht mit dem Zertifikat überein: %1$s. Kein Abonnement Sie sind nicht mit dem Server verbunden, der für den Empfang von Nachrichten dieser Verbindung genutzt wird (kein Abonnement). + Mitgliedsnachrichten löschen + Mitgliedsnachrichten löschen? + Mitgliedsnachrichten löschen + Mitgliedsnachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! + Mitglied entfernen und Nachrichten löschen + Alle Nachrichten + Dateien + Filter + Bilder + Links + Dateien suchen + Bilder suchen + Links suchen + Videos suchen + Sprachnachrichten suchen + Videos + Sprachnachrichten + Verbindung fehlgeschlagen + Fehlgeschlagen + Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml index 9e38019c8b..20a271f58f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -1,39 +1,39 @@ - 1 μέρα + 1 ημέρα 1 μήνας Για το SimpleX - Σαρώστε τον QR κωδικό + Σάρωσε τον QR κωδικό α + β Για το SimpleX Chat - Σαρώστε τον κωδικό ασφαλείας από την εφαρμογή επαφών σας + Σάρωσε τον κωδικό ασφαλείας από την εφαρμογή επαφών σου Ασφαλή ουρά δε Κωδικός ασφαλείας - Σαρώστε τον κωδικό QR διακομιστή + Σάρωσε τον QR κωδικό του διακομιστή μυστικό 1 εβδομάδα αξιολόγηση ασφαλείας - Συναινώ + Επέτρεψε Αποδοχή Αποδοχή ανώνυμης περιήγησης Προσθήκη προκαθορισμένου διακομιστή Προσθήκη σε άλλη συσκευή - Όλες οι επαφές σας θα παραμείνουν ενεργές. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Αποδοχή διαχειριστής - Προσθέστε μήνυμα καλωσορίσματος + Πρόσθεσε μήνυμα καλωσορίσματος Όλα τα μέλη της ομάδας θα παραμήνουν συνδεδεμένα. Προσθήκη προφίλ - Προφορά + Χρώμα έμφασης πάντα Αποδοχή - Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) - Επιτρέψτε στις επαφές σας να στέλνουν μηνύματα που εξαφανίζονται. - Επιτρέπονται τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σας. - Επιτρέψτε στις επαφές σας να σας καλέσουν. - Επιτρέψτε στις επαφές σας να στέλνουν φωνητικά μηνύματα. + Επιτρέψτε τα μηνύματα που εξαφανίζονται μόνο εάν το επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να διαγράφουν μη αναστρέψιμα τα απεσταλμένα μηνύματα. (24 ώρες) + Επέτρεψε στις επαφές σου να στέλνουν μηνύματα που εξαφανίζονται. + Επέτρεψε τα φωνητικά μηνύματα μόνο εάν τα επιτρέπει η επαφή σου. + Επέτρεψε στις επαφές σου να σε καλέσουν. + Επέτρεψε στις επαφές σου να στέλνουν φωνητικά μηνύματα. Να επιτρέπεται η αποστολή άμεσων μηνυμάτων στα μέλη. Επιτρέπεται η αποστολή μηνυμάτων που εξαφανίζονται. Επιτρέπεται η αποστολή φωνητικών μηνυμάτων. @@ -41,70 +41,69 @@ Αποδοχή Αποδοχή αιτήματος σύνδεσης; αποδεκτή κλήση - Πρόσβαση στους διακομιστές μέσω SOCKS proxy στην πόρτα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. + Πρόσβαση στους διακομιστές μέσω διαμομιστή μεσολάβησης SOCKS στη θύρα %d; Ο διακομιστής μεσολάβησης (proxy server) πρέπει να είναι ενεργός πριν ενεργοποιηθεί αυτή η ρύθμιση. Προσθήκη διακομιστή Προχωρημένες ρυθμίσεις δικτύου Προσθήκη διακομιστών μέσω σάρωσης QR κωδικών. Οι διαχειριστές μπορούν να δημιουργήσουν τους συνδέσμους συμμετοχής σε ομάδες. Όλες οι συνομιλίες και τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! Τα μηνύματα θα διαγραφούν ΜΟΝΟ για εσάς. - Επιτρέψτε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σας. (24 ώρες) - Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σας τις επιτρέπει. + Επέτρεψε τη μη αναστρέψιμη διαγραφή μηνυμάτων μόνο εάν το επιτρέπει η επαφή σου. (24 ώρες) + Επιτρέπονται οι κλήσεις μόνο εάν η επαφή σου τις επιτρέπει. Επιτρέψτε τη μη αναστρέψιμη διαγραφή των απεσταλμένων μηνυμάτων. (24 ώρες) Να επιτρέπονται τα φωνητικά μηνύματα; Πάντα ενεργό Να χρησιμοποιείται πάντα αναμεταδότη - Αναζήτηση + Αναζήτησε Ανενεργό - "Το προφίλ σας %1$s θα μοιραστεί" - Η SimpleX διεύθυνση σας + Το προφίλ σου %1$s θα διαμοιραστεί + Η SimpleX διεύθυνση σου Αντίγραφο δεδομένων εφαρμογής 5 λεπτά - Θα συνδεθείτε όταν η συσκευή της επαφής σας είναι συνδεμένει, παρακαλώ περιμένετε ή ελέγξτε αργότερα! - Ο ICE διακομιστής σας + Θα συνδεθείς όταν η συσκευή της επαφής σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Ο ICE διακομιστής σου Έκδοση εφαρμογής - Στείλατε πρόσκληση ομάδας + Έστειλες πρόσκληση ομάδας 1 λεπτό - Ο διακομιστής σας + Ο διακομιστής σου Διεύθυνση Ακύρωση Πίσω 30 δευτερόλεπτα - Θα συνδεθείτε με όλα τα μέλη της ομάδας. + Θα συνδεθείς με όλα τα μέλη της ομάδας. %1$s θέλει να συνδεθεί μαζί σου μέσω - Επιτρέπονται αντιδράσεις μηνύματος. - Ο διακομιστής XFTP σας - Η διεύθυνση του διακομιστή σας - Το προφίλ, επαφές και παραδομένα μηνύματα σας είναι αποθηκευμένα στην συσκευή σας. - Ο διακομιστής SMP σας + Επέτρεψε τις αντιδράσεις σε μηνύματα. + Ο διακομιστής XFTP σου + Η διεύθυνση του διακομιστή σου + Το προφίλ, επαφές και παραδομένα μηνύματα σου είναι αποθηκευμένα στην συσκευή σου. + Ο διακομιστής SMP σου Επιτρέπεται να σταλούν αρχεία και μέσα. - Το τυχαίο προφίλ σας + Το τυχαίο προφίλ σου %1$s ΜΕΛΗ - Επιτρέπεται - Οι προτιμήσεις σας + Αποδοχή + Οι προτιμήσεις σου Συντάκτης - Ο ΙCE διακομιστής σας - Κωδικός εφαρμογής + Ο ΙCE διακομιστής σου + Κωδικός πρόσβασης εφαρμογής Αίτημα σύνδεσης θα σταλεί σε αυτό το μέλος της ομάδας. ΟΙΚΟΝΑ ΕΦΑΡΜΟΓΗΣ Εφαρμογή - Οι ρυθμίσεις σας + Οι ρυθμίσεις σου Έκδοση εφαρμογής: v%s σφάλμα κλήσης - "ακυρώθηκε %s" + ακυρώθηκε %s ακύρωση πρόβλεψη συνδέσμου - Αλλαγή κωδικού πρόσβασης βάση δεδομένων? + Αλλαγή φράσης πρόσβασης της βάσης δεδομένων? Αλλαγή κωδικού πρόσβασης Αλλαγή ρόλου του %s σε %s Φόντο Δεν είναι δυνατή η προετοιμασία της βάσης δεδομένων - Ένα νέο τυχαίο προφίλ θα μοιραστεί. + Ένα νέο τυχαίο προφίλ θα διαμοιραστεί. Δεν είναι δυνατή η πρόσκληση επαφών! Αλλαγή διεύθυνσης λήψης Πιστοποίηση μη διαθέσιμη - Αλλαγή - " -\nΔιαθέσιμο στην έκδοση 5.1" + Άλλαξε + \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης ΚΛΗΣΕΙΣ Αυτόματη αποδοχή @@ -126,25 +125,25 @@ Όλα τα δεδομένα της εφαρμογής διαγράφηκαν. Εμφάνιση Τέλος κλήσης %1$s - Ακύρωση + Ακύρωσε Αλλαγή διεύθυνσης λήψης; αλλαγή διεύθυνσης… Αλλαγή λειτουργίας αυτοκαταστροφής Κάμερα κλήση σε εξέλιξη Αυτόματη αποδοχή εικόνων - Αλλαγή του ρόλου σας σε %s + Αλλαγή του ρόλου σου σε %s Κλήση σε εξέλιξη Πιστοποίηση απέτυχε - "σύνδεση %1$d" + σύνδεση %1$d Δημιουργία διεύθυνση SimpleX - επαφή έχει κρυπτογράφηση από άκρο σε άκρο + η επαφή έχει κρυπτογράφηση από άκρη-σε-άκρη Δημιουργία μια ομάδας χρησιμοποιώντας ένα τυχαίο προφίλ. Δημιουργία ομάδας Δημιουργία προφίλ Η επαφή και όλα τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! Δημιουργία προφίλ - Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή; θα μπορείτε να τα δείτε. + Οι επαφές μπορούν να επισημάνουν μηνύματα προς διαγραφή, τα οποία θα μπορείς να τα δεις. Σύνδεση μέσω μιας εφάπαξ σύνδεσης; Δημιουργία σύνδεσμου Σύνδεση μέσω σύνδεσμο/κωδικό γρήγορης ανταπόκρισης @@ -153,11 +152,11 @@ συνδέεται… Όνομα επαφής Δημιουργία διεύθυνσης - Αντιγραφή + Αντέγραψε Συνέχεια Σύνδεση μέσω σύνδεσμο; Επαφή υπάρχει ήδη - Σύνδεση στον εαυτό σας; + Σύνδεση στον εαυτό σου; Δημιουργία μυστικής ομάδας Συνδεδεμένη σε επιφάνεια εργασίας δημιουργός @@ -180,8 +179,8 @@ Συνδε Σύνδεση ανώνυμης περιήγησης σύνδεση επετεύχθη - επαφή δεν έχει κρυπτογράφηση από άκρο σε άκρο - Επαφή επιτρέπει + η επαφή δεν έχει κρυπτογράφηση από άκρη-σε-άκρη + Η επαφή επιτρέπει σύνδεση (ανακοινώθηκε) Συνδεδεμένος συνδέεται… @@ -194,29 +193,29 @@ Δημιουργία μυστικής ομάδας Σφάλμα σύνδεσης Η επαφή δεν είναι συνδράμει αυτή τη στιγμή! - Συνδεδεμένο απευθείας - Η ιδιωτικότητά σας - Η επαφή σας έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). - Το προφίλ της συνομιλίας σας θα σταλεί στην επαφή σας + αιτούμενη σύνδεση + Η ιδιωτικότητά σου + Η επαφή σου έστειλε ένα αρχείο το οποίο είναι μεγαλύτερο από το παρόν υποστηριζόμενο μέγεθος (%1$s). + Το προφίλ της συνομιλίας σου θα σταλεί\nστην επαφή σου Χρήση νέου ανώνυμου προφίλ Ήδη συνδέεται - Απορρίψατε την πρόσκληση της ομάδας + Απέρριψες την πρόσκληση της ομάδας Σύνδεση μέσω διεύθυνση επαφής - Χρήση του τρέχων προφίλ - Σύνδεση - Το τρέχον προφίλ σας - αφαιρέσατε %1$s - "Συμμετοχή ομάδας;" - Οι επαφες σας θα παραμένουν συνδεδεμένες. - Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Χρήση του τρέχοντος προφίλ + Συνδέσου + Το τρέχον προφίλ σου + αφαίρεσες %1$s + Συμμετοχή ομάδας; + Οι επαφες σου θα παραμένουν συνδεδεμένες. + Προσπάθεια σύνδεσης με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. Το προφίλ σου θα σταλεί στην επαφή από την οποία έλαβες αυτόν τον σύνδεσμο. σφάλμα Άνοιγμα βάση δεδομένων… Η προβολή συνετρίβη συνδεδεμένο - Κοινοποιήσατε μια μη έγκυρη διαδρομή αρχείου. Αναφέρετε το πρόβλημα στους προγραμματιστές της εφαρμογής. + Κοινοποίησες μία μη έγκυρη διαδρομή αρχείου. Ανέφερε το πρόβλημα στους προγραμματιστές της εφαρμογής. Μη έγκυρη διαδρομή αρχείου - Είστε συνδεδεμένοι στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν την επαφή. + Είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτήν τη σύνδεση. %d μύνημα επισημάνθηκε ως διαγραμμένο %1$d μυνήματα συντονίζονται από %2$s επισημάνθηκε ως διαγραμμένο @@ -230,22 +229,22 @@ Η αλλαγή διεύθυνσης θα ακυρωθεί. Θα χρησιμοποιηθεί η παλιά διεύθυνση παραλαβής. Ενεργές συνδέσεις Προχωρημένες ρυθμίσεις - Πρόσθετος τόνος + Επιπρόσθετο χρώμα έμφασης Προσθήκη επαφής - Διακοπή αλλαγής διεύθυνσης + Ακύρωση αλλαγής διεύθυνσης Προχωρημένες ρυθμίσεις Οι διαχειριστές μπορούν να αποκλείσουν ένα μέλος για όλους. Αναγνωρισμένο παραπάνω, λοιπόν: - Προσθέστε τη διεύθυνση στο προφίλ σας, έτσι ώστε οι επαφές σας να μπορούν να τη μοιραστούν με άλλα άτομα. Το ενημέρωμένο προφίλ θα σταλεί στις επαφές σας. + Πρόσθεσε τη διεύθυνση στο προφίλ σου, έτσι ώστε οι επαφές σου να μπορούν να τη διαμοιραστούν με άλλα άτομα. Το ενημέρωμένο σου προφίλ θα αποσταλεί στις επαφές σου. διαχειριστές Λάθη αναγνώρισης - Προειδοποίηση: το αρχείο θα διαγραφεί.]]> + Προειδοποίηση: το αρχείο αρχειοθέτησης θα διαγραφεί.]]> Υπέρβαση χωρητικότητας - ο παραλήπτης δεν έλαβε μηνύματα που στάλθηκαν προηγουμένως. αποκλεισμένος από τον διαχειριστή Συνομιλίες όλα τα μέλη - Όλες οι επαφές σας θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σας θα αποσταλεί στις επαφές σας. + Όλες οι επαφές σου θα παραμείνουν ενεργές. Το ανανεωμένο προφίλ σου θα αποσταλεί στις επαφές σου. Να χρησιμοποιείται πάντα ιδιωτική δρομολόγηση. Ένα κενό προφίλ συνομιλίας με το παρεχόμενο όνομα δημιουργείται και η εφαρμογή ανοίγει ως συνήθως. Η βάση δεδομένων της συνομιλίας διαγράφηκε @@ -263,7 +262,7 @@ Η εφαρμογή κρυπτογραφεί νέα τοπικά αρχεία (εκτός απο βίντεο). Καλύτερες ομάδες Γίνεται ήδη συμμετοχή στην ομάδα! - Αρχειοθέτηση και αποστολή + Αρχειοθέτηση και ανέβασμα %1$d διαφορετικό/κα σφάλμα/τα αρχείου/ων. Η υπηρεσία παρασκηνίου λειτουργεί πάντα - οι ειδοποιήσεις θα εμφανίζονται μόλις τα μηνύματα είναι διαθέσιμα. %1$d αρχείο/α ακόμα κατεβαίνουν. @@ -272,10 +271,10 @@ %1$d αρχείο/α δεν κατέβηκε/καν. %1$s μήνυμα/τα δεν προωθήθηκε/καν Προφίλ συνομιλίας - για κάθε προφίλ συνομιλίας που έχετε στην εφαρμογή.]]> - Παρακαλώ σημειώστε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> + για κάθε προφίλ συνομιλίας που έχεις στην εφαρμογή.]]> + Παρακαλώ σημείωσε: οι αναμεταδότες μηνυμάτων και αρχείων συνδέονται μέσω διακομιστή μεσολάβησης SOCKS. Οι κλήσεις και οι προεπισκοπήσεις συνδέσμων αποστολής χρησιμοποιούν άμεση σύνδεση.]]> Πάντα - Η ενημέρωση της εφαρμογής κατεβαίνει + Η ενημέρωση της εφαρμογής κατέβηκε Έλεγχος για ενημερώσεις Οποιοσδήποτε μπορεί να φιλοξενήσει διακομιστές. κλήση ήχου (χωρίς κρυπτογράφηση e2e) @@ -291,21 +290,21 @@ Χρώματα συνομιλίας ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ Η συνομιλία εκτελείται - Παρακαλώ σημειώστε: ΔΕΝ θα μπορείτε να ανακτήσετε ή να αλλάξετε τη φράση πρόσβασης εάν τη χάσετε.]]> + Παρακαλώ σημείωσε: ΔΕΝ θα μπορείς να ανακτήσεις ή να αλλάξεις τη φράση πρόσβασης εάν τη χάσεις.]]> Αποκλεισμός για όλους - Και εσείς και η επαφή σας μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να κάνετε κλήσεις. + Και εσύ και η επαφή σου μπορείτε να προσθέστε αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να κάνετε κλήσεις. Επιτρέψτε την αποστολή συνδέσμων SimpleX. Αραβικά, Βουλγαρικά, Φινλανδικά, Εβραϊκά, Ταϊλανδέζικα και Ουκρανικά - χάρη στους χρήστες και το Weblate. Μεταφορά δεδομένων εφαρμογής Θάμπωμα για καλύτερη ιδιωτικότητα. Η συνομιλία έχει μεταφερθεί! Αρχειοθέτηση της βάσης δεδομένων - Όλες οι επαφές, συζητήσεις και αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν σε διαμορφωμένα κομμάτια αναμετάδοσης XFTP. + Όλες οι επαφές, οι συζητήσεις και τα αρχεία θα κρυπτογραφηθούν με ασφάλεια και θα μεταφορτωθούν τμηματικά σε διαμορφωμένους αναμεταδότες XFTP. Κινητή τηλεφωνία - Δημιουργία ομάδας : για την δημιουργίας νέας ομάδας.]]> - Ελέγξτε τη σύνδεσή σας στο διαδίκτυο και δοκιμάστε ξανά - Συζήτηση με τους προγραμματιστές + Δημιουργία ομάδας : για να δημιουργήσεις μία νέα ομάδα.]]> + Έλεγξε τη σύνδεσή σου στο διαδίκτυο και δοκίμασε ξανά + Συνομίλησε με τους προγραμματιστές Ζήτησε να λάβει το βίντεο Δεν είναι δυνατή η αποστολή μηνυμάτων στο μέλος της ομάδας Αλλαγή λειτουργίας κλειδώματος @@ -313,16 +312,16 @@ άλλαξε η διεύθυνση για εσάς και %d άλλες εκδηλώσεις Μαύρο - Πρόσθετο δευτερεύον - Και εσείς και η επαφή σας μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) - Και εσείς και η επαφή σας μπορείτε να στείλετε ηχητικά μηνύματα. + Επιπρόσθετο δευτερεύων + Και εσύ και η επαφή σου μπορείτε να διαγράψετε απεσταλμένα μηνύματα χωρίς ανατροπή. (24 ώρες) + Και εσύ και η επαφή σου μπορείτε να στείλετε ηχητικά μηνύματα. Η συνομιλία σταμάτησε - Η συνομιλία έχει διακοπεί. Εάν χρησιμοποιήσατε ήδη αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρετε πίσω προτού ξεκινήσετε τη συνομιλία. - Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείτε να τα ενεργοποιήσετε ξανά μέσω των ρυθμίσεων. - σύνδεσμος μιας χρήσης + Η συνομιλία έχει διακοπεί. Εάν ήδη χρησιμοποίησες αυτήν τη βάση δεδομένων σε άλλη συσκευή, θα πρέπει να τη μεταφέρεις πίσω προτού ξεκινήσεις τη συνομιλία. + Η λειτουργία βελτιστοποίησης της μπαταρίας είναι ενεργή, η υπηρεσία παρασκηνίου και τα περιοδικά αιτήματα για νέα μηνύματα θα απενεργοποιηθούν. Μπορείς να τα ενεργοποιήσεις ξανά μέσω των ρυθμίσεων. + σύνδεσμος 1-χρήσης Κλήσεις ήχου & βίντεο Κλήσεις ήχου/βίντεο - Κωδικός εφαρμογής + Κωδικός πρόσβασης εφαρμογής Συνεδρία εφαρμογής Η συνομιλία σταμάτησε Έλεγχος για ενημερώσεις @@ -331,47 +330,47 @@ Bluetooth έντονο Κονσόλα συνομιλίας - Παρακαλώ σημειώστε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σας, ως προστασία ασφαλείας.]]> + Παρακαλώ σημείωσε: η χρήση της ίδιας βάσης δεδομένων σε δύο συσκευές θα διακόψει την αποκρυπτογράφηση των μηνυμάτων από τις συνδέσεις σου, ως προστασία ασφαλείας.]]> Χρησιμοποιεί περισσότερη μπαταρία! Η εφαρμογή εκτελείται πάντα στο παρασκήνιο - οι ειδοποιήσεις εμφανίζονται αμέσως.]]> Η βάση δεδομένων της συνομιλίας εξάχθηκε κλήση Κακή διεύθυνση Desktop - Μεταφορά απο άλλη συσκευή στη νέα συσκευή και σαρώστε τον κωδικό QR.]]> + Μεταφορά από άλλη συσκευή στη νέα συσκευή και σάρωσε τον κωδικό QR.]]> Με προφίλ συνομιλίας (προεπιλογή) ή μέσω σύνδεσης (BETA). Κάμερα και μικρόφωνο 6 νέες γλώσσες διεπαφής - Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσετε κλήσεις ή επείγοντα μηνύματα.]]> + Καλό για την μπαταρία. Η εφαρμογή ελέγχει για την παραλαβή μηνυμάτων κάθε 10 λεπτά. Ενδέχεται να χάσεις κλήσεις ή επείγοντα μηνύματα.]]> Επισύναψη - Διακοπή αλλαγής διεύθυνσης; + Ακύρωση αλλαγής διεύθυνσης; Επιλέξτε ένα αρχείο Όλα τα νέα μηνύνματα απο %s θα αποκρυφθούν! Δεν είναι δυνατή η λήψη του αρχείου Πιστοποίηση Όλα τα μηνύματα θα διαγραφούν - αυτή η ενέργεια δεν μπορεί να αντιστραφεί! - Ελέγχει νέα μηνύματα κάθε 10 λεπτά για έως και 1 λεπτό + Ελέγχει νέα μηνύματα κάθε 10 λεπτά για εώς και 1 λεπτό Η εφαρμογή μπορεί να λαμβάνει ειδοποιήσεις μόνο όταν εκτελείται, καμία υπηρεσία δεν θα ξεκινήσει στο παρασκήνιο Μπορεί να απενεργοποιηθεί μέσω των ρυθμίσεων – οι ειδοποιήσεις θα εξακολουθούν να εμφανίζονται ενώ η εφαρμογή εκτελείται.]]> - Επιτρέψτε τις επαφές σας να χρησιμοποιούν αντιδράσεις μηνυμάτων. - Και εσείς και η επαφή σας μπορείτε να στείλετε μηνύματα που εξαφανίζονται. + Επέτρεψε στις επαφές σου να χρησιμοποιούν αντιδράσεις μηνυμάτων. + Και εσύ και η επαφή σου μπορείτε να στείλετε μηνύματα που εξαφανίζονται. Κάμερα μη διαθέσιμη Ελέγξτε την διεύθυνση του διακομιστή και δοκιμάστε ξανά. - Επιτρέψτε αντιδράσεις μηνυμάτων εφόσον οι επαφές σας το επιτρέπουν. + Επέτρεψε αντιδράσεις μηνυμάτων εφόσον οι επαφές σου το επιτρέπουν. %1$d μήνυμα/τα παραλήφθηκε/καν. Κλήσεις απογορευμένες! Δεν είναι δυνατή η αποστολή μηνύματος Η κλήση έχει ήδη τερματιστεί! Ο κωδικός πρόσβασης της εφαρμογής αντικαθίσταται με κωδικό πρόσβασης αυτοκαταστροφής. Το Android Keystore θα χρησιμοποιηθεί για την ασφαλή αποθήκευση της φράσης πρόσβασης μετά την επανεκκίνηση της εφαρμογής ή την αλλαγή της φράσης πρόσβασης - θα επιτρέπει τη λήψη ειδοποιήσεων. - Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού πρόσβασης της βάσης δεδομένων + Δεν είναι δυνατή η πρόσβαση στο Keystore για αποθήκευση του κωδικού της βάσης δεδομένων Αποκλεισμός μέλους Αποκλεισμός μέλους; Προτιμήσεις συνομιλίας Καλύτερα μηνύματα Εφαρμογή - Συνέναιση υποβάθμισης + Συναίνεση υποβάθμισης Κάμερα κλήση ήχου - Αρχειοθετήστε τις επαφές για να συνομιλήσετε αργότερα. + Αρχειοθέτησε τις επαφές για να συνομιλήσεις αργότερα. Όλα τα προφίλ %1$d μηνύμα/τα παραλείφθηκε/καν κακό μήνυμα hash @@ -380,7 +379,7 @@ Κακό αναγνωριστικό μηνύματος ΣΥΝΟΜΙΛΙΕΣ Η βάση δεδεδομένων της συνομιλίας εισάχθηκε - "συμφωνία κρυπτογράφησης για %s…" + συμφωνία κρυπτογράφησης για %s… Να επιτραπούν οι κλήσεις; Αποκλεισμός μέλους για όλους; Κλήσεις ήχου και βίντεο @@ -390,16 +389,16 @@ Καλύτερη εμπειρία χρήστη Δεν είναι δυνατή η κλήση μέλους ομάδας Ζήτησε να λάβει την εικόνα - για κάθε επαφή και μέλος ομάδας .\nΛάβετε υπόψη: εάν έχετε πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της κυκλοφορίας μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> - Προσθήκη επαφής : για να δημιουργήσετε έναν νέο σύνδεσμο πρόσκλησης ή να συνδεθείτε μέσω ενός συνδέσμου που λάβατε.]]> - Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνετε ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> + για κάθε επαφή και μέλος ομάδας .\nΛάβε υπόψη: εάν έχεις πολλές συνδέσεις, η κατανάλωση της μπαταρίας και της χρήσης ίντερνετ μπορεί να είναι σημαντικά υψηλότερη και ορισμένες συνδέσεις μπορεί να αποτύχουν.]]> + Προσθήκη επαφής : για να δημιουργήσεις ένα νέο σύνδεσμο πρόσκλησης ή να συνδεθείς μέσω ενός συνδέσμου που έλαβες.]]> + Καλύτερο για τη ζωή της μπαταρίας . Θα λαμβάνεις ειδοποιήσεις μόνο όταν εκτελείται η εφαρμογή (ΧΩΡΙΣ υπηρεσία παρασκηνίου).]]> Beta Καλύτερες κλήσεις %1$d σφάλμα/τα αρχείου/ων:\n%2$s - 1 συζήτηση με ένα μέλος + 1 συνομιλία με ένα μέλος 1 αναφορά 1 χρόνος - Σχετικά με χειρηστές + Σχετικά με τους χειριστές Αποδοχή Αποδοχή Αποδοχή ως μέλος @@ -409,32 +408,2120 @@ Αποδοχή αιτήματος επαφής αποδέχτηκε %1$s Αποδεχούμενοι όροι - αποδέχτηκε τη πρόσκληση + αποδέχτηκε την πρόσκληση σε αποδέχτηκε Αποδοχή μέλους Προστέθηκαν διακομιστές πολυμέσων και αρχείων Προστέθηκε διακομιστής μυνημάτων Προσθήκη φίλων - Πρόσθετος τόνος 2 + Επιπρόσθετο χρώμα έμφασης 2 Προσθήκη λίστας Προσθήκη μυνήματος - Διεύθυνση ή σύνδεσμος μιας χρήσης; + Διεύθυνση ή σύνδεσμος 1-χρήσης; Ρυθμίσεις διεύθυνσης Προσθήκη μέλη ομάδας - Προσθήκη στην λίστα + Προσθήκη στη λίστα Πρόσθεσε τα μέλη της ομάδας σου στις συνομιλίες. όλα - Όλα + Όλες Όλες οι συζητήσεις θα διαγραφτούν απο την λίστα %s, και η λίστα θα διαγραφτεί Όλα τα καινούργια μυνήματα από αυτά τα μέλη θα είναι κρυμμένα! Επιτρέψτε τα αρχεία και πολυμέσα μόνο αν η επαφή σου το επιτρέπει. Επιτρέψτε την αναφορά μυνημάτων στους διαχειριστές. - Επιτρέψτε τις επαφές σας να σας στέλνουν αρχεία και πολυμέσα. + Επέτρεψε στις επαφές σου να σου στέλνουν αρχεία και πολυμέσα. Όλες η αναφορές θα αρχειοθετηθούν για εσένα. Όλοι οι διακομιστές Άλλος λόγος Η εφαρμογή πάντα να τρέχει στο παρασκήνιο - Αρχειοθέτηση + Αρχειοθέτησε Αρχειοθέτηση όλων των αναφορών; αρχειοθετημένη αναφορά + 4 νέες γλώσσες διεπαφής + Γραμμές εργαλείων εφαρμογής + αρχειοθετημένη αναφορά από %s + Να αρχειοθετηθούν %d αναφορές; + Αρχειοθέτηση αναφοράς + Αρχειοθέτηση αναφοράς; + Αρχειοθέτηση αναφορών + Ερώτηση + Καλύτερη απόδοση ομάδων + Καλύτερη ιδιωτικότητα και ασφάλεια + Βιογραφικό: + Το βιογραφικό είναι πολύ μεγάλο + Αποκλεισμός μελών για όλους; + Θόλωμα + Μποτ + Εσύ και η επαφή σου, μπορείτε να στέλνετε αρχεία και πολυμέσα. + Διεύθυνση επιχείρησης + Επαγγελματικές συνομιλίες + Επαγγελματική σύνδεση + Επιχειρήσεις + Χρησιμοποιώντας το SimpleX Chat συμφωνείς να:\n- στέλνεις μόνο νόμιμο περιεχόμενο στις δημόσιες ομάδες.\n- σέβεσαι τους άλλους χρήστες – όχι spam. + Δεν μπορείς να αλλάξεις το προφίλ + δεν μπορείς να στείλεις μηνύματα + Καταλανικά, Ινδονησιακά, Ρουμανικά και Βιετναμέζικα – ευχαριστούμε τους χρήστες μας! + με μία μόνο επαφή - προσωπικό διαμοιρασμό ή μέσω οποιασδήποτε εφαρμογής μηνυμάτων.]]> + με κρυπτογράφηση από άκρη-σε-άκρη και με μετα-κβαντική ασφάλεια σε άμεσα μηνύματα.]]> + Επέτρεψε το στο επόμενο παράθυρο διαλόγου για να λαμβάνεις ειδοποιήσεις άμεσα.]]> + Συσκευές Xiaomi: ενεργοποίησε το Αυτόματο Ξεκίνημα στις ρυθμίσεις συστήματος για να λειτουργούν οι ειδοποιήσεις.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %s βρίσκεται σε κακή κατάσταση]]> + Σάρωση QR κωδικού.]]> + %s με τον λόγο: %s]]> + σαρώσεις τον QR κωδικό στη βιντεοκλήση, ή η επαφή σου να διαμοιραστεί ένα σύνδεσμο πρόσκλησης.]]> + δείξε τον QR κωδικό στη βιντεοκλήση, ή μοιράσου το σύνδεσμο.]]> + (νέο)]]> + (αυτή η συσκευή v%s)]]> + κρυπτογράφηση από άκρη-σε-άκρη.]]> + κρυπτογράφηση από άκρη-σε-άκρη με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + κβαντο-ανθεκτική κρυπτογράφηση e2e και με πλήρη εμπιστευτικότητα, δυνατότητα απόρριψης και ανάκτηση μετά από παραβίαση.]]> + %s έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές.]]> + %s είναι απασχολημένο]]> + %s είναι ανενεργό]]> + %s δεν υπάρχει]]> + %s έχει αποσυνδεθεί]]> + %s έχει αποσυνδεθεί]]> + Άνοιγμα στην εφαρμογή κινητού, μετά πάτα Σύνδεση μέσα στην εφαρμογή.]]> + Χρήση από τον υπολογιστή στην εφαρμογή του κινητού και σκάναρε τον QR κωδικό.]]> + Οδηγό Χρήσης.]]> + αποθετήριό μας στο GitHub.]]> + Χρήση .onion hosts σε Όχι, αν ο διακομιστής μεσολάβησης SOCKS δεν τα υποστηρίζει.]]> + %s.]]> + %s.]]> + %s.]]> + %s.]]> + %1$s!]]> + %s]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + το SimpleX τρέχει στο παρασκήνιο αντί να χρησιμοποιεί ειδοποιήσεις push.]]> + Χρήση μπαταρίας εφαρμογής / Απεριόριστη στις ρυθμίσεις της εφαρμογής.]]> + %s, αποδέξου τους όρους χρήσης.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + %1$s.]]> + πρέπει να χρησιμοποιείς την ίδια βάση δεδομένων σε δύο συσκευές.]]> + Άνοιγμα στην εφαρμογή κινητού κουμπί.]]> + συνδεθείς με τους δημιουργούς του SimpleX Chat για να κάνεις ερωτήσεις και να λαμβάνεις ενημερώσεις.]]> + μόνο αφού γίνει αποδεκτό το αίτημά σου.]]> + Να αλλάξεις την αυτόματη διαγραφή μηνυμάτων; + Αλλαγή των προφίλ συνομιλίας + Αλλαγή λίστας + Αλλαγή σειράς + Συνομιλία + Η συνομιλία υπάρχει ήδη! + Συνομιλίες με μέλη + Η συνομιλία θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η συνομιλία θα διαγραφεί για εσένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με τους διαχειριστές + Συνομίλησε με μέλος + Συνομιλία με μέλη, πριν συνενωθούν. + Έλεγχος μηνυμάτων κάθε 10 λεπτά + Τα κομμάτια κατέβηκαν + Τα τμήματα των αρχείων ανέβηκαν + Καθάρισε + Καθαρισμός + Καθαρισμός + Καθαρισμός συνομιλίας + Καθαρισμός συνομιλίας; + Καθαρισμός ιδιωτικών σημειώσεων; + Καθαρισμός επαλήθευσης + Κάνε κλικ στο κουμπί πληροφοριών δίπλα στο πεδίο διεύθυνσης για να επιτρέψεις τη χρήση του μικροφώνου. + Κουμπί κλεισίματος + χρωματισμένο + Λειτουργία χρώματος + Έρχεται σύντομα! + Παράβαση των κατευθυντήριων γραμμών της κοινότητας + Σύγκριση αρχείου + Σύγκρινε τους κωδικούς ασφαλείας με τις επαφές σου. + ολοκληρώθηκε + Ολοκληρωμένο + Οι όροι έγιναν αποδεκτοί στις: %s. + Όροι χρήσης + Οι όροι θα γίνουν αποδεκτοί για τους ενεργούς χειριστές μετά από 30 ημέρες. + Οι όροι θα γίνουν αποδεκτοί στις: %s. + Οι όροι θα γίνουν αυτόματα αποδεκτοί για τους ενεργούς χειριστές στις: %s. + Διαμορφωμένοι SMP διακομιστές + Διαμορφωμένοι XFTP διακομιστές + Διαμορφωμένοι ICE διακομιστές + Διαμόρφωση χειριστών διακομιστή + Επιβεβαίωσε + Επιβεβαίωση διαγραφής επαφής; + Επιβεβαίωση αναβαθμίσεων βάσης δεδομένων + Επιβεβαίωση αρχείων από άγνωστους διακομιστές. + Επιβεβαίωση ρυθμίσεων δικτύου + Επιβεβαίωση νέας φράσης πρόσβασης… + Επιβεβαίωση κωδικού πρόσβασης + Επιβεβαίωση κωδικού + Επιβεβαίωσε ότι θυμάσαι τη φράση πρόσβασης της βάσης δεδομένων για να τη μεταφέρεις. + Επιβεβαίωση μεταφόρτωσης + Επιβεβαίωση των διαπιστευτηρίων σου + σύνδεση + Σύνδεση + Σύνδεση + Σύνδεση + Αυτόματη σύνδεση + Απευθείας σύνδεση; + συνδεδεμένος + συνδεδεμένος + συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος + Συνδεδεμένος υπολογιστής + Συνδεδεμένοι διακομιστές + Συνδέσου γρηγορότερα! 🚀 + Συνδέεται + κλήση σε σύνδεση… + Σύνδεση κλήσης + σύνδεση (σε εξέλιξη) + συνδέεται (μέσω πρόσκλησης) + Σύνδεση με την επαφή, περίμενε ή δοκίμασε αργότερα! + Κατάσταση σύνδεσης και διακομιστών. + Σύνδεση μπλοκαρισμένη + Η σύνδεση έχει μπλοκαριστεί από τον χειριστή του διακομιστή:\n%1$s. + Η σύνδεση δεν είναι έτοιμη. + Η σύνδεση απαιτεί επαναδιαπραγμάτευση κρυπτογράφησης. + Συνδέσεις + Ασφάλεια σύνδεσης + Η σύνδεση διακόπηκε + Η σύνδεση διακόπηκε + Η σύνδεση με τον υπολογιστή βρίσκεται σε κακή κατάσταση + - σύνδεση με υπηρεσία καταλόγου (δοκιμαστικό)!\n- επιβεβαιώσεις παράδοσης (εώς 20 μέλη).\n- γρηγορότερα και πιο σταθερά. + Συνδέσου με τους φίλους σου πιο γρήγορα. + η επαφή %1$s άλλαξε σε %2$s + Η επαφή ελέγχθηκε + η επαφή διαγράφηκε + Η επαφή διαγράφηκε! + η επαφή απενεργοποιήθηκε + Κρυμμένη επαφή: + Η επαφή διαγράφηκε. + η επαφή δεν είναι έτοιμη + ΑΙΤΗΣΕΙΣ ΕΠΑΦΩΝ ΑΠΟ ΟΜΑΔΕΣ + Επαφές + η επαφή πρέπει να αποδεχτεί… + Η επαφή θα διαγραφεί – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Το περιεχόμενο παραβιάζει τους όρους χρήσης + Εικονίδιο περιεχομένου + Συνέχεια + Συνέχεια + Συνεισφορά + Έλεγξε το δίκτυό σου + Η συζήτηση διαγράφηκε! + Αντιγράφηκε στο πρόχειρο + Σφάλμα αντιγραφής + Έκδοση πυρήνα: v%s + Γωνία + Δημιουργία + Δημιουργία συνδέσμου 1-χρήσης + Δημιούργησε μια διεύθυνση για να μπορούν οι άλλοι να συνδεθούν μαζί σου. + Δημιουργήθηκε + Δημιουργήθηκε στις + Δημιουργήθηκε στις: %s + Δημιουργία λίστας + Δημιουργία νέου προφίλ στην εφαρμογή υπολογιστή. 💻 + Δημιουργία συνδέσμου 1-χρήσης + Δημιουργία ουράς + Δημιούργησε τη διεύθυνσή σου + Δημιουργία συνδέσμου αρχειοθέτησης + Δημιουργία συνδέσμου… + Κρίσιμο σφάλμα + (τρέχον) + Το κείμενο των τρεχουσών προϋποθέσεων δεν φορτώθηκε, μπορείς να τις δεις μέσω αυτού του συνδέσμου: + Το μέγιστο υποστηριζόμενο μέγεθος αρχείου αυτήν τη στιγμή είναι %1$s. + Τρέχων κωδικός πρόσβασης + Τρέχουσα φράση πρόσβασης… + Τρέχον προφίλ + προσαρμοσμένος + Προσαρμόσιμη μορφή μηνύματος. + Προσάρμοσε και μοίρασε τα θέματα χρωμάτων. + Προσαρμογή θέματος + Προσαρμοσμένα θέματα + Προσαρμοσμένος χρόνος + Σκούρο + Σκούρο + Σκοτεινή λειτουργία + Χρώματα σκοτεινής λειτουργίας + Σκούρο θέμα + Υποβάθμιση βάσης δεδομένων + Η βάση δεδομένων κρυπτογραφήθηκε! + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί. + Η φράση πρόσβασης για την κρυπτογράφηση της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στις ρυθμίσεις. + Η φράση κρυπτογράφησης της βάσης δεδομένων θα ενημερωθεί και θα αποθηκευτεί στο Keystore. + Σφάλμα βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων + Αναγνωριστικό βάσης δεδομένων: %d + Αναγνωριστικά βάσης δεδομένων και επιλογή απομόνωσης μεταφοράς. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης. Παρακαλώ άλλαξέ την πριν την εξαγωγή. + Η βάση δεδομένων είναι κρυπτογραφημένη με τυχαία φράση πρόσβασης, μπορείς να την αλλάξεις. + Η μετεγκατάσταση της βάσης δεδομένων βρίσκεται σε εξέλιξη.\nΜπορεί να χρειαστούν λίγα λεπτά. + Φράση πρόσβασης βάσης δεδομένων + Φράση πρόσβασης βάσης δεδομένων και εξαγωγή αυτής + Η φράση πρόσβασης της βάσης δεδομένων διαφέρει από αυτή που έχει αποθηκευτεί στο Keystore. + Η φράση πρόσβασης της βάσης δεδομένων απαιτείται για να ανοίξεις τη συνομιλία. + Αναβάθμιση βάσης δεδομένων + Η έκδοση της βάσης δεδομένων είναι νεότερη από την εφαρμογή, αλλά δεν υπάρχει δυνατότητα υποβάθμισης για: %s + Η βάση δεδομένων θα κρυπτογραφηθεί. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις. + Η βάση δεδομένων θα κρυπτογραφηθεί και η φράση πρόσβασης θα αποθηκευτεί στο Keystore. + ημέρες + %d συνομιλία/ες + %d συνομιλίες με μέλη + %d επαφή/ές επιλέχθηκε/καν + %dη + %d ημέρα + %d ημέρες + Αποστολή για αποσφαλμάτωση + Αποκεντρωμένο + Σφάλμα αποκωδικοποίησης + Σφάλμα αποκρυπτογράφησης + σφάλματα αποκρυπτογράφησης + προεπιλογή (%s) + προεπιλογή (%s) + Διέγραψε + Διαγραφή + Διαγραφή + Διαγραφή + Διαγραφή διεύθυνσης + Διαγραφή διεύθυνσης; + Διαγραφή μετά + Διαγραφή όλων των αρχείων + Διαγραφή και ειδοποίηση επαφής + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας + Διαγραφή συνομιλίας; + Διαγραφή μηνυμάτων συνομιλίας από τη συσκευή σου. + Διαγραφή προφίλ συνομιλίας + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας; + Διαγραφή προφίλ συνομιλίας για + Διαγραφή συνομιλίας με μέλος; + Διαγραφή επαφής + Διαγραφή επαφής; + Διαγράφηκε + Διαγράφηκε στις + Διαγραφή βάσης δεδομένων + Διαγραφή βάσης δεδομένων από αυτήν τη συσκευή + Διαγράφηκε στις: %s + διεγραμμένη επαφή + διεγραμμένη ομάδα + Διαγραφή %d μηνυμάτων; + Διαγραφή %d μηνυμάτων μελών; + Διαγραφή αρχείου + Διαγραφή μηνυμάτων και πολυμέσων; + Διαγραφή μηνυμάτων για όλα τα προφίλ συνομιλίας + Διαγραφή για όλους + Διαγραφή για μένα + Διαγραφή ομάδας + Διαγραφή ομάδας; + Διαγραφή εικόνας + Διαγραφή συνδέσμου + Διαγραφή συνδέσμου; + Διαγραφή λίστας; + Διαγραφή μηνύματος μέλους; + Διαγραφή μηνυμάτων μέλους + Διαγραφή μηνυμάτων μέλους; + Διαγραφή μηνύματος; + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων + Διαγραφή μηνυμάτων μετά + Διαγραφή ή διαχείριση εώς 200 μηνυμάτων. + Να διαγραφεί η εκκρεμής σύνδεση; + Διαγραφή προφίλ + Διαγραφή ουράς + Διαγραφή αναφοράς + Διαγραφή διακομιστή + Διαγραφή εώς 20 μηνυμάτων ταυτόχρονα. + Διαγραφή χωρίς ειδοποίηση + Σφάλματα διαγραφής + Παράδοση + Επιβεβαιώσεις παράδοσης! + Οι επιβεβαιώσεις παράδοσης είναι απενεργοποιημένες! + Απαρχαιωμένες επιλογές + Περιγραφή + Περιγραφή πολύ μεγάλη + Υπολογιστής + Διεύθυνση υπολογιστή + Η έκδοση της εφαρμογής για υπολογιστή %s δεν είναι συμβατή με αυτήν την εφαρμογή. + Συσκευές υπολογιστή + Ο υπολογιστής έχει μη υποστηριζόμενη έκδοση. Βεβαιώσου ότι χρησιμοποιείς την ίδια έκδοση και στις δύο συσκευές. + Ο υπολογιστής έχει λάθος κωδικό πρόσκλησης + Ο υπολογιστής είναι απασχολημένος + Ο υπολογιστής είναι ανενεργός + Ο υπολογιστής έχει αποσυνδεθεί + Η διεύθυνση διακομιστή προορισμού %1$s δεν είναι συμβατή με τις ρυθμίσεις του διακομιστή προώθησης %2$s. + Σφάλμα διακομιστή προορισμού: %1$s + Η έκδοση του διακομιστή προορισμού %1$s δεν είναι συμβατή με τον διακομιστή προώθησης %2$s. + Αναλυτικά στατιστικά + Λεπτομέρειες + Επιλογές προγραμματιστή + Εργαλεία προγραμματιστή + ΣΥΣΚΕΥΗ + Η επαλήθευση συσκευής είναι απενεργοποιημένη. Απενεργοποιείται το SimpleX Lock. + Η επαλήθευση συσκευής δεν είναι ενεργοποιημένη. Μπορείς να ενεργοποιήσεις το SimpleX Lock από τις Ρυθμίσεις, αφού πρώτα ενεργοποιήσεις την επαλήθευση συσκευής. + Συσκευές + %d αρχείο/α με συνολικό μέγεθος %s + %d συμβάντα ομάδας + %dω + %dώρα + %dώρες + διαφορετική μετεγκατάσταση στην εφαρμογή/βάση δεδομένων: %s / %s + Διαφορετικά ονόματα, avatar και απομόνωση μεταφοράς. + άμεσα + Άμεσα μηνύματα + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα. + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτή τη συνομιλία + Τα απευθείας μηνύματα μεταξύ των μελών, είναι απαγορευμένα σε αυτήν την ομάδα. + Απενεργοποίηση + Απενεργοποίηση + Απενεργοποίηση αυτόματης διαγραφής μηνυμάτων; + απενεργοποιημένο + απενεργοποιημένο + Απενεργοποιημένο + Απενεργοποίηση διαγραφής μηνυμάτων + Απενεργοποίηση για όλους + Απενεργοποίηση για όλες τις ομάδες + πενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Απενεργοποίηση (διατήρηση παρακάμψεων) + Απενεργοποίηση ειδοποιήσεων + Απενεργοποίηση αναφορών παράδοσης; + Απενεργοποιίηση αναφορών παράδοσης για τις ομάδες; + Απερνεργοποίση SimpleX Lock + Μήνυμα που εξαφανίζεται + Μηνύματα που εξαφανίζονται + Μηνύματα που εξαφανίζονται + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται. + Είναι απαγορευμένα τα μηνύματα που εξαφανίζονται σε αυτήν τη συνομιλία. + Να εξαφανιστεί σε + Να εξαφανιστεί σε: %s + Αποσύνδεση + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Αποσυνδεδεμένος + Αποσυνδεδεμένος με το λόγο: %s + Αποσύνδεση τηλεφώνων + Ανιχνεύσιμο μέσω τοπικού δικτύου + Ανακάλυψε και συνδέσου σε ομάδες + Ανακάλυψη μέσω τοπικού δικτύου + Το εμφανιζόμενο όνομα δεν μπορεί να περιέχει κενά. + %dμ + %dμηνύματα + %dμηνύματα μπλοκαρισμενα από το διαχειριστή + %d λπτ + %d λεπτά + %dμήνας + %d μήνες + %dμν + Να μη σταλεί το ιστορικό σε νέα μέλη. + ΜΗΝ στέλνεις μηνύματα απευθείας, ακόμα κι αν ο δικός σου διακομιστής ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Μην χρησιμοποιείς διαπιστευτήρια με το διακομιστή μεσολάβησης (proxy). + ΜΗΝ χρησιμοποιείς ιδιωτική δρομολόγηση. + Μην δημιουργήσεις διεύθυνση + Μην ενεργοποιήσεις + Μην χάσεις σημαντικά μηνύματα. + Να μην εμφανιστεί ξανά + Υποβάθμιση και άνοιγμα συνομιλίας + Κατέβασμα + Κατέβασμα + Κατέβηκε + Κατεβασμένα αρχεία + Σφάλματα λήψης + Η λήψη απέτυχε + Λήψη αρχείου + Η αναβάθμιση εφαρμογής βρίσκεται σε εξέλιξη, μην κλείσεις την εφαρμογή + Λήψη αρχείου αρχειοθέτησης + Λήψη λεπτομερειών συνδέσμου + Κατέβασε νέες εκδόσεις από το GitHub. + Κατέβασμα %s (%s) + %d αναφορές + %dδ + %dδευτ + %dδευτερόλεπτα + Διπλότυπο εμφανιζόμενο όνομα! + διπλότυπο μήνυμα + διπλότυπα + %dε + %d εβδομάδα + %d εβδομάδες + e2e κρυπτογραφημένο + e2e κρυπτογραφημένη φωνητική κλήση + e2e κρυπτογραφημένη βιντεοκλήση + Ακουστικό + Επεξεργάσου + Επεξεργασία + επεξεργάστηκε + Επεξεργασία προφίλ ομάδας + Επεξεργασία εικόνας + Email + Ενεργοποίηση + Ενεργοποίηση αυτόματης διαγραφής μηνυμάτων + Ενεργοποίηση φωνητικών κλήσεων από την οθόνη κλειδώματος μέσω των Ρυθμίσεων + Ενεργοποίηση πρόσβασης κάμερας + ενεργοποιημένο + Ενεργοποιημένο για + ενεργοποιημένο για την επαφή + ενεργοποιημένο για εσένα + Ενεργοποίηση μηνυμάτων που εξαφανίζονται από προεπιλογή. + Ενεργοποίησε το Flux στις ρυθμίσεις Δικτύου & διακομιστών για καλύτερη προστασία μεταδεδομένων. + Ενεργοποίηση για όλα + Ενεργοποίηση για όλες τις ομάδες + Ενεργοποίηση στις άμεσες συνομιλίες (ΔΟΚΙΜΑΣΤΙΚΟ)! + Ενεργοποίηση (διατήρηση παρακάμψεων ομάδας) + Ενεργοποίηση (διατήρηση παρακάμψεων) + Ενεργοποίηση κλειδώματος + Ενεργοποίηση αρχείων καταγραφής δραστηριότητας + Ενεργοποίηση αναφορών παράδοσης; + Ενεργοποίηση αναφορών παράδοσης για τις ομάδες; + Ενεργοποίηση αυτοκαταστροφής + Ενεργοποίηση κωδικού αυτοκαταστροφής + Ενεργοποίηση SImpleX Lock + Ενεργοποίηση TCP keep-alive + Κρυπτογράφηση + Κρυπτογράφηση βάσης δεδομένων; + Κρυπτογραφημένη βάση δεδομένων + κρυπτογράφηση συμφωνήθηκε + κρυπτογράφηση συμφωνήθηκε για %s + κρυπτογράφηση οκ + κρυπτογράφηση οκ για %s + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης + επιτρέπεται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Σφάλμα κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Αποτυχία κατά την επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης σε εξέλιξη. + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης + απαιτείται επαναδιαπραγμάτευση κρυπτογράφησης για %s + Κρυπτογράφηση τοπικών αρχείων + Κρυπτογράφηση αποθηκευμένων αρχείων & πολυμέσων + Τερματισμός κλήσης + τερματίστηκε + Εισήγαγε σωστή φράση πρόσβασης. + Εισήγαγε όνομα ομάδας: + Εισήγαγε κωδικό πρόσβασης + Εισήγαγε φράση πρόσβασης + Εισήγαγε φράση πρόσβασης… + Εισήγαγε κωδικό στην αναζήτηση + Χειροκίνητη εισαγωγή διακομιστή + Εισήγαγε το όνομα συσκευής… + Εισήγαγε το μήνυμα καλωσορίσματος… + Εισήγαγε το μήνυμα καλωσορίσματος… (προαιρετικό) + Εισήγαγε το όνομά σου: + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα + Σφάλμα: %1$s + Σφάλμα κατά την ακύρωση αλλαγής διεύθυνσης + Σφάλμα κατά την αποδοχή των όρων + Σφάλμα κατά την αποδοχή του αιτήματος επαφής + Σφάλμα κατά την αποδοχή μέλους + Επιδιόρθωση σύνδεσης; + Επιδιόρθωση σύνδεσης; + Διόρθωσε την κρυπτογράφηση μετά από επαναφορά αντιγράφων ασφαλείας. + Η επιδιόρθωση δεν υποστηρίζεται από την επαφή + Η επιδιόρθωση δεν υποστηρίζεται από μέλος ομάδας + Αντιστροφή κάμερας + Μέγεθος γραμματοσειράς + Για όλους τους διαχειριστές + για καλύτερη ιδιωτικότητα μεταδεδομένων + Για το προφίλ συνομιλίας %s: + ΓΙΑ ΚΟΝΣΟΛΑ + Για όλους + Για παράδειγμα, αν η επαφή σου λαμβάνει μηνύματα μέσω κάποιου SimpleX Chat διακομιιστή, η εφαρμογή σου θα τα παραδίδει μέσω ενός Flux διακομιστή. + Για μένα + Για ιδιωτική δρομολόγηση + Για μέσα κοινωνικής δικτύωσης + Προώθηση + Προώθηση %1$s μηνύματος/ων; + Προώθηση και αποθήκευση μηνυμάτων + προωθήθηκε + Προωθήθηκε + Προωθήθηκε από + Προώθηση %1$s μηνυμάτων + Διακομιστής προώθησης: %1$s\nΣφάλμα διακομιστή προορισμού: %2$s + Διακομιστής προώθησης: %1$s\nΣφάλμα: %2$s + Ο διακομιστής προώθησης %1$s δεν κατάφερε να συνδεθεί με τον διακομιστή προορισμού %2$s. Δοκίμασε ξανά αργότερα. + Η διεύθυνση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Η έκδοση του διακομιστή προώθησης είναι ασύμβατη με τις ρυθμίσεις του δικτύου: %1$s. + Προώθηση μηνύματος… + Προώθηση μηνυμάτων… + Προώθηση μηνυμάτων χωρίς τα αρχεία; + Προώθησε μέχρι και 20 μηνύματα μαζί. + Βρέθηκε υπολογιστής + Διεπαφή στα γαλλικά + Από τη Συλλογή + Πλήρης σύνδεσμος + Πλήρης σύνδεσμος + Πλήρες όνομα: + Πλήρως αποκεντρωμένο – ορατό μόνο στα μέλη. + Περαιτέρω μειωμένη κατανάλωση μπαταρίας + Λάβε ειδοποίηση όταν σε αναφέρουν. + Καλησπέρα! + Καλημέρα! + Χορήγησε στις ρυθμίσεις + Χορήγησε άδειες + Χορήγησε άδεια/ες για να κάνεις φωνητικές κλήσεις + Ομάδα + Ομάδα + Η ομάδα υπάρχει ήδη! + η ομάδα διαγράφηκε + Πλήρες όνομα ομάδας: + Ανενεργή ομάδα + Έληξε η πρόσκληση ομάδας + Η πρόσκληση για την ομάδα δεν ισχύει πια, αφαιρέθηκε από τον αποστολέα. + η ομάδα διαγράφηκε + Σύνδεσμος ομάδας + Σύνδεσμοι ομάδας + Διαχείριση ομάδας + Η ομάδα δεν βρέθηκε! + Προτιμήσεις ομάδας + Το προφίλ της ομάδας αποθηκεύεται στις συσκευές των μελών και όχι στους διακομιστές. + το προφίλ της ομάδας ανανεώθηκε + Ομάδες + Μήνυμα καλωσορίσματος ομάδας + Η ομάδα θα διαγραφεί για όλα τα μέλη – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η ομάδα θα διαγραφεί για σένα – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Τερματισμός κλήσης + Ακουστικά + βοήθεια + ΒΟΗΘΕΙΑ + Βοήθησε τους διαχειριστές να διαχειρίζονται τις ομάδες τους. + Γεια σου!\nΣυνδέσου μαζί μου μέσω SimpleX Chat: %s + Κρυφό + Κρυμμένα προφίλ συνομιλίας + Κρυφό συνθηματικό προφίλ + Κρύψε + Κρύψε + Κρύψε + Κρύψε: + Απόκρυψη της οθόνης της εφαρμογής στις πρόσφατες εφαρμογές. + Απόκρυψη επαφής και μηνύματος + Απόκρυψη προφίλ + Ιστορικό + Το ιστορικό δεν αποστέλλεται σε νέα μέλη. + Φιλοξένησε + ώρες + Πως επηρεάζει τη μπαταρία + Πως βοηθάει την ιδιωτικότητα + Πως δουλεύει + Πως δουλεύει το SimpleX + Πως να + Πως να το χρησιμοποιήσεις + Πως να χρησιμοποιήσεις markdown σύνταξη + Πως να χρησιμοποιήσεις τους διακομιστές σου + Διεπαφή στα Ουγγρικά και Τουρκικά + ICE διακομιστές (ένας σε κάθε γραμμή) + Αν δεν μπορείς να συναντηθείς προσωπικά, δείξε τον QR κωδικό σε μια βιντεοκλήση ή μοιράσου τον σύνδεσμο. + Αν επιλέξεις να απορρίψεις, ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Αν επιβεβαιώσεις, οι διακομιστές μηνυμάτων θα μπορούν να δουν τη διεύθυνση IP σου, και ο πάροχός σου – σε ποιους διακομιστές συνδέεσαι. + Αν εισάγεις αυτόν τον κωδικό κατά το άνοιγμα της εφαρμογής, όλα τα δεδομένα της εφαρμογής θα διαγραφούν οριστικά! + Αν εισάγεις τον κωδικό αυτοκαταστροφής κατά το άνοιγμα της εφαρμογής: + Αν έλαβες σύνδεσμο πρόσκλησης για SimpleX Chat, μπορείς να τον ανοίξεις στον περιηγητή σου: + Αγνόησε + Εικόνα + Εικόνα + Η εικόνα αποθηκεύτηκε στη Συλλογή + Η εικόνα στάλθηκε + Η εικόνα θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή της. + Η εικόνα θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, περίμενε ή έλεγξε αργότερα! + Άμεσα + Ανοσοποιημένο στο spam + Εισαγωγή + Εισαγωγή βάσης δεδομένων συνομιλίας; + Εισαγωγή βάσης δεδομένων + Η εισαγωγή απέτυχε + Εισαγωγή αρχείου αρχειοθέτησης + Εισαγωγή θέματος + Σφάλμα εισαγωγής θέματος + Βελτιωμένη πλοήγηση συνομιλίας + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη παράδοση μηνύματος + Βελτιωμένη ιδιωτικότητα και ασφάλεια + Βελτιωμένη διαμόρφωση διακομιστή + ανενεργό + Ακατάλληλο περιεχόμενο + Ακατάλληλο προφίλ + Ήχοι κατά τη διάρκεια κλήσης + Ανώνυμο + Ανώνυμες ομάδες + Ανώνυμη λειτουργία + Η ανώνυμη λειτουργία προστατεύει το απόρρητό σου χρησιμοποιώντας ένα νέο τυχαίο προφίλ για κάθε επαφή. + ανώνυμα μέσω συνδέσμου διεύθυνσης επαφής + ανώνυμα μέσω συνδέσμου ομάδας + ανώνυμα μέσω συνδέσμου 1-χρήσης + Εισερχόμενη φωνητική κλήση + Εισερχόμενη βιντεοκλήση + Ασύμβατη έκδοση βάσης δεδομένων + Ασύμβατη έκδοση + Λανθασμένος κωδικός πρόσβασης + Λανθασμένος κωδικός ασφάλειας! + Μεγένθυση γραμματοσειράς + έμμεσο (%1$s) + Πληροφορίες + Αρχικός ρόλος + Για να συνεχίσεις, η συνομιλία θα πρέπει να σταματήσει. + Σε απάντηση του + Εγκαταστάθηκε επιτυχημένα + Εγκατέστησε το SimpleX Chat για το τερματικό + Εγκατάσταση αναβάθμισης + Άμεσα + Άμεσες ειδοποιήσεις + Άμεσες ειδοποιήσεις! + Οι άμεσες ειδοποιήσεις είναι απενεργοποιημένες! + ΧΡΩΜΑΤΑ ΔΙΕΠΑΦΗΣ + Εσωτερικό σφάλμα + μη έγκυρη συνομιλία + Μη έγκυρος σύνδεσμος + μη έγκυρα δεδομένα + Μη έγκυρο εμφανιζόμενο όνομα! + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος + Μη έγκυρος σύνδεσμος! + μη έγκυρη διαμόρφωση μηνύματος + Μη έγκυρη επιβεβαίωση μετεγκατάστασης + Μη έγκυρο όνομα! + Μη έγκυρος QR κωδικός + Μη έγκυρος QR κωδικός + Μη έγκυρη διεύθυνση διακομιστή! + Η πρόσκληση έληξε! + πρόσκληση στην ομάδα %1$s + Προσκάλεσε + Προσκάλεσε + προσκαλεσμένος + προσκεκλημένος %1$s + προσκεκλημένος για σύνδεση + προσκεκλημένος μέσω του συνδέσμου της ομάδας σου + Προσκάλεσε φίλους + Προσκάλεσε μέλη + Προσκάλεσε μέλη + Προσκάλεσε για συνομιλία + Προσκάλεσε σε ομάδα + Οριστική διαγραφή μηνύματος + Η οριστική διαγραφή μηνύματος απαγορεύεται. + Η οριστική διαγραφή μηνύματος απαγορεύεται σε αυτή τη συνομιλία. + Διεπαφή στα Ιταλικά + πλάγια γραφή + Σου επιτρέπει να έχεις πολλές ανώνυμες συνδέσεις χωρίς κοινά δεδομένα μεταξύ τους σε ένα μόνο προφίλ συνομιλίας. + Μπορεί να συμβεί όταν:\n1. Τα μηνύματα λήγουν στον αποστολέα μετά από 2 ημέρες ή στον διακομιστή μετά από 30 ημέρες.\n2. Η αποκρυπτογράφηση μηνύματος απέτυχε, επειδή εσύ ή η επαφή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων.\n3. Η σύνδεση έχει παραβιαστεί. + Μπορεί να συμβεί όταν εσύ ή η σύνδεσή σου χρησιμοποιήσατε παλιό αντίγραφο ασφαλείας της βάσης δεδομένων. + Προστατεύει τη διεύθυνση IP και τις συνδέσεις σου. + Διεπαφή στα Ιαπωνικά και Πορτογαλικά + Συμμετοχή + Συμμετοχή ως %s + Συμμετοχή στην ομάδα + Συμμετοχή στην ομάδα; + Συμμετοχή στις συνομιλίες της ομάδας + Ανώνυμη συμμετοχή + Συμμετοχή στη ομάδα + Θέλεις να συμμετάσχεις στην ομάδα σου; + χ + Διατήρηση + Διατήρηση συνομιλίας + Διατήρηση αχρησιμοποίητων προσκλήσεων; + Διατήρησε καθαρές τις συνομιλίες σου + Διατήρησε τις συνδέσεις + Σφάλμα κλειδιού + Μεγάλο αρχείο! + Μάθε περισσότερα + Αποχώρησε + Αποχώρησε από τη συνομιλία + Αποχώρηση από τη συνομιλία; + Αποχώρησε από την ομάδα + Αποχώρηση από την ομάδα; + αποχώρησε + αποχώρησε + Λιγότερη κίνηση στα δίκτυα κινητής τηλεφωνίας. + Ας μιλήσουμε στο SimpleX Chat + Ανοιχτόχρωμο + Ανοιχτόχρωμο + Ανοιχτόχρωμη λειτουργία + Σύνδεσε ένα τηλέφωνο + Επιλογές συνδεδεμένου υπολογιστή + Συνδεδεμένοι υπολογιστές + Συνδεδεμένα τηλέφωνα + Σύνδεσε τις εφαρμογές κινητού και υπολογιστή! 🔗 + εικόνα προεπισκόπησης συνδέσμου + Λίστα + Όνομα λίστας... + Το όνομα της λίστας και το emoji πρέπει να είναι διαφορετικά για όλες τις λίστες. + Διεπαφή στα Λιθουανικά + ΖΩΝΤΑΝΑ + Ζωντανό μήνυμα! + Ζωντανά μηνύματα! + Φόρτωση συνομιλιών… + Φόρτωση προφίλ… + Φόρτωση αρχείου + Τοπικό όνομα + Μόνο τοπικά δεδομένα προφίλ + Κλείδωμα μετά + Λειτουργία κλειδώματος + Σύνδεση χρησιμοποιώντας τα στοιχεία σου + Δημιούργησε μία ιδιωτική συνομιλία + Εξαφάνισε ένα μήνυμα + Κάνε το προφίλ ιδιωτικό! + Βεβαιώσου ότι έχεις σωστή διαμόρφωση του διακομιστή μεσολάβησης. + Βεβαιώσου ότι οι διευθύνσεις του διακομιστή SMP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Βεβαιώσου ότι το αρχείο έχει σωστή σύνταξη YAML. Κάνε εξαγωγή ενός θέματος για να έχεις παράδειγμα της δομής αρχείου των θεμάτων. + "Βεβαιώσου ότι οι διευθύνσεις των διακομιστών WebRTC ICE έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες." + Βεβαιώσου ότι οι διευθύνσεις των διακομιστών XFTP έχουν σωστή μορφή, διαχωρίζονται με νέα γραμμή και δεν είναι διπλότυπες. + Κάνε τις συνομιλίες σου να ξεχωρίζουν! + Βοήθεια στη Markdown σύνταξη + Σύνταξη Markdown στα μηνύματα + Επισήμανση ως αναγνωσμένο + Επισήμανση ως μη αναγνωσμένο + Επισήμανση ως επαληθευμένο + Μέχρι 40 δευτερόλεπτα, λαμβάνεται άμεσα. + Διακομιστές πολυμέσων & αρχείων + Μεσαίο + μέλος + ΜΕΛΟΣ + Μέλος %1$s + το μέλος %1$s άλλαξε σε %2$s + Εγγραφή μέλους + το μέλος έχει παλαιότερη έκδοση + Το μέλος είναι ανενεργό + Το μέλος διαγράφηκε – δεν μπορεί να γίνει αποδοχή του αιτήματος + Τα μηνύματα του μέλους θα διαγραφούν – αυτό δεν μπορεί να αναιρεθεί! + Αναφορές μέλους + Τα μέλη μπορούν να προσθέτουν αντιδράσεις στα μηνύματα. + Τα μέλη μπορούν να διαγράψουν οριστικά τα απεσταλμένα μηνύματα. (24 ώρες) + Τα μέλη μπορούν να αναφέρουν μηνύματα στους διαχειριστές. + Τα μέλη μπορούν να στέλνουν απευθείας μηνύματα. + Τα μέλη μπορούν να στέλνουν μηνύματα που εξαφανίζονται. + Τα μέλη μπορούν να στέλνουν αρχεία και πολυμέσα. + Τα μέλη μπορούν να στέλνουν συνδέσμους SimpleX. + Τα μέλη μπορούν να στέλνουν φωνητικά μηνύματα. + Τα μέλη θα αφαιρεθούν από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Τα μέλη θα αφαιρεθούν από τη ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από τη συνομιλία – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα αφαιρεθεί από την ομάδα – αυτό δεν μπορεί να αναιρεθεί! + Το μέλος θα συμμετάσχει στην ομάδα, να γίνει αποδοχή του; + Επισήμανση μελών 👋 + Μενού & προειδοποιήσεις + μήνυμα + Μήνυμα + Σφάλμα παράδοσης μηνύματος + Αναφορές παράδοσης μηνύματος! + Προειδοποίηση παράδοσης μηνύματος + Πρόχειρο μήνυμα + Πρόχειρο μήνυμα + Το μήνυμα προωθήθηκε + Στείλε μήνυμα αμέσως μόλις πατήσεις Σύνδεση. + Το μήνυμα είναι πολύ μεγάλο! + Το μήνυμα μπορεί να παραδοθεί αργότερα όταν το μέλος γίνει ενεργό. + Πληροφορίες ουράς μηνυμάτων + Αντιδράσεις μηνυμάτων + Αντιδράσεις μηνυμάτων + Απαγορεύονται οι αντιδράσεις στα μηνύματα. + Απαγορεύονται οι αντιδράσεις στα μηνύματα σε αυτήν τη συνομιλία. + Λήψη μηνυμάτων + Εναλλακτική δρομολόγηση μηνυμάτων + Λειτουργία δρομολόγησης μηνυμάτων + Μηνύματα + ΜΗΝΥΜΑΤΑ ΚΑΙ ΑΡΧΕΙΑ + Διακομιστές μηνυμάτων + Θα εμφανιστούν τα μηνύματα από το %s! + Θα εμφανιστούν τα μηνύματα από αυτά τα μέλη! + Μορφή μηνύματος + Τα μηνύματα σε αυτήν τη συνομιλία δεν θα διαγραφούν ποτέ. + Η πηγή του μηνύματος παραμένει ιδιωτική. + Ληφθέντα μηνύματα + Απεσταλμένα μηνύματα + Κατάσταση μηνύματος + Κατάσταση μηνύματος: %s + Τα μηνύματα διαγράφηκαν αφού τα επιλέξατε. + Τα μηνύματα θα διαγραφούν - αυτό δεν μπορεί να αναιρεθεί! + Τα μηνύματα θα επισημανθούν για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτά τα μηνύματα. + Κείμενο μηνύματος + Το μήνυμα είναι πολύ μεγάλο + Το μήνυμα θα διαγραφεί - αυτό δεν μπορεί να αναιρεθεί! + Το μήνυμα θα επισημανθεί για διαγραφή. Ο/Οι παραλήπτης/ες θα μπορούν να αποκαλύψουν αυτό το μήνυμα. + Μικρόφωνο + Μετεγκατάσταση συσκευής + Μετεγκατάσταση από άλλη συσκευή + Μετεγκατάσταση εδώ + Μετεγκατάσταση σε άλλη συσκευή + Μετεγκατάσταση σε άλλη συσκευή μέσω QR κωδικού. + Μετεγκατάσταση σε εξέλιξη + Η μετεγκατάσταση ολοκληρώθηκε + Μετεγκαταστάσεις: %s + λεπτά + αναπάντητη κλήση + Αναπάντητη κλήση + Διαχειρίσου + διαχειρίζεται + Διαχειρίστηκε στις + Διαχειρίστηκε στις: %s + διαχειριστής + διαχειριστές + μήνες + Περισσότερα + Σύντομα έρχονται περισσότερες βελτιώσεις! + Σύντομα έρχονται περισσότερες βελτιώσεις! + Πιο αξιόπιστη σύνδεση δικτύου. + - πιο σταθερή παράδοση μηνυμάτων.\n- λίγο καλύτερες ομάδες.\n- και πολλά ακόμα! + Πιθανότατα αυτή η επαφή να έχει διαγράψει τη σύνδεση μαζί σου. + Πολλαπλά προφίλ συνομιλίας + Σίγαση + Σίγαση + Σίγαση όλων + Σε σίγαση όταν είναι ανενεργό! + Σύνδεση δικτύου + Αποκέντρωση δικτύου + Προβλήματα δικτύου - το μήνυμα έληξε μετά από πολλές προσπάθειες αποστολής. + Διαχείριση δικτύου + Χειριστής δικτύου + Χειριστές δικτύου + Δίκτυο & διακομιστές + Κατάσταση δικτύου + ποτέ + Ποτέ + Νέα συνομιλία + Νέα εμπειρία συνομιλίας 🎉 + Νέα θέματα συνομιλίας + Νέο αίτημα επαφής + Νέο αρχείο βάσης δεδομένων + Νέα εφαρμογή για υπολογιστές! + Νέο εμφανιζόμενο όνομα: + δευτερόλεπτα + Το βιογραφικό σου: + Πάτα Σύνδεση για να συνομιλήσεις + Πάτα Σύνδεση για αποστολή αιτήματος + Πάτα Σύνδεση για να χρησιμοποιήσεις το μποτ + Πατήστε Δημιουργία διεύθυνσης SimpleX στο μενού, για να τη δημιουργήσετε αργότερα. + Πάτα Συμμετοχή στην ομάδα + Πάτα για να ενεργοποιήσεις το προφίλ. + Πάτα για Σύνδεση + Πάτα για συμμετοχή + Πάτα για ανώνυμη συμμετοχή + Πάτα για επικόλληση συνδέσμου + Πάτα για σάρωση + Πάτα για να ξεκινήσεις μία νέα συνομιλία + Σύνδεση TCP + Χρόνος λήξης σύνδεσης TCP στο παρασκήνιο + Χρόνος λήξης σύνδεσης TCP + Θύρα TCP για ανταλλαγή μηνυμάτων + Σφάλμα προσωρινού αρχείου + Η δοκιμή απέτυχε στο βήμα %s. + Δοκιμή διακομιστή + Δοκιμή διακομιστών + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Ευχαριστούμε τους χρήστες – συνεισφέρετε μέσω του Weblate! + Σε ευχαριστούμε που εγκατέστησες το SimpleX Chat! + Η διεύθυνση θα είναι σύντομη και το προφίλ σου θα κοινοποιηθεί μέσω αυτής. + Η εφαρμογή λαμβάνει νέα μηνύματα περιοδικά — καταναλώνει ένα μικρό ποσοστό της μπαταρίας ανά ημέρα. Η εφαρμογή δεν χρησιμοποιεί ειδοποιήσεις push — τα δεδομένα από τη συσκευή σου δεν αποστέλλονται στους διακομιστές. + Η εφαρμογή ενδέχεται να κλείσει μετά από 1 λεπτό στο παρασκήνιο. + Η εφαρμογή προστατεύει το απόρρητό σου χρησιμοποιώντας διαφορετικούς χειριστές σε κάθε συνομιλία. + Η εφαρμογή θα ζητήσει επιβεβαίωση για λήψεις από άγνωστους διακομιστές αρχείων (εκτός από .onion ή όταν είναι ενεργοποιημένος ο διακομιστής μεσολάβησης SOCKS). + Η προσπάθεια αλλαγής της φράσης πρόσβασης της βάσης δεδομένων δεν ολοκληρώθηκε. + Ο κωδικός που σάρωσες δεν είναι κωδικός QR ενός συνδέσμου SimpleX. + Η σύνδεση έφτασε στο όριο των μη παραδοθέντων μηνυμάτων, η επαφή σου ενδέχεται να είναι εκτός σύνδεσης. + Η σύνδεση που αποδέχθηκες θα ακυρωθεί! + Η επαφή με την οποία μοιράστηκες αυτόν το σύνδεσμο, ΔΕΝ θα μπορεί να συνδεθεί! + Η βάση δεδομένων δεν λειτουργεί σωστά. Πάτησε για να μάθεις περισσότερα. + Για τις κλήσεις απαιτείται ο προεπιλεγμένος περιηγητής. Ρύθμισε τον προεπιλεγμένο περιηγητή στο σύστημα σου και μοιράσου περισσότερες πληροφορίες με τους προγραμματιστές. + Το όνομα της συσκευής θα κοινοποιηθεί στην εφαρμογή του συνδεδεμένου κινητού. + Η κρυπτογράφηση λειτουργεί και η νέα κρυπτογράφηση δεν είναι απαραίτητη. Μπορεί να προκαλέσει σφάλματα σύνδεσης! + Το μέλλον στην ανταλλαγή μηνυμάτων + Ο κωδικός ελέγχου του προηγούμενου μηνύματος είναι διαφορετικός. + Ο αναγνωριστικός κωδικός του επόμενου μηνύματος είναι λανθασμένος (μικρότερος ή ίσος με τον προηγούμενο).\nΑυτό μπορεί να συμβεί λόγω κάποιου σφάλματος ή όταν η σύνδεση έχει παραβιαστεί. + Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. + Ο σύνδεσμος θα είναι σύντομος και το προφίλ της ομάδας θα κοινοποιηθεί μέσω αυτού. + Θέμα + ΘΕΜΑΤΑ + Τα μηνύματα θα διαγραφούν για όλα τα μέλη. + Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. + Το μήνυμα θα διαγραφεί για όλα τα μέλη. + Το μήνυμα θα επισημανθεί ως υπό έλεγχο για όλα τα μέλη. + Η πλατφόρμα μηνυμάτων και εφαρμογών που προστατεύει το απόρρητο και την ασφάλειά σου. + Η φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο. + Η φράση πρόσβασης θα αποθηκευτεί στις ρυθμίσεις ως απλό κείμενο μετά την αλλαγή της ή την επανεκκίνηση της εφαρμογής. + Το προφίλ κοινοποιείται μόνο στις επαφές σου. + Η αναφορά θα αρχειοθετηθεί για εσένα. + Ο ρόλος θα αλλάξει σε %s. Όλοι οι συμμετέχοντες στη συνομιλία θα ειδοποιηθούν. + Ο ρόλος θα αλλάξει σε %s. Όλα τα μέλη της ομάδας θα ενημερωθούν. + Ο ρόλος θα αλλάξει σε %s. Το μέλος θα λάβει νέα πρόσκληση. + Ο δεύτερος προκαθορισμένος χειριστής στην εφαρμογή! + Το δεύτερο τικ που χάσαμε! ✅ + Ο αποστολέας ΔΕΝ θα ειδοποιηθεί. + Οι διακομιστές για τις νέες συνδέσεις του τρέχοντος προφίλ συνομιλίας σου + Οι διακομιστές για τα νέα αρχεία του τρέχοντος προφίλ συνομιλίας σου + Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου + Το κείμενο που επικόλλησες δεν είναι σύνδεσμος SimpleX. + Το αρχείο της βάσης δεδομένων που μεταφορτώθηκε, θα διαγραφεί οριστικά από τους διακομιστές. + Το βίντεο δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε ένα άλλο βίντεο ή επικοινώνησε με τους προγραμματιστές. + Μπορούν να παρακαμφθούν στις ρυθμίσεις επαφών και ομάδων. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - όλα τα ληφθέντα και απεσταλμένα αρχεία και πολυμέσα θα διαγραφούν. Οι εικόνες χαμηλής ανάλυσης θα παραμείνουν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. Η διαδικασία μπορεί να διαρκέσει αρκετά λεπτά. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - τα μηνύματα που έχουν αποσταλεί και παραληφθεί σε αυτήν τη συνομιλία, πριν από την επιλεγμένη ημερομηνία, θα διαγραφούν. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου, θα χαθούν οριστικά και ανεπανόρθωτα. + Αυτή η συνομιλία προστατεύεται με κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συνομιλία προστατεύεται με κβαντο-ανθεκτική κρυπτογράφηση από άκρη-σε-άκρη. + Αυτή η συσκευή + Το όνομα αυτής της συσκευής + Το εμφανιζόμενο όνομα δεν είναι έγκυρο. Επέλεξε ένα άλλο όνομα. + Αυτή η λειτουργία δεν υποστηρίζεται ακόμη. Δικίμασε την επόμενη έκδοση. + Αυτή η ομάδα έχει πάνω από %1$d μέλη, δεν αποστέλλονται αναφορές παράδοσης. + Αυτή η ομάδα δεν υπάρχει πλέον. + Αυτός είναι ο δικός σου σύνδεσμος 1-χρήσης! + Αυτή είναι η διεύθυνση σου SimpeX! + Αυτός ο σύνδεσμος δεν είναι έγκυρος! + Αυτός ο σύνδεσμος απαιτεί νεότερη έκδοση της εφαρμογής. Αναβάθμισε την εφαρμογή ή ζήτησε από την επαφή σου να σου στείλει ένα συμβατό σύνδεσμο. + Αυτός ο σύνδεσμος χρησιμοποιήθηκε με άλλη κινητή συσκευή. Δημιούργησε ένα νέο σύνδεσμο στον υπολογιστή σου. + Αυτό το μήνυμα διαγράφηκε ή δεν έχει ληφθεί ακόμα. + Αυτός ο κωδικός QR δεν είναι σύνδεσμος! + Αυτή η ρύθμιση ισχύει για τα μηνύματα στο τρέχον προφίλ συνομιλίας σου. + Αυτή η ρύθμιση αφορά το τρέχον προφίλ σου. + Αυτό το κείμενο δεν είναι σύνδεσμος! + Αυτό το κείμενο είναι διαθέσιμο στις ρυθμίσεις + Εξαντλήθηκε ο χρόνος αναμονής κατά τη σύνδεση με τον υπολογιστή + Ο χρόνος εξαφάνισης ορίζεται μόνο για τις νέες επαφές. + Τίτλος + Για να επιτρέψεις σε μια εφαρμογή κινητού να συνδεθεί στον υπολογιστή, άνοιξε αυτήν τη θύρα στο τείχος προστασίας σου, εάν το έχεις ενεργοποιήσει. + Για να λαμβάνεις ειδοποιήσεις σχετικά με τις νέες εκδόσεις, ενεργοποίησε τον περιοδικό έλεγχο για σταθερές ή δοκιμαστικές εκδόσεις. + Για να συνδεθείς μέσω συνδέσμου + Για να συνδεθείς, η επαφή σου μπορεί να σαρώσει τον κωδικό QR ή να χρησιμοποιήσει τον σύνδεσμο στην εφαρμογή. + Εναλλαγή λίστας συνομιλιών: + Ενεργοποίηση ανώνυμης λειτουργίας κατά τη σύνδεση. + Για απόκρυψη ανεπιθύμητων μηνυμάτων. + Για να πραγματοποιήσεις κλήσεις, επέτρεψε τη χρήση του μικροφώνου σου. Τερμάτισε την κλήση και προσπάθησε να καλέσεις ξανά. + Πάρα πολλές εικόνες! + Πάρα πολλά βίντεο! + Για να προστατευτείς από αντικατάσταση του συνδέσμου σου, μπορείς να συγκρίνεις τους κωδικούς ασφαλείας των επαφών σου. + Για την προστασία της ζώνης ώρας, τα αρχεία εικόνας/φωνής χρησιμοποιούν UTC ώρα. + Για να προστατεύσεις τις πληροφορίες σου, ενεργοποίησε το SimpleX Lock.\nΘα σου ζητηθεί να ολοκληρώσεις την επαλήθευση ταυτότητας πριν ενεργοποιηθεί αυτή η λειτουργία. + Για την προστασία της IP διεύθυνσής σου, η ιδιωτική δρομολόγηση χρησιμοποιεί τους διακομιστές SMP για την παράδοση μηνυμάτων. + Για την προστασία της ιδιωτικότητάς σου, το SimpleX χρησιμοποιεί ξεχωριστά αναγνωριστικά για κάθε μία από τις επαφές σου. + Για λήψη + Για να λαμβάνεις ειδοποιήσεις, παρακαλώ εισήγαγε τη φράση πρόσβασης της βάσης δεδομένων. + Για να αποκαλύψεις το κρυφό προφίλ σου, εισήγαγε έναν πλήρη κωδικό στο πεδίο αναζήτησης στη σελίδα Τα προφίλ συνομιλίας σου. + Για αποστολή + Για αποστολή εντολών, θα πρέπει να είσαι συνδεδεμένος. + (για διαμοιρασμό με την επαφή σου) + Για να ξεκινήσεις μία νέα συνομιλία + Συνολικά + Για να χρησιμοποιήσεις άλλο προφίλ μετά την προσπάθεια σύνδεσης, διέγραψε τη συνομιλία και χρησιμοποίησε ξανά τον σύνδεσμο. + Για να επαληθεύσεις την κρυπτογράφηση από άκρη-σε-άκρη με την επαφή σου, συγκρίνετε (ή σαρώστε) τον κωδικό στις συσκευές σας. + Διαφάνεια + Απομόνωση μεταφοράς + Απομόνωση μεταφοράς + Μεταφορές συνεδριών + Ενεργοποίηση + μη εξουσιοδοτημένη αποστολή + Ξεμπλοκάρισμα + ξεμπλοκαρισμένο %s + Ξεμπλοκάρισμα για όλους + Ξεμπλοκάρισμα μέλους + Ξεμπλοκάρισμα μέλους; + Ξεμπλοκάρισμα μέλους για όλους; + Ξεμπλοκάρισμα μελών για όλους; + Μηνύματα που δεν παραδόθηκαν + Αφαίρεση από τα αγαπημένα + Εμφάνιση + Εμφάνιση προφίλ συνομιλίας + Εμφάνιση προφίλ + άγνωστο + Άγνωστο σφάλμα βάσης δεδομένων: %s + Άγνωστο σφάλμα + άγνωστη μορφή μηνύματος + Άγνωστοι διακομιστές + Άγνωστοι διακομιστές! + άγνωστη κατάσταση + Εκτός αν η επαφή σου διέγραψε τη σύνδεση ή αυτός ο σύνδεσμος είχε ήδη χρησιμοποιηθεί, μπορεί να πρόκειται για σφάλμα - παρακαλούμε να το αναφέρεις.\nΓια να συνδεθείς, ζήτησε από την επαφή σου να δημιουργήσει έναν άλλο σύνδεσμο σύνδεσης και έλεγξε ότι έχεις σταθερή σύνδεση δικτύου. + Αποσύνδεση + Αποσύνδεση υπολογιστή; + Ξεκλείδωμα + Απενεργοποίηση σίγασης + Απενεργοποίηση σίγασης + Απροστάτευτο + αδιάβαστο + Μη αναγνωσμένες αναφορές + Μη υποστηριζόμενος σύνδεσμος σύνδεσης + Αναβάθμιση + Αναβάθμιση + Αναβάθμιση + Διαθέσιμη αναβάθμιση: %s + Ενημέρωση φράσης πρόσβασης της βάσης δεδομένων + Ενημερωμένοι όροι + ενημερωμένο προφίλ ομάδας + Η λήψη της ενημέρωσης ακυρώθηκε + ενημερωμένο προφίλ + Ενημέρωση ρυθμίσεων δικτύου; + Ενημέρωση της λειτουργίας απομόνωσης μεταφοράς; + Ενημέρωσε τη διεύθυνσή σου + Η ενημέρωση των ρυθμίσεων θα επανασυνδέσει την εφαρμογή με όλους τους διακομιστές. + Αναβάθμιση + Αναβάθμιση διεύθυνσης + Αναβάθμιση διεύθυνσης; + Αναβάθμιση και άνοιγμα συνομιλίας + Αυτόματη αναβάθμιση εφαρμογής + Αναβάθμιση συνδέσμου ομάδας + Αναβάθμιση συνδέσμου ομάδας; + Ανέβηκε + Ανεβασμένα αρχεία + Σφάλματα μεταφόρτωσης + Αποτυχία μεταφόρτωσης + Ανέβασμα αρχείου + Ανεβαίνει το αρχείο αρχειοθέτησης + Τα τελευταία 100 μηνύματα αποστέλλονται στα νέα μέλη. + Χρήση συνομιλίας + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε σύνδεση. + Χρησιμοποίησε διαφορετικά διαπιστευτήρια διακομιστή μεσολάβησης για κάθε προφίλ. + Χρήση απευθείας σύνδεσης στο Διαδίκτυο; + Χρήση για αρχεία + Χρήση για μηνύματα + Χρήση για νέες συνδέσεις + Χρήση από τον υπολογιστή + Χρήση ανώνυμου προφίλ + Χρήση κεωτρικών διακομιστών .onion + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές. + Χρήση ιδιωτικής δρομολόγησης με άγνωστους διακομιστές όταν η διεύθυνση IP δεν προστατεύεται. + Χρήση τυχαίων διαπιστευτηρίων + Χρήση τυχαίας φράσης πρόσβασης + Όνομα χρήστη + Χρήση %s + Χρήση διακομιστή + Χρήση διακομιστών + Χρήση διακομιστών SimpleX Chat; + Χρήση δικομιστή μεσολάβησης SOCKS + Χρήση διακομιστή μεσολάβησης SOCKS; + Χρήση της θύρας TCP %1$s όταν δεν έχει καθοριστεί θύρα. + Χρήση της θύρας TCP 443 μόνο για προκαθορισμένους διακομιστές. + Χρήση της εφαρμογής κατά τη διάρκεια μίας κλήσης. + Χρήση της εφαρμογής με το ένα χέρι + Χρήση θύρας web + Χρήση διακομιστών SimpleX Chat. + Σφάλμα κατά την προσθήκη μέλους/ων + Σφάλμα κατά την προσθήκη διακομιστή + Σφάλμα κατά το μπλοκάρισμα του μέλους, για όλους + Σφάλμα κατά την αλλαγή διεύθυνσης + Σφάλμα κατά την αλλαγή προφίλ + Σφάλμα κατά την αλλαγή ρόλου + Σφάλμα κατά την αλλαγή της ρύθμισης + Σφάλμα κατά τη σύνδεση με το διακομιστή προώθησης %1$s. Παρακαλώ δοκίμασε ξανά αργότερα. + Σφάλμα κατά τη σύνδεση με τον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση: %1$s. + Σφάλμα κατά τη δημιουργία διεύθυνσης + Σφάλμα κατά τη δημιουργία της λίστας συνομιλιών + Σφάλμα κατά τη δημιουργία συνδέσμου ομάδας + Σφάλμα κατά τη δημιουργία επαφής μέλους + Σφάλμα κατά τη δημιουργία μηνύματος + Σφάλμα κατά τη δημιουργία προφίλ! + Σφάλμα κατά τη δημιουργία της αναφοράς + Σφάλμα κατά τη διαγραφή της συνομιλίας + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά τη διαγραφή της επαφής + Σφάλμα κατά τη διαγραφή του αιτήματος της επαφής + Σφάλμα κατά τη διαγραφή της βάσης δεδομένων + Σφάλμα κατά τη διαγραφή ομάδας + Σφάλμα κατά τη διαγραφή του συνδέσμου ομάδας + Σφάλμα κατά τη διαγραφή εκκρεμούς σύνδεσης επαφής + Σφάλμα κατά τη διαγραφή ιδιωτικών σημειώσεων + Σφάλμα κατά τη διαγραφή του προφίλ χρήστη + Σφάλμα κατά τη λήψη του αρχείου αρχειοθέτησης + Σφάλμα κατά την ενεργοποίηση των αναφορών παράδοσης! + Σφάλμα κατά την κρυπτογράφηση της βάσης δεδομένων + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την εξαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα προώθησης μηνυμάτων + Σφάλμα κατά την εισαγωγή της βάσης δεδομένων συνομιλιών + Σφάλμα κατά την αρχικοποίηση του WebView. Βεβαιώσου ότι έχεις εγκαταστήσει το WebView και ότι η υποστηριζόμενη αρχιτεκτονική είναι arm64.\nΣφάλμα: %s + Σφάλμα κατά την αρχικοποίηση του WebView. Ενημέρωσε το σύστημά σου στη νέα έκδοση. Επικοινώνησε με τους προγραμματιστές.\nΣφάλμα: %s + Σφάλμα κατά τη συμμετοχή στην ομάδα + Σφάλμα κατά τη φόρτωση των λιστών συνομιλιών + Σφάλμα κατά τη φόρτωση των λεπτομερειών + Σφάλμα κατά τη φόρτωση των διακομιστών SMP + Σφάλμα κατά τη φόρτωση των διακομιστών XFTP + Σφάλμα επισήμανσης ως αναγνωσμένου + Σφάλμα κατά το άνοιγμα του προγράμματος περιήγησης + Σφάλμα κατά το άνοιγμα της συνομιλίας + Σφάλμα κατά το άνοιγμα της ομάδας + Σφάλμα κατά την ανάγνωση της φράσης πρόσβασης της βάσης δεδομένων + Σφάλμα κατά τη λήψη του αρχείου + Σφάλμα κατά την επανασύνδεση του διακομιστή + Σφάλμα κατά την επανασύνδεση των διακομιστών + Σφάλμα κατά την απόρριψη αιτήματος της επαφής + Σφάλμα κατά την αφαίρεση του μέλους + Σφάλμα επαναφοράς στατιστικών στοιχείων + Σφάλμα: %s + Σφάλματα + Σφάλμα κατά την αποθήκευση της βάσης δεδομένων + Σφάλμα κατά την αποθήκευση του αρχείου + Σφάλμα κατά την αποθήκευση του προφίλ ομάδας + Σφάλμα κατά την αποθήκευση των διακομιστών ICE + Σφάλμα κατά την αποθήκευση του διακομιστή μεσολάβησης + Σφάλμα κατά την αποθήκευση διακομιστών + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των ρυθμίσεων + Σφάλμα κατά την αποθήκευση των διακομιστών SMP + Σφάλμα κατά την αποθήκευση του κωδικού πρόσβασης χρήστη + Σφάλμα κατά την αποθήκευση διακομιστών XFTP + Σφάλμα κατά την αποστολή της πρόσκλησης + Σφάλμα κατά την αποστολή του μηνύματος + Σφάλμα κατά τη ρύθμιση της διεύθυνσης + σφάλμα κατά την εμφάνιση του περιεχομένου + σφάλμα εμφάνισης μηνύματος + Σφάλμα στην εμφάνιση της ειδοποίησης, επικοινώνησε με τους προγραμματιστές. + Σφάλματα στη διαμόρφωση των διακομιστών. + Σφάλμα κατά την έναρξη της συνομιλίας + Σφάλμα κατά τη διακοπή της συνομιλίας + Σφάλμα κατά την εναλλαγή προφίλ + Σφάλμα κατά την αλλαγή προφίλ! + Σφάλμα κατά τo συγχρονισμό της σύνδεσης + Σφάλμα κατά την ενημέρωση της λίστας συνομιλιών + Σφάλμα κατά την ενημέρωση του συνδέσμου ομάδας + Σφάλμα κατά την ενημέρωση της διαμόρφωσης δικτύου + Σφάλμα κατά την αναβάθμιση του διακομιστή + Σφάλμα κατά την ενημέρωση των ρυθμίσεων απορρήτου χρήστη + Σφάλμα κατά το ανέβασμα του αρχείου αρχειοθέτησης + Σφάλμα κατά την επαλήθευση της φράσης πρόσβασης: + Ακόμα και όταν είναι απενεργοποιημένη στη συνομιλία. + Η εκτέλεση της λειτουργίας διαρκεί πολύ χρόνο: %1$d δευτερόλεπτα: %2$s + Έξοδος χωρίς αποθήκευση + Επέκτεινε + Επέκταση επιλογής ρόλου + ΠΕΙΡΑΜΑΤΙΚΟ + Πειραματικά χαρακτηριστικά + έληξε + Εξαγωγή της βάσης δεδομένων + Το εξαγόμενο αρχείο δεν υπάρχει + Εξαγωγή θέματος + Αποτυχία φόρτωσης συνομιλίας + Αποτυχία φόρτωσης συνομιλιών + Γρήγορα και χωρίς αναμονή μέχρι να συνδεθεί ο αποστολέας! + Ταχύτερη διαγραφή ομάδων. + Ταχύτερη σύνδεση και πιο αξιόπιστα μηνύματα. + Ταχύτερη αποστολή μηνυμάτων. + Αγαπημένο + Αγαπημένα + Αρχείο + Αρχείο + Σφάλμα αρχείου + Το αρχείο έχει αποκλειστεί από το χειριστή του διακομιστή:\n%1$s. + Το αρχείο δεν βρέθηκε + Το αρχείο δεν βρέθηκε - πιθανότατα το αρχείο διαγράφηκε ή ακυρώθηκε. + Αρχείο: %s + Αρχεία + ΑΡΧΕΙΑ + Αρχεία και πολυμέσα + Απαγορεύονται τα αρχεία και τα πολυμέσα. + Τα αρχεία και τα πολυμέσα, απαγορεύονται σε αυτήν τη συνομιλία. + Δεν επιτρέπονται αρχεία και πολυμέσα + Απαγορεύονται αρχεία και πολυμέσα! + Το αρχείο αποθηκεύτηκε + Σφάλμα διακομιστή αρχείων: %1$s + Αρχεία & πολυμέσα + Κατάσταση αρχείου + Κατάσταση αρχείου: %s + Το αρχείο διαγράφηκε ή ο σύνδεσμος δεν είναι έγκυρος. + Το αρχείο θα διαγραφεί από τους διακομιστές. + Το αρχείο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το αρχείο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Γέμισμα οθόνης + Φίλτραρε τις μη αναγνωσμένες και τις αγαπημένες συνομιλίες. + Ολοκλήρωση της μετεγκατάστασης + Ολοκλήρωσε τη μετεγκατάσταση σε άλλη συσκευή. + Επιτέλους, τα έχουμε! 🚀 + Βρες τις συνομιλίες πιο γρήγορα + Βρες αυτήν την άδεια στις ρυθμίσεις Android και παραχώρησέ την χειροκίνητα. + Το αποτύπωμα στη διεύθυνση του διακομιστή προορισμού δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή προώθησης δεν ταιριάζει με το πιστοποιητικό: %1$s. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό. + Το αποτύπωμα στη διεύθυνση του διακομιστή δεν ταιριάζει με το πιστοποιητικό: %1$s. + Προσαρμογή στην οθόνη + Επιδιόρθωση + Επιδιόρθωση + Επιδιόρθωση σύνδεσης + Νέος ρόλος ομάδας: Συντονιστής + Νέο στο %s + Επιλογές νέων πολυμέσων + Νέος ρόλος μέλους + Νέο μέλος θέλει να ενταχθεί στην ομάδα. + νέο μήνυμα + Νέο μήνυμα + Νέα συσκευή τηλεφώνου + Νέος κωδικός πρόσβασης + Νέα φράση πρόσβασης + Νέος διακομιστής + Κάθε φορά που εκκινείς την εφαρμογή, θα χρησιμοποιούνται νέα διαπιστευτήρια SOCKS. + Νέα διαπιστευτήρια SOCKS θα χρησιμοποιούνται για κάθε διακομιστή. + όχι + Όχι + Όχι + Όχι + Χωρίς κωδικό πρόσβασης εφαρμογής + Χωρίς κλήσεις στο παρασκήνιο + Χωρίς υπηρεσία παρασκηνίου + Χωρίς συνομιλίες + Δεν βρέθηκαν συνομιλίες + Δεν υπάρχουν συνομιλίες στη λίστα %s. + Δεν υπάρχουν συνομιλίες με μέλη + Δεν υπάρχει συνδεδεμένο κινητό + Δεν έχουν επιλεγεί επαφές + Δεν υπάρχουν επαφές για προσθήκη + Δεν υπάρχουν πληροφορίες παράδοσης + χωρίς λεπτομέρειες + Δεν υπάρχει ακόμη άμεση σύνδεση, το μήνυμα προωθείται από το διαχειριστή. + χωρίς κρυπτογράφηση e2e + Καμία φιλτραρισμένη συνομιλία + Καμία φιλτραρισμένη επαφή + Χωρίς ιστορικό + Δεν υπάρχουν πληροφορίες, δοκίμασε να επαναφορτώσεις + Χωρίς διακομιστές πολυμέσων και αρχείων. + Κανένα μήνυμα + Χωρίς διακομιστές μηνυμάτων. + κανένα + Δεν υπάρχει σύνδεση δικτύου + Καμία συνεδρία ιδιωτικής δρομολόγησης + Δεν υπάρχουν ληφθέντα ή απεσταλμένα αρχεία + Δεν έχει επιλεγεί συνομιλία + Δεν υπάρχουν διακομιστές για τη δρομολόγηση ιδιωτικών μηνυμάτων. + Δεν υπάρχουν διακομιστές για τη λήψη αρχείων. + Δεν υπάρχουν διακομιστές για τη λήψη μηνυμάτων. + Δεν υπάρχουν διακομιστές για την αποστολή αρχείων. + χωρίς συνδρομή + Μη συμβατό! + Σημειώσεις + χωρίς κείμενο + Δεν έχει επιλεγεί τίποτα + Δεν υπάρχει τίποτα να προωθήσεις! + Προεπισκόπηση ειδοποίησης + Ειδοποιήσεις + Ειδοποιήσεις και μπαταρία + Υπηρεσία ειδοποιήσεων + Οι ειδοποιήσεις θα παραδίδονται μόνο μέχρι να σταματήσει η εφαρμογή! + Οι ειδοποιήσεις θα σταματήσουν να λειτουργούν μέχρι να επανεκκινήσεις την εφαρμογή. + μη συγχρονισμένο + Δεν υπάρχουν μη αναγνωσμένες συνομιλίες + Χωρίς αναγνωριστικά χρήστη. + Τώρα οι διαχειριστές μπορούν:\n- να διαγράφουν τα μηνύματα των μελών.\n- να απενεργοποιούν μέλη (ρόλος παρατηρητή) + παρατηρητής + κλειστό` + κλειστό + κλειστό + Κλειστό + Κλειστή + προσφέρεται %s + προσφέρθηκε %s: %2s + ΟΚ + Παλιό αρχείο βάσης δεδομένων + ανοιχτό + Σύνδεσμος πρόσκλησης 1-χρήσης + Σύνδεσμος πρόσκλησης 1-χρήσης + Για τη σύνδεση θα απαιτηθούν διακομιστές Onion.\nΣημείωση: δεν θα μπορείς να συνδεθείς στους διακομιστές χωρίς διεύθυνση .onion. + Οι κεντρικοί υπολογιστές Onion θα χρησιμοποιούνται όταν είναι διαθέσιμοι. + Οι κεντρικοί υπολογιστές Onion δεν θα χρησιμοποιηθούν. + Μπορούν να σταλούν μόνο 10 εικόνες ταυτόχρονα + Μπορούν να σταλούν μόνο 10 βίντεο ταυτόχρονα + Μόνο οι ιδιοκτήτες του chat μπορούν να αλλάξουν τις προτιμήσεις. + Μόνο οι συσκευές αποθηκεύουν προφίλ χρηστών, επαφές, ομάδες και μηνύματα. + Διαγραφή μόνο της συνομιλίας + Μόνο οι ιδιοκτήτες ομάδων μπορούν να αλλάξουν τις προτιμήσεις της ομάδας. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν αρχεία και πολυμέσα. + Μόνο οι ιδιοκτήτες ομάδων μπορούν να ενεργοποιήσουν τα φωνητικά μηνύματα. + Μόνο μία συσκευή μπορεί να λειτουργεί ταυτόχρονα + Μόνο ο αποστολέας και οι διαχειριστές μπορούν να το δουν + (αποθηκεύεται μόνο από τα μέλη της ομάδας) + Μόνο εσύ και οι διαχειριστές το βλέπετε + Μόνο εσύ μπορείς να προσθέσεις αντιδράσεις σε μηνύματα. + Μόνο εσύ μπορείς να διαγράψεις οριστικά τα μηνύματα (η επαφή σου μπορεί να τα επισημάνει για διαγραφή). (24 ώρες) + Μόνο εσύ μπορείς να πραγματοποιήσεις κλήσεις. + Μόνο εσύ μπορείς να στέλνεις μηνύματα που εξαφανίζονται. + Μόνο εσύ μπορείς να στέλνεις αρχεία και πολυμέσα. + Μόνο εσύ μπορείς να στέλνεις φωνητικά μηνύματα. + Μόνο η επαφή σου μπορεί να προσθέσει αντιδράσεις σε μηνύματα. + Μόνο η επαφή σου μπορεί να διαγράψει οριστικά τα μηνύματα (μπορείς να τα επισημάνεις για διαγραφή). (24 ώρες) + Μόνο η επαφή σου μπορεί να πραγματοποιεί κλήσεις. + Μόνο η επαφή σου μπορεί να στείλει μηνύματα που εξαφανίζονται. + Μόνο η επαφή σου μπορεί να στείλει αρχεία και πολυμέσα. + Μόνο η επαφή σου μπορεί να στείλει φωνητικά μηνύματα. + άνοιγμα + Άνοιξε + Άνοιγμα + Άνοιξε τις ρυθμίσεις της εφαρμογής + Ανοιχτές αλλαγές + Άνοιγμα συνομιλίας + Άνοιγμα συνομιλίας + Άνοιγμα κονσόλας συνομιλίας + - Άνοιγμα συνομιλίας στο πρώτο μη αναγνωσμένο μήνυμα.\n- Μετάβαση στα αναφερόμενα μηνύματα. + Άνοιγμα καθαρού συνδέσμου + Ανοιχτές προϋποθέσεις + Άνοιγμα φακέλου βάσης δεδομένων + Άνοιγμα θέσης αρχείου + Άνοιγμα πλήρους συνδέσμου + Άνοιγμα ομάδας + Το άνοιγμα του συνδέσμου στον περιηγητή μπορεί να μειώσει την ιδιωτικότητα και την ασφάλεια της σύνδεσης. Οι μη αξιόπιστοι σύνδεσμοι SimpleX θα εμφανίζονται με κόκκινο χρώμα. + Άνοιγμα συνδέσμου + Άνοιγμα συνδέσμων από τη λίστα συνομιλιών + Άνοιξε την οθόνη μετεγκατάστασης + Άνοιξε νέα συνομιλία + Άνοιξε νέα ομάδα + Άνοιγμα θύρας στο τείχος προστασίας + Άνοιξε τις Ρυθμίσεις Safari / Ιστοσελίδες / Μικρόφωνο και στη συνέχεια επέλεξε Να επιτρέπεται για το localhost. + Άνοιγμα ρυθμίσεων διακομιστή + Άνοιγμα ρυθμίσεων + Άνοιξε το SimpleX Chat για να αποδεχθείς την κλήση + Άνοιξε για να αποδεχθείς + Άνοιξε για να συνδεθείς + Άνοιξε για να συμμετάσχεις + Άνοιξε για να χρησιμοποιήσεις το μποτ + Άνοιγμα συνδέσμου ιστού; + Άνοιγμα με %s + Χειριστής + Διακομιστής χειριστή + - προαιρετική ειδοποίηση για διεγραμμένες επαφές.\n- ονόματα προφίλ με κενά.\n- και πολλά άλλα! + Οργάνωσε τις συνομιλίες σε λίστες + Ή εισαγωγή αρχείου αρχειοθέτησης + Ή επικόλλησε το σύνδεσμο του αρχείου αρχειοθέτησης + Ή σάρωσε τον κωδικό QR + Ή μοιράσου με ασφάλεια αυτόν τον σύνδεσμο αρχείου + Ή δείξε αυτόν τον κωδικό + Ή για να μοιραστείς ιδιωτικά + άλλο + Άλλο + άλλα σφάλματα + Άλλοι διακομιστές SMP + Άλλοι διακομιστές XFTP + ιδιοκτήτης + ιδιοκτήτες + Κωδικός πρόσβασης + Ο κωδικός πρόσβασης αλλάχθηκε! + Εισαγωγή κωδικού πρόσβασης + Ο κωδικός πρόσβασης δεν έχει αλλάξει! + Ο κωδικός πρόσβασης έχει οριστεί! + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Η φράση πρόσβασης στο Keystore δεν μπορεί να διαβαστεί. Αυτό μπορεί να συνέβη μετά από ενημέρωση του συστήματος που δεν είναι συμβατή με την εφαρμογή. Εάν δεν είναι αυτή η περίπτωση, επικοινώνησε με τους προγραμματιστές. + Απαιτείται φράση πρόσβασης + Ο φράση πρόσβασης δεν βρέθηκε στο Keystore, παρακαλώ εισήγαγέ τη χειροκίνητα. Αυτό μπορεί να συνέβη αν επανέφερες τα δεδομένα της εφαρμογής χρησιμοποιώντας ένα εργαλείο δημιουργίας αντιγράφων ασφαλείας. Αν δεν είναι αυτή η περίπτωση, παρακαλώ επικοινώνησε με τους προγραμματιστές. + Κωδικός + Κωδικός για εμφάνιση + Επικόλληση + Επικόλληση συνδέσμου αρχείου αρχειοθέτησης + Επικόλληση διεύθυνσης υπολογιστή + Επικόλληση συνδέσμου + Επικόλλησε το σύνδεσμο για να συνδεθείς! + Επικόλλησε το σύνδεσμο που έλαβες + Επικόλλησε το σύνδεσμο που έλαβες για να συνδεθείς με την επαφή σου… + από άκρη-σε-άκρη + εκκρεμής + Εκκρεμής + Εκκρεμής + σε αναμονή έγκρισης + Εκκρεμής κλήση + σε αναμονή για έλεγχο + Περιοδικά + Περιοδικές ειδοποιήσεις + Οι περιοδικές ειδοποιήσεις είναι απενεργοποιημένες! + Η άδεια απορρίφθηκε! + Διεπαφή στα Περσικά + Κλήσεις σε λειτουργία εικόνα-μέσα-στην-εικόνα + Μέτρηση PING + εσωτερικό PING + Αναπαραγωγή από τη λίστα συνομιλιών. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τις κλήσεις. + Παρακαλώ ζήτησε από την επαφή σου να ενεργοποιήσει τα φωνητικά μηνύματα. + Έλεγξε ότι το κινητό και ο υπολογιστής είναι συνδεδεμένοι στο ίδιο τοπικό δίκτυο και ότι το τείχος προστασίας του υπολογιστή επιτρέπει τη σύνδεση.\nΕνημέρωσε τους προγραμματιστές για τυχόν άλλα προβλήματα. + Έλεγξε ότι ο σύνδεσμος SimpleX είναι σωστός. + Έλεγξε ότι χρησιμοποιείς το σωστό σύνδεσμο ή ζήτησε από την επαφή σου να σου στείλει έναν άλλο. + Έλεγξε τη σύνδεσή σου στο δίκτυο με %1$s και δοκίμασε ξανά. + Επιβεβαίωσε ότι οι ρυθμίσεις δικτύου είναι σωστές για αυτήν τη συσκευή. + Παρακαλώ επικοινώνησε με το διαχειριστή της ομάδας. + Εισήγαγε τη σωστή τρέχουσα φράση πρόσβασης. + Εισήγαγε τον προηγούμενο κωδικό μετά την επαναφορά του αντιγράφου ασφαλείας της βάσης δεδομένων. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. + Μείωσε το μέγεθος του μηνύματος και απέστειλέ το ξανά. + Μείωσε το μέγεθος του μηνύματος ή αφαίρεσε τα αρχεία πολυμέσων και απέστειλέ το ξανά. + Παρακαλώ θυμήσου ή αποθήκευσε το με ασφάλεια - δεν υπάρχει τρόπος να ανακτήσεις έναν χαμένο κωδικό! + Παρακαλώ ανάφερέ το στους προγραμματιστές. + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s + Παρακαλώ ανάφερέ το στους προγραμματιστές: \n%s\n\nΠροτείνεται η επανεκκίνηση της εφαρμογής. + Παρακαλώ επανεκκίνησε την εφαρμογή. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να έχεις πρόσβαση στη συνομιλία αν τη χάσεις. + Αποθήκευσε τη φράση πρόσβασης σε ασφαλές μέρος, καθώς ΔΕΝ θα μπορείς να την αλλάξεις σε περίπτωση απώλειας. + Παρακαλώ δοκίμασε αργότερα. + Ενημέρωσε την εφαρμογή και επικοινώνησε με τους προγραμματιστές. + Παρακαλώ περίμενε μέχρι οι διαχειριστές της ομάδας να εξετάσουν το αίτημά σου για συμμετοχή στην ομάδα. + Παρακαλώ, περίμενε ενώ το αρχείο φορτώνεται από το συνδεδεμένο κινητό + Διεπαφή στα Πολωνικά + Μετέφερε + θύρα%d + Προετοιμασία λήψης + Προετοιμασία μεταφόρτωσης + Διατήρηση του τελευταίου πρόχειρου μηνύματος, με τα συνημμένα. + Προκαθορισμένος διακομιστής + Διεύθυνση προκαθορισμένου διακομιστή + Προκαθορισμένοι διακομιστές + Προκαθορισμένοι διακομιστές + Προεπισκόπηση + Προηγούμενοι συνδεδεμένοι διακομιστές + Προστασία της ιδιωτικότητας των πελατών σου. + Πολιτική απορρήτου και όροι χρήσης. + Επαναπροσδιορισμός της ιδιωτικότητας + Απόρρητο & ασφάλεια + Οι ιδιωτικές συνομιλίες, οι ομάδες και οι επαφές σου δεν είναι προσβάσιμες στους χειριστές του διακομιστή. + Ιδιωτικά ονόματα αρχείων + Ιδιωτικά ονόματα αρχείων πολυμέσων. + Δρομολόγηση ιδιωτικών μηνυμάτων 🚀 + ΔΡΟΜΟΛΟΓΗΣΗ ΙΔΙΩΤΙΚΩΝ ΜΗΝΥΜΑΤΩΝ + Ιδιωτικές σημειώσεις + Ιδιωτικές σημειώσεις + Ιδιωτικές ειδοποιήσεις + Ιδιωτική δρομολόγηση + Σφάλμα ιδιωτικής δρομολόγησης + Λήξη χρονικού ορίου ιδιωτικής δρομολόγησης + Προφίλ και συνδέσεις διακομιστή + εικόνα προφίλ + θέση για εικόνα προφίλ + Εικόνες προφίλ + Όνομα προφίλ: + Κωδικός προφίλ + Θέμα προφίλ + Η ενημέρωση του προφίλ θα σταλεί στις επαφές σου. + Απαγόρευση κλήσεων ήχου/βίντεο. + Απαγόρευση της μη αναστρέψιμης διαγραφής μηνυμάτων. + Απαγόρευση αντιδράσεων σε μήνυμα. + Απαγόρευση αντιδράσεων σε μηνύματα. + Απαγόρευση αναφοράς μηνυμάτων στους διαχειριστές. + Απαγόρευση αποστολής άμεσων μηνυμάτων στα μέλη. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής μηνυμάτων που εξαφανίζονται. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής αρχείων και πολυμέσων. + Απαγόρευση αποστολής συνδέσμων SimpleX + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Απαγόρευση αποστολής φωνητικών μηνυμάτων. + Προστασία οθόνης εφαρμογής + Προστασία διεύθυνσης IP + Προστάτεψε τα προφίλ συνομιλίας σου με έναν κωδικό! + Προστάτεψε τη διεύθυνση IP σου από τα κέντρα διαβίβασης μηνυμάτων που επιλέγουν οι επαφές σου.\nΕνεργοποίησε την επιλογή στις ρυθμίσεις *Δίκτυο και διακομιστές*. + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου + Χρονικό όριο πρωτοκόλλου ανά KB + Μέσω διακομιστή μεσολάβησης + Διακομιστές μέσω proxy + Πιστοποίηση διακομιστή μεσολάβησης + Κωδικός QR + κβαντο-ανθεκτική κρυπτογράφηση e2e + Κβαντο-ανθεκτική κρυπτογράφηση + Τυχαία + Η τυχαία φράση πρόσβασης αποθηκεύεται στις ρυθμίσεις ως απλό κείμενο.\nΜπορείς να την αλλάξεις αργότερα. + Αξιολόγησε την εφαρμογή + Προσβάσιμες γραμμές εργαλείων εφαρμογής + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Προσβάσιμη γραμμή εργαλείων συνομιλίας + Διάβασε περισσότερα + Οι αναφορές παράδοσης είναι απενεργοποιημένες + απάντηση που παραλήφθηκε… + Παραλήφθηκε στις + Παραλήφθηκε στις: %s + επιβεβαίωση που παραλήφθηκε… + Μήνυμα που παραλήφθηκε + Μήνυμα που παραλήφθηκε + Μηνύματα που παραλήφθηκαν + παραλήφθηκε, απαγορεύεται + Παραλήφθηκε απάντηση + Σύνολο που παραλήφθηκε + Σφάλματα παραλαβής + Η διεύθυνση παραλαβής θα αλλάξει σε διαφορετικό διακομιστή. Η αλλαγή διεύθυνσης θα ολοκληρωθεί μετά την σύνδεση του αποστολέα. + Λήψη ταυτόχρονης πρόσβασης + η λήψη αρχείων δεν υποστηρίζεται ακόμη + Η λήψη αρχείων θα διακοπεί. + Λήψη μηνυμάτων… + Λήψη μέσω + Πρόσφατο ιστορικό και βελτιωμένο μποτ καταλόγου. + Ο/Οι παραλήπτης/ες δεν μπορούν να δουν από ποιον προέρχεται αυτό το μήνυμα. + Οι παραλήπτες βλέπουν τις ενημερώσεις καθώς τις πληκτρολογείς. + Επανασύνδεση + Επανασύνδεσε όλους τους συνδεδεμένους διακομιστές για να επιβάλεις την παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Επανασύνδεση όλων των διακομιστών + Επανασύνδεση διακομιστή; + Επανασύνδεση διακομιστών; + Επανασύνδεση διακομιστή για να επιβληθεί η παράδοση μηνυμάτων. Χρησιμοποιεί επιπλέον κίνηση. + Η εγγραφή ενημερώθηκε στις + Η εγγραφή ενημερώθηκε στις: %s + Εγγραφή φωνητικού μηνύματος + Μειωμένη χρήση μπαταρίας + Ανανέωση + Απόρριψη + Απόρριψη + Απόρριψη + Απόρριψη αιτήματος επαφής + απορρίφθηκε + απορρίφθηκε + απορριφθείσα κλήση + Απορριφθείσα κλήση + Απόρριψη μέλους; + Ο διακομιστής αναμετάδοσης χρησιμοποιείται μόνο αν είναι απαραίτητο. Οι άλλοι μπορούν να δουν τη διεύθυνση IP σου. + Ο διακομιστής αναμετάδοσης προστατεύει τη διεύθυνση IP σου, αλλά μπορεί να παρακολουθεί τη διάρκεια της κλήσης. + Υπενθύμιση αργότερα + Απομακρυσμένα κινητά τηλέφωνα + Κατάργηση + Κατάργηση + Κατάργηση και διαγραφή μηνυμάτων + Κατάργηση αρχείου αρχειοθέτησης; + καταργήθηκε + καταργήθηκε %1$s + διεγραμμένη διεύθυνση επαφής + αφαιρέθηκε από την ομάδα + αφαιρέθηκε η φωτογραφία προφίλ + σε αφαίρεσε + Κατάργηση εικόνας + Κατάργηση παρακολούθησης συνδέσμων + Κατάργηση μέλους + Κατάργηση μέλους + Κατάργηση μέλους; + Κατάργηση μελών; + Κατάργηση φράσης πρόσβασης από το Keystore; + Κατάργηση φράσης πρόσβασης από τις ρυθμίσεις; + Κατάργηση μηνυμάτων και μπλοκάρισμα μελών. + Επαναδιαπραγμάτευση + Επαναδιαπραγμάτευση κρυπτογράφησης + Επαναδιαπραγμάτευση κρυπτογράφησης; + Επανάληψη στην οθόνη + Επανάληψη αιτήματος σύνδεσης; + Επανάληψη λήψης + Επανάληψη εισαγωγής + Επανάληψη αιτήματος συμμετοχής; + Επανάληψη μεταφόρτωσης + Απάντησε + Ανέφερε + Αναφορά περιεχομένου: μόνο οι διαχειριστές της ομάδας θα το δουν. + Η αναφορά μηνυμάτων απαγορεύεται σε αυτήν την ομάδα. + Αναφορά προφίλ μέλους: μόνο οι διαχειριστές της ομάδας θα το δουν. + Άλλη αναφορά: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αιτία αναφοράς; + Αναφορά: %s + Αναφορές + Η αναφορά εστάλη στους διαχειριστές + Επανεκκίνηση + Αναφορά spam: μόνο οι διαχειριστές της ομάδας θα το δουν. + Αναφορά παραβίασης κανόνων: μόνο οι διαχειριστές της ομάδας θα τη δουν. + αιτήσου σύνδεση από την ομάδα %1$s + αιτήσου να συνδεθείς + το αίτημα αποστέλλεται + το αίτημα συμμετοχής απορρίφθηκε + Απαιτείται + Επανέφερε + Επαναφορά + Επαναφορά όλων των υποδείξεων + Επαναφορά όλων των στατιστικών + Επαναφορά όλων των στατιστικών; + Επαναφορά χρώματος + Επαναφορά χρωμάτων + Επαναφορά στο θέμα της εφαρμογής + Επαναφορά στις προεπιλογές + Επαναφορά στο θέμα χρήστη + Επανεκκίνηση συνομιλίας + Επανεκκίνησε την εφαρμογή για να δημιουργήσεις ένα νέο προφίλ συνομιλίας. + Επανεκκίνησε την εφαρμογή για να χρησιμοποιήσεις την εισαγώμενη βάση δεδομένων. + Επαναφορά + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων + Επαναφορά αντιγράφου ασφαλείας βάσης δεδομένων; + Σφάλμα επαναφοράς βάσης δεδομένων + Επανέλαβε + Αποκάλυψε + ανασκόπηση + Προϋποθέσεις ελέγχου + ελέγχθηκε από τους διαχειριστές + Έλεγχος μελών ομάδας + Έλεγχος αργότερα + Έλεγχος μελών + Έλεγχος μελών πριν την αποδοχή τους (knocking). + Ανάκληση + Ανάκληση αρχείου + Ανάκληση αρχείου; + Ρόλος + ΕΚΚΙΝΗΣΗ ΣΥΝΟΜΙΛΙΑΣ + Εκτελείται όταν η εφαρμογή είναι ανοιχτή + Ασφαλής λήψη αρχείων + Ασφαλέστερες ομάδες + %s και %s + %s και %s συνδέθηκαν + %s στις %s + Αποθήκευσε + Αποθήκευση + Αποθήκευση + Αποθήκευση ρυθμίσεων εισόδου; + Αποθήκευση και ειδοποίηση επαφής + Αποθήκευση και ειδοποίηση επαφών + Αποθήκευση και ειδοποίηση μελών ομάδας + Αποθήκευση και επανασύνδεση + Αποθήκευση και ενημέρωση προφίλ ομάδας + αποθηκευμένο + Αποθηκευμένο + Αποθηκευμένο από + αποθηκευμένο από %s + Αποθηκευμένο μήνυμα + Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. + Αποθήκευση προφίλ ομάδας + Αποθήκευση λίστας + Αποθήκευση φράσης πρόσβασης και άνοιγμα συνομιλίας + Αποθήκευση φράσης πρόσβασης στο Keystore + Αποθήκευση φράσης πρόσβασης στις ρυθμίσεις + Αποθήκευση προτιμήσεων; + Αποθήκευση κωδικού προφίλ + Αποθήκευση διακομιστών + Αποθήκευση διακομιστών; + Αποθήκευση ρυθμίσεων; + Αποθήκευση ρυθμίσεων διεύθυνσης SimpleX + Αποθήκευση μηνύματος καλωσορίσματος; + Αποθήκευση %1$s μηνυμάτων + Κλιμάκωση στην οθόνη + Σάρωση κωδικού + Σάρωση από κινητό + (σάρωσε ή επικόλλησε από το πρόχειρο) + Σάρωση / Επικόλληση συνδέσμου + Σάρωσε τον κωδικό QR από τον υπολογιστή + %s συνδέθηκε + %s (τρέχον) + %s κατέβηκαν + αναζήτηση + Η γραμμή αναζήτησης δέχεται συνδέσμους πρόσκλησης. + Αναζήτηση ή επικόλληση συνδέσμου SimpleX + Δευτερεύων + Ασφαλής + ο κωδικός ασφαλείας άλλαξε + Επιλογή + Επέλεξε + Επέλεξε προφίλ συνομιλίας + Επέλεξε επαφές + Οι επιλεγμένες προτιμήσεις συνομιλίας απαγορεύουν αυτό το μήνυμα. + Επιλέχθηκαν %d + Επέλεξε τους χειριστές δικτύου που θέλεις να χρησιμοποιήσεις. + Αυτοκαταστροφή + Κωδικός αυτοκαταστροφής + Κωδικός αυτοκαταστροφής + Ο κωδικός αυτοκαταστροφής άλλαξε! + Ο κωδικός αυτοκαταστροφής ενεργοποιήθηκε! + Απέστειλε + Απέστειλε + Στείλε ένα ζωντανό μήνυμα - θα ενημερώνεται για τον παραλήπτη ή τους παραλήπτες καθώς το πληκτρολογείς. + Αποστολή αιτήματος επαφής; + ΑΠΟΣΤΟΛΗ ΑΝΑΦΟΡΩΝ ΠΑΡΑΔΟΣΗΣ ΣΕ + Αποστολή άμεσου μηνύματος + Στείλε άμεσο μήνυμα για να συνδεθείς + Αποστολή μηνύματος που εξαφανίζεται + Ο αποστολέας ακύρωσε τη μεταφορά αρχείων. + Ο αποστολέας ενδέχεται να έχει διαγράψει το αίτημα σύνδεσης. + Σφάλματα αποστολής + αποτυχία αποστολής + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές. + Η αποστολή αναφορών παράδοσης θα είναι ενεργοποιημένη για όλες τις επαφές σε όλα τα ορατά προφίλ συνομιλίας. + η αποστολή αρχείων δεν υποστηρίζεται ακόμη + Η αποστολή του αρχείου θα διακοπεί. + Η αποστολή αναφορών είναι απενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι απενεργοποιημένη για %d ομάδες + Η αποστολή αναφορών είναι ενεργοποιημένη για %d επαφές + Η αποστολή αναφορών είναι ενεργοποιημένη για %d ομάδες + Αποστέλλεται μέσω + Αποστολή προεπισκόπησης συνδέσμων + Αποστολή ζωντανού μηνύματος + Αποστολή Μηνύματος + Στείλε μηνύματα απευθείας όταν η διεύθυνση IP είναι προστατευμένη και ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μηνύματα απευθείας όταν ο διακομιστής σου ή ο διακομιστής προορισμού δεν υποστηρίζει ιδιωτική δρομολόγηση. + Στείλε μήνυμα για να ενεργοποιήσεις τις κλήσεις. + Αποστολή ιδιωτικών αναφορών + Στείλε ερωτήσεις και ιδέες + Αποστολή αναφορών + Αποστολή αιτήματος + Αποστολή αιτήματος χωρίς μήνυμα + αποστολή για σύνδεση + Αποστολή εώς και 100 τελευταίων μηνυμάτων σε νέα μέλη. + Στείλε μας ένα mail + Στείλε τα προσωπικά σου σχόλια στις ομάδες. + στάλθηκε + Στάλθηκε στις + Στάλθηκε στις: %s + Στάλθηκε απευθείας + Απεσταλμένο μήνυμα + Απεσταλμένο μήνυμα + Απεσταλμένα μηνύματα + Τα αποσταλμένα μηνύματα θα διαγραφούν μετά από καθορισμένο χρονικό διάστημα. + Απεσταλμένη απάντηση + Σύνολο απεσταλμένων + Αποστέλλεται στην επαφή σου μετά τη σύνδεση. + Αποστολή μέσω διακομιστή μεσολάβησης + Διακομιστής + Ο διακομιστής προστέθηκε στο χειριστή %s. + Διεύθυνση διακομιστή + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η διεύθυνση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου: %1$s. + Ο χειριστής του διακομιστή άλλαξε. + Χειριστές διακομιστή + Αλλαγή πρωτοκόλλου διακομιστή. + πληροφορίες ουράς διακομιστή: %1$s\n\nτελευταίο ληφθέν μήνυμα: %2$s + Ο διακομιστής απαιτεί εξουσιοδότηση για τη δημιουργία ουρών, έλεγξε τον κωδικό. + Ο διακομιστής απαιτεί εξουσιοδότηση για ανέβασμα αρχείων, έλεγξε τον κωδικό. + ΔΙΑΚΟΜΙΣΤΕΣ + Πληροφορίες διακομιστών + Θα γίνει επαναφορά στα στατιστικά στοιχεία των διακομιστών - αυτή η ενέργεια δεν μπορεί να αναιρεθεί! + Η δοκιμή του διακομιστή απέτυχε! + Η έκδοση του διακομιστή δεν είναι συμβατή με τις ρυθμίσεις δικτύου. + Η έκδοση του διακομιστή δεν είναι συμβατή με την εφαρμογή σου: %1$s. + Κωδικός συνεδρίας + Όρισε σε 1 ημέρα + Όρισε το όνομα συνομιλίας… + Όρισε το όνομα επαφής + Όρισε το όνομα επαφής… + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Όρισε το προεπιλεγμένο θέμα + Όρισε τις προτιμήσεις ομάδας + Όρισέ τον αντί για την πιστοποίηση συστήματος. + Όρισε την εισαγωγή μέλους + Όρισε τη λήξη των μηνυμάτων στις συνομιλίες. + ορίστε νέα διεύθυνση επαφής + όρισε νέα εικόνα προφίλ + Όρισε κωδικό πρόσβασης + Όρισε φράση πρόσβασης + Όρισε φράση πρόσβασης για εξαγωγή + Όρισε το βιογραφικό του προφίλ και το μήνυμα καλωσορίσματος. + Όρισε το εμφανιζόμενο μήνυμα για τα νέα μέλη! + Ρυθμίσεις + Ρυθμίσεις + ΡΥΘΜΙΣΕΙΣ + Όρισε τη φράση πρόσβασης της βάσης δεδομένων + Διαμόρφωση εικόνων προφίλ + Διαμοίρασε + Διαμοίρασε το σύνδεσμο 1-χρήσης + Διαμοίρασε το σύνδεσμο 1-χρήσης με ένα φίλο + Διαμοιρασμός διεύθυνσης + Δημόσιος διαμοιρασμός διεύθυνσης + Διαμοιρασμός διεύθυνσης με τις επαφές; + Διαμοιρασμός αρχείου… + Διαμοιρασμός συνδέσμου + Διαμοιρασμός πολυμέσων… + Διαμοιρασμός μηνύματος… + Διαμοιρασμός παλιάς διεύθυνσης + Διαμοιρασμός παλιού συνδέσμου + Διαμοιρασμός προφίλ + Διαμοιρασμός διεύθυνσης SimpleX σε εφαρμογές κοινωνικής δικτύωσης. + Διαμοιρασμός αυτού του συνδέσμου 1-χρήσης + Διαμοιρασμός με τις επαφές + Διαμοιρασμός της διεύθυνσής σου + Σύντομη περιγραφή: + Σύντομος σύνδεσμος + Σύντομη διεύθυνση SimpleX + Εμφάνιση + Εμφάνιση: + Εμφάνιση λίστας μηνυμάτων σε νέο παράθυρο + Εμφάνιση κονσόλας τερματικού σε νέο παράθυρο + Εμφάνιση επαφής και μηνύματος + Εμφάνιση επιλογών για προγραμματιστές + Εμφάνιση πληροφοριών για + Εμφάνιση εσωτερικών σφαλμάτων + Εμφάνιση τελευταίων μηνυμάτων + Εμφάνιση κατάστασης μηνύματος + Εμφάνιση μόνο της επαφής + Εμφάνιση ποσοστού + Εμφάνιση προεπισκόπησης + Εμφάνιση κωδικού QR + Εμφάνιση αργών κλήσεων API + Απενεργοποίηση + Απενεργοποίηση; + SImpleX + SimpleX διεύθυνση + Διεύθυνση SimpleX + Η διεύθυνση SimpleX και οι σύνδεσμοι 1-χρήσης είναι ασφαλές να διαμοιράζονται μέσω οποιασδήποτε εφαρμογής ανταλλαγής μηνυμάτων. + Διεύθυνση SimpleX ή σύνδεσμος 1-χρήσης; + Το SimpleX δεν μπορεί να λειτουργήσει στο παρασκήνιο. Θα λαμβάνεις τις ειδοποιήσεις μόνο όταν η εφαρμογή είναι σε λειτουργία. + Σύνδεσμος καναλιού SimpleX + Η SimpleX Chat και η Flux σύναψαν συμφωνία για την ενσωμάτωση των διακομιστών που λειτουργεί η Flux, στην εφαρμογή. + Κλήσεις SimpleX Chat + Μηνύματα SimpleX Chat + Η ασφάλεια του SimpleX Chat ελέγχθηκε από την Trail of Bits. + Υπηρεσία SimpleX Chat + Διεύθυνση επικοινωνίας SimpleX + Σύνδεσμος ομάδας SimpleX + Σύνδεσμοι SimpleX + Σύνδεσμοι SimpleX + Οι σύνδεσμοι SimpleX απαγορεύονται. + Οι σύνδεσμοι SimpleX δεν επιτρέπονται + SimpleX Lock + SimpleX Lock + Λειτουργία SimpleX Lock + Το SimpleX Lock δεν είναι ενεργοποιημένο! + Το SimpleX Lock είναι ενεργοποιημένο + SimpleX Logo + simplexmq: v%s (%2s) + Πρόσκληση 1-χρήσης SimpleX + Πρωτόκολλα SimpleX που έχουν ελεγχθεί από την Trail of Bits. + Σύνδεσμος αναμεταδότη SimpleX + SimpleX Team + Απλοποιημένη ανώνυμη λειτουργία + %s δεν έχει επαληθευτεί + %s έχει επαληθευτεί + Μέγεθος + Παράλειψη πρόσκλησης μελών + Παραλειπόμενα μηνύματα + Παράλειψη αυτής της έκδοσης + Αργή λειτουργία + Μικρές ομάδες (μέγιστο 20 άτομα) + Διακομιστής SMP + Διακομιστές SMP + Διακομιστής μεσολάβησης SOCKS + ΔΙΑΚΟΜΙΣΤΗΣ ΜΕΣΟΛΑΒΗΣΗΣ SOCKS + Ρυθμίσεις διακομιστή μεσολάβησης SOCKS + Απαλό + Κάποιο/α αρχείο/α δεν εξήχθησαν + Κατά την εισαγωγή προέκυψαν ορισμένα μη κρίσιμα σφάλματα: + Ορισμένοι διακομιστές απέτυχαν στη δοκιμή: + Ήχος σε σίγαση + Spam + Spam + Ηχείο + Απενεργοποίηση ηχείου + Εεργοποίηση ηχείου + Τετράγωνο, κύκλος ή οτιδήποτε μεταξύ τους. + %s: %s + %s, %s και %d μέλη + %s, %s και %d άλλα μέλη συνδεδεμένα + %s, %s και %s συνδεδεμένα + %s δευτερόλεπτο/α + %s διακομιστές + Σταθερή + τυποποιημένη κρυπτογράφηση από άκρη-σε-άκρη + Αστέρι στο GitHub + Εκκίνηση συνομιλίας + Εκκίνηση συνομιλίας; + εκκινεί… + Εκκινεί από %s. + Εκκινεί από %s.\nΌλα τα δεδομένα παραμένουν ιδιωτικά στη συσκευή σου. + Εκκίνηση νέας συνομιλίας + Εκκινεί περιοδικά + Στατιστικά + Διακοπή + Διακοπή + Διακοπή συνομιλίας + Διακοπή συνομιλίας; + Διέκοψε τη συνομιλία για να εξάγεις, να εισάγεις ή να διαγράψεις τη βάση δεδομένων συνομιλιών. Δεν θα μπορείς να λαμβάνεις και να στέλνεις μηνύματα ενώ η συνομιλία έχει διακοπεί. + Διακοπή αρχείου + Διακοπή συνομιλίας + Διακοπή λήψης αρχείου; + Διακοπή αποστολής αρχείου; + Διακοπή διαμοιρασμού + Διακοπή διαμοιρασμού διεύθυνσης; + διαγράμμιση + Έντονο + Υποβολή + Εγγεγραμμένος + Σφάλματα εγγραφής + Η εγγραφή αγνοήθηκε + %s ανεβασμένα + Υποστήριξη bluetooth και άλλων βελτιώσεων. + ΥΠΟΣΤΗΡΙΞΗ SIMPLEX CHAT + Ενάλλαξε + Εναλλαγή ήχου και βίντεο κατά τη διάρκεια της κλήσης. + Αλλαγή προφίλ συνομιλίας για προσκλήσεις 1-χρήσης. + Σύστημα + Σύστημα + Σύστημα + Σύστημα + Αυθεντικοποίηση συστήματος + Λειτουργία συστήματος + Ουρά + Πάτα το κουμπί + Επαλήθευση κωδικού στο κινητό + Επαλήθευση κωδικού με υπολογιστή + Επαλήθευση σύνδεσης + Επαλήθευση συνδέσεων + Επαλήθευση ασφάλειας σύνδεσης + Επαλήθευση φράσης πρόσβασης της βάσης δεδομένων + Επαλήθευση φράσης πρόσβασης + Επαλήθευση κωδικού ασφαλείας + μέσω %1$s + Μέσω περιηγητή + μέσω του συνδέσμου διεύθυνσης επαφής + μέσω συνδέσμου ομάδας + μέσω συνδέσμου 1-χρήσης + μέσω αναμεταδότη + Μέσω ασφαλούς κβαντο-ανθεκτικού πρωτοκόλλου + βίντεο + Βίντεο + Βίντεο + βιντεοκλήση + Βιντεοκλήση + βιντεοκλήση (χωρίς κρυπτογράφηση e2e) + Βίντεο απενεργοποιημένο + Βίντεο ενεργοποιημένο + Βίντεο και αρχεία εώς 1gb + Βίντεο απεστάλη + Το βίντεο θα ληφθεί όταν η επαφή σου ολοκληρώσει τη μεταφόρτωσή του. + Το βίντεο θα ληφθεί όταν η επαφή σου είναι συνδεδεμένη, παρακαλώ περίμενε ή έλεγξε αργότερα! + Δες τους όρους + Προβολή κωδικού ασφαλείας + Προβολή ενημερωμένων συνθηκών + Ορατό ιστορικό + Φωνητικό μήνυμα + Φωνητικό μήνυμα… + Φωνητικό μήνυμα (%1$s) + Φωνητικά μηνύματα + Φωνητικά μηνύματα + Τα φωνητικά μηνύματα απαγορεύονται. + Τα φωνητικά μηνύματα απαγορεύονται σε αυτήν τη συνομιλία. + Τα φωνητικά μηνύματα δεν επιτρέπονται + Τα φωνητικά μηνύματα απαγορεύονται! + - φωνητικά μηνύματα εώς 5 λεπτά.\n- προσαρμοσμένος χρόνος εξαφάνισης.\n- ιστορικό επεξεργασίας. + αναμονή για απάντηση… + αναμονή για επιβεβαίωση… + Αναμονή για τον υπολογιστή… + Αναμονή για το αρχείο + Αναμονή για την εικόνα + Αναμονή για την εικόνα + Αναμονή σύνδεσης κινητού: + Αναμονή για το βίντεο + Αναμονή για το βίντεο + Χρωματική έμφαση ταπετσαρίας + Φόντο ταπετσαρίας + θέλει να συνδεθεί μαζί σου! + Προειδοποίηση: η έναρξη συνομιλίας σε πολλαπλές συσκευές δεν υποστηρίζεται και θα προκαλέσει σφάλματα στην παράδοση των μηνυμάτων. + Προειδοποίηση: ενδέχεται να χάσεις ορισμένα δεδομένα! + Διακομιστές WebRTC ICE + Ιστοσελίδα + Δεν αποθηκεύουμε καμία από τις επαφές ή τα μηνύματά σου (αφού παραδοθούν) στους διακομιστές. + εβδομάδες + Καλωσόρισες! + Καλωσόρισες %1$s! + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Μήνυμα καλωσορίσματος + Το μήνυμα καλωσορίσματος είναι πολύ μεγάλο + Καλωσόρισε τις επαφές σου 👋 + Τι νέο υπάρχει + Όταν η εφαρμογή είναι σε λειτουργία + Όταν είναι διαθέσιμο + Κατά τη σύνδεση κλήσεων ήχου και βίντεο. + Όταν η IP είναι κρυφή + Όταν είναι ενεργοποιημένοι περισσότεροι από ένας χειριστές, κανένας από αυτούς δεν διαθέτει μεταδεδομένα για να μάθει ποιος επικοινωνεί με ποιον. + Όταν κάποιος ζητήσει να συνδεθεί, μπορείς να αποδεχτείς ή να απορρίψεις το αίτημα. + Όταν μοιράζεσε ένα ανώνυμο προφίλ με κάποιον, αυτό το προφίλ θα χρησιμοποιείται για τις ομάδες στις οποίες σε προσκαλούν. + WiFi + Θα ενεργοποιηθεί στις άμεσες συνομιλίες! + Ενσύρματο ethernet + Με κρυπτογραφημένα αρχεία και μέσα. + Με προαιρετικό μήνυμα καλωσορίσματος. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή στους διακομιστές αρχείων. + Χωρίς Tor ή VPN, η διεύθυνση IP σου θα είναι ορατή σε αυτούς τους XFTP αναμεταδότες:\n%1$s. + Με μειωμένη χρήση της μπαταρίας. + Με μειωμένη χρήση της μπαταρίας. + Λανθασμένη φράση πρόσβασης της βάσης δεδομένων + Λανθασμεο κλειδί ή άγνωστη σύνδεση - πιθανότατα αυτή η σύνδεση έχει διαγραφεί. + Λανθασμένο κλειδί ή άγνωστη διεύθυνση τμήματος αρχείου - πιθανότατα το αρχείο έχει διαγραφεί. + Λανθασμένη φράση πρόσβασης! + Διακομιστής XFTP + Διακομιστές XFTP + ναι + Ναι + Ναι + εσύ + ΕΣΥ + εσύ: %1$s + Αποδέχθηκες τη σύνδεση + αποδέχθηκες αυτό το μέλος + Επιτρέπεις + Έχεις ήδη ένα προφίλ συνομιλίας με το ίδιο όνομα εμφάνισης. Παρακαλώ επέλεξε ένα άλλο όνομα. + Είσαι ήδη συνδεδεμένος στο %1$s. + Ήδη συνδέεσαι μέσω αυτού του μοναδικού συνδέσμου! + Έχεις ήδη ενταχθεί στην ομάδα μέσω αυτού του συνδέσμου. + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα + Είσαι προσκεκλημένος στην ομάδα. Αποδέξου την πρόσκληση για να συνδεθείς με τα μέλη της ομάδας. + Δεν είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση (δεν υπάρχει συνδρομή). + Δεν είσαι συνδεδεμένος σε αυτούς τους διακομιστές. Για την παράδοση μηνυμάτων σε αυτούς, χρησιμοποιείται ιδιωτική δρομολόγηση. + είσαι παρατηρητής + είσαι παρατηρητής + μπλόκαρες %s + Μπορείς να το αλλάξεις στις ρυθμίσεις Εμφάνισης. + Μπορείς να διαμορφώσεις τους χειριστές στις ρυθμίσεις Δικτύου & διακομιστών. + Μπορείς να διαμορφώσεις τους διακομιστές μέσω των ρυθμίσεων. + Μπορείς να αντιγράψεις και να μειώσεις το μέγεθος του μηνύματος για να το στείλεις. + Μπορείς να το δημιουργήσεις αργότερα + Μπορείς να το ενεργοποιήσεις αργότερα μέσω των Ρυθμίσεων. + Μπορείς να τις ενεργοποιήσεις αργότερα μέσω των ρυθμίσεων απορρήτου και ασφάλειας της εφαρμογής. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να δοκιμάσεις ξανά. + Μπορείς να αποκρύψεις ή να σιγάσεις ένα προφίλ χρήστη - κράτησέ το πατημένο για να εμφανιστεί το μενού. + Μπορείς να το κάνεις ορατό στις επαφές σου στο SimpleX μέσω των Ρυθμίσεων. + Μπορείς να αναφέρεις εώς και %1$s μέλη ανά μήνυμα! + Μπορείς να στείλεις μηνύματα στην επαφή %1$s από τις αρχειοθετημένες επαφές. + Μπορείς να ορίσεις το όνομα της σύνδεσης για να θυμάσε με ποιον μοιράστηκες το σύνδεσμο. + Μπορείς να μοιραστείς ένα σύνδεσμο ή έναν κωδικό QR - οποιοσδήποτε θα μπορεί να συμμετάσχει στην ομάδα. Δεν θα χάσεις μέλη της ομάδας αν τον διαγράψεις αργότερα. + Μπορείς να μοιραστείς αυτήν τη διεύθυνση με τις επαφές σου για να τους επιτρέψεις να συνδεθούν με την επαφή %s. + Μπορείς να διαμοιραστείς τη διεύθυνσή σου ως σύνδεσμο ή κωδικό QR - οποιοσδήποτε θα μπορεί να συνδεθεί μαζί σου. + Μπορείς να ξεκινήσεις τη συνομιλία μέσω της εφαρμογής Ρυθμίσεις / Βάση δεδομένων ή επανεκκινώντας την εφαρμογή. + Μπορείς ακόμα να δεις τη συνομιλία με την επαφή %1$s, στη λίστα των συνομιλιών. + Δεν μπορείς να στείλεις μηνύματα! + Μπορείς να ενεργοποιήσεις το SimpleX Lock μέσω των Ρυθμίσεων. + Μπορείς να χρησιμοποιήσεις σύνταξη markdown για να μορφοποιήσεις τα μηνύματα: + Μπορείς να δεις ξανά το σύνδεσμο πρόσκλησης στις λεπτομέρειες σύνδεσης. + Μπορείς να δείς τις αναφορές σου στη Συνομιλία με τους διαχειριστές. + άλλαξες διεύθυνση + άλλαξες διεύθυνση για %s + άλλαξες ρόλο για τον εαυτό σου σε %s + άλλαξες το ρόλο του μέλους %s σε %s + Έχεις τον έλεγχο της συνομιλίας σου! + Δεν ήταν δυνατή η επαλήθευση. Παρακαλώ, δοκίμασε ξανά. + Εσύ αποφασίζεις ποιος μπορεί να συνδεθεί. + Έχεις ήδη ζητήσει σύνδεση μέσω αυτής της διεύθυνσης! + Δεν έχεις συνομιλίες + Πρέπει να εισάγεις τη φράση πρόσβασης κάθε φορά που ξεκινά η εφαρμογή - δεν αποθηκεύεται στη συσκευή. + Προσκάλεσες μία επαφή + Εντάχθηκες σε αυτήν την ομάδα + Έχεις ενταχθεί σε αυτή την ομάδα. Σύνδεση με το μέλος που σε προσκάλεσε. + αποχώρησες + αποχώρησες + Μπορείς να μεταφέρεις την εξαγώμενη βάση δεδομένων. + Μπορείς να αποθηκεύσεις το εξαγώμενο αρχείο. + Πρέπει να χρησιμοποιήσεις την πιο πρόσφατη έκδοση της βάσης δεδομένων συνομιλιών σου σε ΜΟΝΟ μία συσκευή, διαφορετικά ενδέχεται να σταματήσεις να λαμβάνεις μηνύματα από ορισμένες επαφές. + Πρέπει να επιτρέψεις στην επαφή σου να σε καλέσει για να μπορείς να την καλέσεις πίσω. + Για να μπορείς να στέλνεις φωνητικά μηνύματα, πρέπει να επιτρέψεις στην επαφή σου να στέλνει φωνητικά μηνύματα. + Η επαγγελματική σου επαφή + Η κλήσεις σου + Η βάση δεδομένων συνομιλιών σου + Η βάση δεδομένων συνομιλιών σου δεν είναι κρυπτογραφημένη - όρισε μία φράση πρόσβασης για να την προστατεύσεις. + Τα προφίλ συνομιλιών σου + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της συνομιλίας. + Το προφίλ συνομιλίας σου θα σταλεί στα μέλη της ομάδας. + Η σύνδεσή σου μεταφέρθηκε στο προφίλ %s, αλλά προέκυψε σφάλμα κατά την εναλλαγή του. + Η επαφή σου + Η επαφή σου πρέπει να είναι συνδεδεμένη στο διαδίκτυο για να ολοκληρωθεί η σύνδεση.\nΜπορείς να ακυρώσεις αυτήν τη σύνδεση και να καταργήσεις την επαφή (και να δοκιμάσεις αργότερα με έναν νέο σύνδεσμο). + Οι επαφές σου + Οι επαφές σου μπορούν να επιτρέψουν την πλήρη διαγραφή μηνυμάτων. + Τα διαπιστευτήριά σου ενδέχεται να αποσταλούν χωρίς κρυπτογράφηση. + Η τρέχουσα βάση δεδομένων συνομιλιών σου θα ΔΙΑΓΡΑΦΕΙ και θα ΑΝΤΙΚΑΤΑΣΤΑΘΕΙ με την εισαγώμενη.\nΑυτή η ενέργεια δεν μπορεί να αναιρεθεί - το προφίλ, οι επαφές, τα μηνύματα και τα αρχεία σου θα χαθούν οριστικά. + Προσπαθείς να προσκαλέσεις μία επαφή με την οποία έχεις μοιραστεί ένα ανώνυμο προφίλ στην ομάδα στην οποία χρησιμοποιείς το κύριο προφίλ σου. + Χρησιμοποιείς ένα ανώνυμο προφίλ για αυτήν την ομάδα - για να αποφύγεις την κοινή χρήση του κύριου προφίλ σου, δεν επιτρέπεται η πρόσκληση επαφών. + Η ομάδα σου + Το προφίλ σου + Το προφίλ σου αποθηκεύεται στη συσκευή σου και κοινοποιείται μόνο στις επαφές σου. Οι διακομιστές της SimpleX δεν μπορούν να δουν το προφίλ σου. + Οι διακομιστές σου + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης + διαμοιράστηκες ένα σύνδεσμο 1-χρήσης ανώνυμα + ξεμπλόκαρες %s + Θα συνδεθείς στην ομάδα όταν η συσκευή του διαχειριστή της ομάδας είναι συνδεδεμένη στο διαδίκτυο. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα συνδεθείς όταν γίνει αποδεκτό το αίτημά σου για σύνδεση. Παρακαλώ περίμενε ή έλεγξε αργότερα! + Θα σου ζητηθεί να πραγματοποιήσεις έλεγχο ταυτότητας όταν ξεκινήσεις ή συνεχίσεις την εφαρμογή μετά από 30 δευτερόλεπτα στο παρασκήνιο. + Θα συνεχίσεις να λαμβάνεις κλήσεις και ειδοποιήσεις από τα προφίλ που έχεις σε σίγαση όταν αυτά θα είναι ενεργά. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν τη συνομιλία. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα λαμβάνεις πλέον μηνύματα από αυτήν την ομάδα. Το ιστορικό συνομιλιών θα διατηρηθεί. + Δεν θα χάσεις τις επαφές σου αν διαγράψεις αργότερα τη διεύθυνσή σου. + Μεγέθυνση + Όλα τα μηνύματα + Αρχεία + ΦΙλτράρισμα + Εικόνες + Σύνδεσμοι + Αναζήτηση αρχείων + Αναζήτηση εικόνων + Αναζήτηση συνδέσμων + Αναζήτηση βίντεο + Αναζήτηση φωνητικών μηνυμάτων + Βίντεο + Φωνητικά μηνύματα + Η σύνδεση απέτυχε + απέτυχε + Αν έχετε συμμετάσχει ή δημιουργήσει κανάλια, θα σταματήσουν να λειτουργούν μόνιμα. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml index b5e756aaad..c233d8eabc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1732,7 +1732,7 @@ Descargar Reenviar Reenviado - Mensaje reenviado… + Reenviando mensaje… Los destinatarios no ven de quién procede este mensaje. Bluetooth Concurrencia en la recepción @@ -1906,7 +1906,7 @@ Las estadísticas de los servidores serán restablecidas. ¡No puede deshacerse! Descargado Servidor SMP - Aún no hay conexión directa, el mensaje es reenviado por el administrador. + Aún no hay conexión directa, los mensajes son reenviados por el administrador. Otros servidores SMP Otros servidores XFTP Escanear / Pegar enlace @@ -2391,7 +2391,7 @@ Error al aceptar el miembro ¿Guardar configuración? Por favor, espera a que tu solicitud sea revisada por los moderadores del grupo. - has aceptado al miembro + has admitido al miembro pendiente de revisión por revisar Chat con administradores @@ -2419,7 +2419,7 @@ ¡No puedes enviar mensajes! Puedes ver tus informes en Chat con administradores has salido - te ha aceptado + te ha admitido Un miembro nuevo desea unirse al grupo. todos Chat con miembros @@ -2537,4 +2537,21 @@ La huella en la dirección del servidor de reenvío no coincide con el certificado: %1$s. Sin suscripciones No estás conectado al servidor usado para recibir mensajes de esta conexión (no suscrito). + Eliminar mensajes del miembro + ¿Eliminar mensajes del miembro? + Eliminar mensajes + Los mensajes del miembro serán eliminados. ¡No puede deshacerse! + Eliminar miembro y sus mensajes + Todos los mensajes + Archivos + Filtro + Imágenes + Enlaces + Buscar archivos + Buscar imágenes + Buscar enlaces + Buscar vídeos + Buscar mensajes de voz + Vídeos + Mensajes de voz diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml index eba31ba788..5483becb91 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -2532,4 +2532,5 @@ اثر انگشت در نشانی سرور مقصد با گواهی مطابقت ندارد: ‎%1$s. اثر انگشت در نشانی سرور انتقال با گواهی مطابقت ندارد: ‎%1$s. اثر انگشت در نشانی سرور با گواهی مطابقت ندارد: ‎%1$s. + پاک کردن پیام کاربر diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml index 12b578edbf..09a843c91c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -21,7 +21,7 @@ A SimpleXről Kiemelőszín fogadott hívás - Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt az opciót. + Hozzáférés a kiszolgálókhoz SOCKS proxyn a következő porton keresztül: %d? A proxyt el kell indítani, mielőtt engedélyezné ezt a beállítást. Elfogadás Elfogadás gombra fent, majd: @@ -39,7 +39,7 @@ Előre beállított kiszolgálók hozzáadása A hívások kezdeményezése le van tiltva. Az összes partneréhez és csoporttaghoz külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.\nMegjegyzés: ha sok kapcsolata van, akkor az akkumulátor-használat és az adatforgalom jelentősen megnövekedhet, és néhány kapcsolódási kísérlet sikertelen lehet.]]> - hivatkozás előnézetének visszavonása + hivatkozáselőnézet visszavonása Az összes csevegési profiljához az alkalmazásban külön TCP-kapcsolat (és SOCKS-hitelesítési adat) lesz használva.]]> Mindkét fél küldhet eltűnő üzeneteket. Az Android Keystore-t a jelmondat biztonságos tárolására használják – lehetővé teszi az értesítési szolgáltatás működését. @@ -48,7 +48,7 @@ Megjegyzés: az üzenet- és fájltovábbító kiszolgálók SOCKS proxyn keresztül kapcsolódnak. A hívások és a hivatkozások előnézetének küldése közvetlen kapcsolatot használ.]]> Alkalmazásadatok biztonsági mentése Az adatbázis előkészítése sikertelen - A partnereivel kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. + Az összes partnerével továbbra is kapcsolatban marad. A profilfrissítés el lesz küldve a partnerei számára. A csevegési profillal (alapértelmezett), vagy a kapcsolattal (BÉTA). Egy új, véletlenszerű profil lesz megosztva. A hangüzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. @@ -59,7 +59,7 @@ Hang- és videóhívások Az alkalmazás titkosítja a helyi fájlokat (a videók kivételével). Hívás fogadása - Az eltűnő üzenetek küldésének engedélyezése a partnerei számára. + Az eltűnő üzenetek küldése engedélyezve van a partnerei számára. Kapcsolódás folyamatban! Nem lehet fogadni a fájlt Hitelesítés elérhetetlen @@ -70,7 +70,7 @@ Mindkét fél véglegesen törölheti az elküldött üzeneteket. (24 óra) Továbbfejlesztett csoportok Az összes üzenet törölve lesz – ez a művelet nem vonható vissza! Az üzenetek CSAK az Ön számára törlődnek. - Hívás befejeződött + A hívás véget ért HÍVÁSOK és további %d esemény Cím @@ -86,7 +86,7 @@ Vissza Kikapcsolható a beállításokban – az értesítések továbbra is meg lesznek jelenítve amíg az alkalmazás fut.]]> Az adminisztrátorok hivatkozásokat hozhatnak létre a csoportokhoz való csatlakozáshoz. - Hívások a zárolási képernyőn: + Hívások a zárolási képernyőn titkosítás elfogadása… Nem lehet meghívni a partnert! hibás az üzenet azonosítója @@ -97,7 +97,7 @@ Hozzáadás egy másik eszközhöz A reakciók hozzáadása az üzenetekhez engedélyezve van. Fájlelőnézet visszavonása - Az összes csoporttag kapcsolatban marad. + Az összes csoporttag továbbra is kapcsolatban marad. Több akkumulátort használ! Az alkalmazás mindig fut a háttérben – az értesítések azonnal megjelennek.]]> Letiltás adminisztrátor @@ -114,11 +114,11 @@ Az alkalmazásjelkód helyettesítve lesz egy önmegsemmisítő jelkóddal. Arab, bolgár, finn, héber, thai és ukrán – köszönet a felhasználóknak és a Weblate-nek. Engedélyezi a hangüzeneteket? - Mindig használjon továbbítókiszolgálót + Mindig legyen használva továbbítókiszolgáló mindig - A hívás már befejeződött! + A hívás már véget ért! Engedélyezés - Az összes partnerével kapcsolatban marad. + Az összes partnerével továbbra is kapcsolatban marad. Élő csevegési üzenet visszavonása Az üzenetek végleges törlése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. (24 óra) Hang- és videóhívások @@ -130,7 +130,7 @@ Megjelenés Az akkumulátor-optimalizálás aktív, ez kikapcsolja a háttérszolgáltatást és az új üzenetek időszakos lekérdezését. Ezt a beállításokban újraengedélyezheti. Letiltja a tagot? - %1$s hívása befejeződött + %1$s hívása véget ért Jó akkumulátoridő. Az alkalmazás 10 percenként ellenőrzi az új üzeneteket. Előfordulhat, hogy hívásokról, vagy a sürgős üzenetekről marad le.]]> szerző Az elküldött üzenetek végleges törlése engedélyezve van a partnerei számára. (24 óra) @@ -165,7 +165,7 @@ A hívások kezdeményezése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Kiszolgáló hozzáadása Hang bekapcsolva - hanghívás (nem e2e titkosított) + hanghívás (végpontok között NEM titkosított) letiltva Módosítja az adatbázis jelmondatát? kapcsolódva @@ -195,11 +195,11 @@ Kapcsolódás partneri kapcsolatot kért kapcsolat %1$d - a partner e2e titkosítással rendelkezik + a partner végpontok közötti titkosítással rendelkezik Csoport létrehozása véletlenszerű profillal. A partner és az összes üzenet törölve lesz – ez a művelet nem vonható vissza! A partnerei törlésre jelölhetnek üzeneteket; Ön majd meg tudja nézni azokat. - Kapcsolódik az egyszer használható meghívóval? + Kapcsolódik az egyszer használható meghívón keresztül? Kapcsolódás egy hivatkozáson vagy QR-kódon keresztül Kapcsolódási hiba (AUTH) Csak név @@ -214,7 +214,7 @@ Kapcsolódik saját magához? Vágólapra másolva Kapcsolódási kérés elküldve! - Kapcsolódás a számítógéphez + Társítás számítógéppel Kapcsolat Helyesbíti a nevet a következőre: %s? Időtúllépés kapcsolódáskor @@ -224,7 +224,7 @@ Kapcsolat Kapcsolat megszakítva kapcsolat létrehozva - a partner nem rendelkezik e2e titkosítással + a partner nem rendelkezik végpontok közötti titkosítással Partner engedélyezi Rejtett név: Társítás számítógéppel @@ -233,7 +233,7 @@ Partnerek Kapcsolódási hiba A partnere még nem kapcsolódott! - - kapcsolódás könyvtár szolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. + - kapcsolódás a könyvtárszolgáltatáshoz (BÉTA)!\n- kézbesítési jelentések (legfeljebb 20 tagig).\n- gyorsabb és stabilabb. Közreműködés kapcsolódás (bemutatkozó meghívó) SimpleX-cím létrehozása @@ -266,25 +266,25 @@ kapcsolódás… Csevegési profil törlése egyéni - kapcsolódási hívás… + hívás kapcsolása… Téma személyre szabása - Jelenleg támogatott legnagyobb fájl méret: %1$s. + Jelenleg támogatott legnagyobb fájlméret: %1$s. Fájl törlése Hamarosan! cím módosítása %s számára… Csevegési adatbázis importálva Üzenetek törlése - Kiürítés + Ürítés Bezárás gomb A csevegés megállt (jelenlegi) Témák személyre szabása és megosztása. Törli a csevegési profilt? Titkos csoport létrehozása - Kapcsolódva a számítógéphez + Társítva a számítógéppel ICE-kiszolgálók beállítása Csoport törlése - Hitelesítés törlése + Ellenőrzés törlése készítő Megerősítés Csak nálam @@ -299,7 +299,7 @@ kapcsolódás… Hívás kapcsolása Törli a fájlokat és a médiatartalmakat? - befejezett + kész CSEVEGÉSI ADATBÁZIS Önmegsemmisítő jelkód módosítása Várólista létrehozása @@ -314,7 +314,7 @@ Egyéni időköz Kapcsolódás inkognitóban CSEVEGÉSEK - Új profil létrehozása a számítógép alkalmazásban. 💻 + Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… Csevegési adatbázis törölve @@ -333,7 +333,7 @@ Titkos csoport létrehozása Elvetés Törli a partnert? - Kiürítés + Ürítés Cím létrehozása, hogy az emberek kapcsolatba léphessenek Önnel. Biztonsági kódok összehasonlítása a partnerekével. Fájl-összehasonlítás @@ -341,9 +341,9 @@ Törli az üzenetet? Törli a függőben lévő kapcsolatot? Adatbázis titkosítva! - Kiüríti a csevegést? + Üríti a csevegés üzeneteit? Adatbázis visszafejlesztése - Üzenetek kiürítése + Csevegés üzeneteinek ürítése Az adatbázis titkosítási jelmondata frissítve lesz. Kapcsolódás automatikusan Adatbázishiba @@ -390,15 +390,15 @@ %2$s %1$d üzenetet moderált Eltűnő üzenet Ne hozzon létre címet - Ne mutasd újra + Ne jelenjen meg újra SimpleX-zár kikapcsolása - e2e titkosított + végpontok között titkosított ESZKÖZ - e2e titkosított videóhívás + végpontok között titkosított videóhívás közvetlen Számítógép %d perc - %d partner kijelölve + %d partner kiválasztva Engedélyezés %dhónap A közvetlen üzenetek küldése a tagok között le van tiltva ebben a csoportban. @@ -419,7 +419,7 @@ Törlés, és a partner értesítése letiltva %d mp - Az összes fájl törlése + Összes fájl törlése Az adatbázis titkosítva lesz. Adatbázis-jelmondat és -exportálás Az adatbázis titkosítva lesz, a jelmondat pedig a Keystore-ban lesz tárolva. @@ -435,7 +435,7 @@ %d csoportesemény %d hónap Csoportprofil szerkesztése - e2e titkosított hanghívás + végpontok között titkosított hanghívás %d mp Decentralizált Dekódolási hiba @@ -443,12 +443,12 @@ Értesítések letiltása Eszközök Látható a helyi hálózaton - Ne engedélyezze + Nem engedélyezem Az eltűnő üzenetek küldése le van tiltva ebben a csevegésben. alapértelmezett (%s) duplikált üzenet Leválasztja a számítógépet? - A számítógép-alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. + A számítógépes alkalmazás verziója (%s) nem kompatibilis ezzel az alkalmazással. Kézbesítés %d fájl, %s összméretben A csevegés megnyitásához adja meg az adatbázis jelmondatát. @@ -495,7 +495,7 @@ A csoportprofil a tagok eszközein tárolódik, nem a kiszolgálókon. Adja meg a jelmondatot… Hiba történt a felhasználói adatvédelem frissítésekor - Titkosít + Titkosítás Csoport nem található! Hiba történt az SMP-kiszolgálók mentésekor Visszafejlesztés és a csevegés megnyitása @@ -566,7 +566,7 @@ Hiba történt az XFTP-kiszolgálók mentésekor A tagok küldhetnek egymásnak közvetlen üzeneteket. Hiba történt a tag eltávolításakor - befejeződött + hívás vége A csoport üdvözlőüzenete Adja meg a csoport nevét: Hiba történt a meghívó elküldésekor @@ -631,7 +631,7 @@ Téves jelkód Azonnali Inkognitócsoportok - Hogyan + Útmutató Összecsukás Kép Továbbfejlesztett adatvédelem és biztonság @@ -695,7 +695,7 @@ moderált A tag el lesz távolítva a csoportból – ez a művelet nem vonható vissza! Győződjön meg arról, hogy a megadott XFTP-kiszolgálók címei megfelelő formátumúak, soronként elkülönítettek, és nincsenek duplikálva. - Nincs partner kijelölve + Nincs partner kiválasztva Nincsenek fogadott vagy küldött fájlok Megnyitás hordozható eszköz-alkalmazásban, majd koppintson a Kapcsolódás gombra az alkalmazásban.]]> Markdown az üzenetekben @@ -711,13 +711,13 @@ Helyi név Hálózat és kiszolgálók Értesítésekben megjelenő információk - Társítsa össze a hordozható eszköz- és számítógépes alkalmazásokat! 🔗 + Társítsa össze a hordozható eszköz- és a számítógépes alkalmazásokat! 🔗 közvetett (%1$s) Hamarosan további fejlesztések érkeznek! A reakciók hozzáadása az üzenetekhez le van tiltva ebben a csevegésben. Helytelen biztonsági kód! Ez akkor fordulhat elő, ha Ön vagy a partnere egy régi adatbázis biztonsági mentését használta. - Új számítógép-alkalmazás! + Új számítógépes alkalmazás! Most már az adminisztrátorok is:\n- törölhetik a tagok üzeneteit.\n- letilthatnak tagokat (megfigyelő szerepkör) meghívta őt: %1$s A reakciók hozzáadása az üzenetekhez le van tiltva. @@ -753,14 +753,14 @@ Az üzenetek végleges törlése le van tiltva. %s nevű hordozható eszköz le lett választva]]> hónap - Üzenetvázlat + Piszkozatok Egy üzenet eltüntetése Végleges üzenettörlés Egyszerre csak 10 videó küldhető el Csak Ön adhat hozzá reakciókat az üzenetekhez. elhagyta a csoportot Az üzenetek végleges törlése le van tiltva ebben a csevegésben. - Max 40 másodperc, azonnal fogadható. + Legfeljebb 40 másodperc, azonnal megérkezik. inkognitó a kapcsolattartási címhivatkozáson keresztül Onion kiszolgálók szükségesek a kapcsolódáshoz.\nMegjegyzés: .onion cím nélkül nem fog tudni kapcsolódni a kiszolgálókhoz. Olasz kezelőfelület @@ -777,7 +777,7 @@ Csak a csoport tulajdonosai engedélyezhetik a fájlok és a médiatartalmak küldését. Fájl betöltése… Nincs hozzáadandó partner - Üzenetvázlat + Piszkozatok függőben lévő kapcsolat Egyszer használható meghívó Értesítések @@ -818,7 +818,7 @@ Menük és figyelmeztetések Tagok meghívása Csatlakozás mint: %s - Nincs csevegés kijelölve + Nincs csevegés kiválasztva Csak helyi profiladatok inkognitó egy egyszer használható meghívón keresztül Moderálva: %s @@ -827,7 +827,7 @@ Beszélgessünk a SimpleX Chatben Moderálva Élő üzenetek - Hitelesítés + Megjelölés ellenőrzöttként Üzenetkézbesítési jelentések! hivatkozás előnézeti képe Elhagyja a csoportot? @@ -838,7 +838,7 @@ Új megjelenítendő név: Új jelmondat… nem fogadott hívás - Átköltöztetés: %s + Átköltöztetések: %s Válaszul erre Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! @@ -851,7 +851,7 @@ dőlt Érvénytelen a fájl elérési útvonala Csatlakozik a csoporthoz? - nincs e2e titkosítás + nincs végpontok közötti titkosítás Új adatbázis-archívum Élő üzenet! Meghívás a csoportba @@ -872,7 +872,7 @@ Időszakos fogadott, tiltott Megismétli a kapcsolódási kérést? - Véglegesen csak Ön törölhet üzeneteket (partnere csak törlésre jelölheti meg őket ). (24 óra) + Csak Ön törölheti véglegesen az üzeneteket (partnere csak törlésre jelölheti meg azokat ). (24 óra) Szerepkör SimpleX kapcsolattartási cím Megállítás @@ -895,17 +895,17 @@ Jelentse a fejlesztőknek. Ön dönti el, hogy kivel beszélget. Az eltűnő üzenetek küldése le van tiltva. - Csak Ön tud hangüzeneteket küldeni. + Csak Ön küldhet hangüzeneteket. Frissítés Videó elküldve - Az adatbázis jelmondatának módosítása + Adatbázis jelmondatának módosítása Alkalmazásbeállítások megnyitása A jelkód nem módosult! Frissítés - Kijelölés - Csak Ön tud hívásokat indítani. + Kiválasztás + Csak Ön kezdeményezhet hívásokat. Biztonságos várólista - Értékelje az alkalmazást + Alkalmazás értékelése Egyszer használható meghívó megosztása Hiba történt az adatbázis visszaállításakor %s és %s @@ -918,7 +918,7 @@ Fogadott üzenet Üdvözlőüzenet %s, %s és további %d tag kapcsolódott - Csak a partnere tud hívást indítani. + Csak a partnere kezdeményezhet hívásokat. TÉMÁK Túl sok videó! Üdvözöljük! @@ -937,10 +937,10 @@ Hangszóró bekapcsolva Importált csevegési adatbázis használatához indítsa újra az alkalmazást. jogosulatlan küldés - Csak a partnere tud hangüzeneteket küldeni. + Csak a partnere küldhet hangüzeneteket. Beállítások A kapcsolódáshoz a partnere beolvashatja a QR-kódot, vagy használhatja az alkalmazásban található hivatkozást. - visszaigazolás fogadása… + visszaigazolás érkezett… Biztonsági kód beolvasása a partnere alkalmazásából. Lépjen kapcsolatba a csoport adminisztrátorával. Videó bekapcsolva @@ -952,7 +952,7 @@ Keresés Újraegyezteti a titkosítást? Az önmegsemmisítő jelkód engedélyezve! - Biztonsági kiértékelés + Biztonsági felmérés Cím Üzenet elküldése Adatbázismentés visszaállítása @@ -1027,13 +1027,13 @@ SIMPLEX CHAT TÁMOGATÁSA SimpleX Chat szolgáltatás Ön megfigyelő - %s hitelesítve + %s ellenőrizve Jelszó a megjelenítéshez Adatvédelem és biztonság Eltávolítás A jelkód beállítva! Elküldött üzenet - Partnerek kijelölése + Partnerek kiválasztása ismeretlen üzenetformátum Kiszolgálók mentése Üdvözlőüzenet @@ -1041,7 +1041,7 @@ A profilfrissítés el lesz küldve a partnerei számára. Egyszerűsített inkognitómód Menti az üdvözlőüzenetet? - Új csevegési fiók létrehozásához indítsa újra az alkalmazást. + Új csevegési profil létrehozásához indítsa újra az alkalmazást. Engedély megtagadva! Függőben lévő hívás Adatbázis megnyitása… @@ -1049,7 +1049,7 @@ Jelmondat szükséges Privát értesítések Ön meghívta egy partnerét - %s nincs hitelesítve + %s nincs ellenőrizve Koppintson ide a kapcsolódáshoz Ennek az eszköznek a neve Jelenlegi profil @@ -1081,7 +1081,7 @@ Újraindítás SMP-kiszolgálók Videó - SimpleX-cím beállításainak mentése + SimpleX-címbeállítások mentése Újraegyeztetés Várakozás a videóra Saját XFTP-kiszolgálók @@ -1119,11 +1119,11 @@ Várakozás a képre Hangüzenetek Eltávolítja a tagot? - Biztonsági kód hitelesítése + Biztonsági kód ellenőrzése eltávolította Önt SimpleX-cím Megjelenítve: - válasz fogadása… + válasz érkezett… Visszaállítja az adatbázismentést? Üzenetek fogadása… %s és %s kapcsolódott @@ -1160,7 +1160,7 @@ Kihagyott üzenetek A hangüzenetek küldése le van tiltva. Partner nevének beállítása - Csak Ön tud eltűnő üzeneteket küldeni. + Csak Ön küldhet eltűnő üzeneteket. Médiatartalom megosztása… Ön: %1$s Beállítások @@ -1170,7 +1170,7 @@ A kapott hivatkozás beillesztése a partnerhez való kapcsolódáshoz… Beolvasás Port nyitása a tűzfalban - indítás… + hívás indítása… Leállítás elküldve SOCKS proxy használata @@ -1217,7 +1217,7 @@ Rendszer-hitelesítés Böngészőn keresztül Védje meg a csevegési profiljait egy jelszóval! - Csak a partnere tud eltűnő üzeneteket küldeni. + Csak a partnere küldhet eltűnő üzeneteket. Saját ICE-kiszolgálók QR-kód beolvasása a számítógépről SimpleX logó @@ -1239,7 +1239,7 @@ SimpleX-zár bekapcsolva elküldés a partnernek Beolvasás hordozható eszközről - Kapcsolatok hitelesítése + Kapcsolatok ellenőrzése Üzenet megosztása… másodperc A SimpleX-zár nincs bekapcsolva! @@ -1248,7 +1248,7 @@ Csevegési adatbázis eltávolította őt: %1$s Sikertelen kiszolgáló teszt! - Kapcsolat hitelesítése + Kapcsolat ellenőrzése Tudjon meg többet A fájl küldője visszavonta az átvitelt. Megállítja a csevegést? @@ -1256,7 +1256,7 @@ Beállítva 1 nap Felfedés Fogadott üzenetbuborék színe - Csak a partnere tudja az üzeneteket véglegesen törölni (Ön csak törlésre jelölheti meg azokat). (24 óra) + Csak a partnere törölheti véglegesen az üzeneteket (Ön csak törlésre jelölheti meg azokat). (24 óra) Az önmegsemmisítő jelkód módosult! SimpleX Chat kiszolgálók használatban. SimpleX Chat kiszolgálók használata? @@ -1273,27 +1273,27 @@ Az üzenetváltás jövője Módosítja a hálózati beállításokat? Várakozás a hordozható eszköz társítására: - Biztonságos kapcsolat hitelesítése + Biztonságos kapcsolat ellenőrzése fájlok küldése egyelőre még nem támogatott Ön módosította a címet %s számára fájlok fogadása egyelőre még nem támogatott Csoportprofil mentése Visszaállítás alapértelmezettre Hacsak a partnere nem törölte a kapcsolatot, vagy ez a hivatkozás már használatban volt egyszer, lehet hogy ez egy hiba – jelentse a problémát.\nA kapcsolódáshoz kérje meg a partnerét, hogy hozzon létre egy másik kapcsolattartási hivatkozást, és ellenőrizze, hogy a hálózati kapcsolat stabil-e. - videóhívás (nem e2e titkosított) + videóhívás (végpontok között NEM titkosított) Használat új kapcsolatokhoz - Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ push-értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. + Az új üzeneteket az alkalmazás időszakosan lekéri – naponta néhány százalékot használ az akkumulátorból. Az alkalmazás nem használ leküldéses értesítéseket – az eszközről származó adatok nem lesznek elküldve a kiszolgálóknak. Számítógép címének beillesztése a kapcsolattartási címhivatkozáson keresztül - a SimpleX a háttérben fut a push értesítések használata helyett.]]> + a SimpleX a háttérben fut a leküldéses értesítések használata helyett.]]> A partnereinek online kell lennie ahhoz, hogy a kapcsolat létrejöjjön.\nVisszavonhatja ezt a kapcsolatot és eltávolíthatja a partnert (ezt később ismét megpróbálhatja egy új hivatkozással). A jelmondat nem található a Keystore-ban, ezért kézzel szükséges megadni. Ez akkor történhetett meg, ha visszaállította az alkalmazás adatait egy biztonsági mentési eszközzel. Ha nem így történt, akkor lépjen kapcsolatba a fejlesztőkkel. - A partnerei továbbra is kapcsolódva maradnak. + A partnereivel továbbra is kapcsolatban marad. A kiszolgálónak hitelesítésre van szüksége a feltöltéshez, ellenőrizze a jelszavát. Az adatbázis nem működik megfelelően. Koppintson ide a további információkért A fájl küldése le fog állni. Kapcsolódási kísérlet ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál. - Nem sikerült hitelesíteni; próbálja meg újra. + Nem sikerült ellenőrizni; próbálja meg újra. Az üzenet az összes tag számára moderáltként lesz megjelölve. Értesítések fogadásához adja meg az adatbázis jelmondatát A teszt a(z) %s lépésnél sikertelen volt. @@ -1329,14 +1329,14 @@ %1$s.]]> Profil felfedése Ez nem egy érvényes kapcsolattartási hivatkozás! - A végpontok közötti titkosítás hitelesítéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. + A végpontok közötti titkosítás ellenőrzéséhez hasonlítsa össze (vagy olvassa be a QR-kódot) a partnere eszközén lévő kóddal. A csevegési adatbázis legfrissebb verzióját CSAK egy eszközön kell használnia, ellenkező esetben előfordulhat, hogy az üzeneteket nem fogja megkapni valamennyi partnerétől. Ez a beállítás csak az Ön jelenlegi csevegési profiljában lévő üzenetekre vonatkozik Ön meghívást kapott a csoportba. Csatlakozzon, hogy kapcsolatba léphessen a csoport tagjaival. Ez a csoport már nem létezik. A csatlakozás már folyamatban van a csoporthoz ezen a hivatkozáson keresztül. Ön meghívást kapott a csoportba - A partnere a jelenleg megengedett maximális méretű (%1$s) fájlnál nagyobbat küldött. + A partnere a jelenleg támogatott legnagyobb (%1$s) fájlméretnél nagyobbat küldött. A partnerei és az üzenetek (kézbesítés után) nem a SimpleX kiszolgálókon vannak tárolva. Üzenetek formázása a szövegbe szúrt speciális karakterekkel: Megnyitás az alkalmazásban gombra.]]> @@ -1348,14 +1348,14 @@ Átvitelelkülönítés Akkor lesz kapcsolódva, ha a kapcsolódási kérését elfogadják, várjon, vagy ellenőrizze később! A hangüzenetek küldése le van tiltva. - Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> + Alkalmazás akkumulátor-használata / Korlátlan módot az alkalmazás beállításaiban.]]> Biztonságos kvantumbiztos protokollon keresztül. - legfeljebb 5 perc hosszúságú hangüzenetek.\n- egyéni időkorlát beállítása az üzenetek eltűnéséhez.\n- előzmények szerkesztése. Társítás számítógéppel menüt a hordozható eszköz alkalmazásban és olvassa be a QR-kódot.]]> %s ekkor: %s Akkor lesz kapcsolódva, amikor a partnerének az eszköze online lesz, várjon, vagy ellenőrizze később! Kéretlen üzenetek elrejtése. - Onion kiszolgálók használata opciót „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> + Onion kiszolgálók használata beállítást „Nemre”, ha a SOCKS proxy nem támogatja őket.]]> Megoszthatja a címét egy hivatkozásként vagy egy QR-kódként – így bárki kapcsolódhat Önhöz. Létrehozás később A profilja az eszközén van tárolva és csak a partnereivel van megosztva. A SimpleX kiszolgálók nem láthatják a profilját. @@ -1366,7 +1366,7 @@ Csoportmeghívó elküldve Frissíti az átvitelelkülönítési módot? Átvitelelkülönítés - Ettől a csoporttól nem fog értesítéseket kapni. A csevegési előzmények megmaradnak. + Nem fog több üzenetet kapni ebből a csoportból, de a csevegés előzményei megmaradnak. A csevegési adatbázis nem titkosított – állítson be egy jelmondatot annak védelméhez. Közvetlen internetkapcsolat használata? Továbbra is kap hívásokat és értesítéseket a némított profiloktól, ha azok aktívak. @@ -1389,10 +1389,10 @@ A beállítások frissítése a kiszolgálókhoz való újra kapcsolódással jár. kapcsolatba akar lépni Önnel! Ön a következőre módosította a saját szerepkörét: „%s” - A csevegési szolgáltatás elindítható a „Beállítások / Adatbázis” menüben vagy az alkalmazás újraindításával. - Kód hitelesítése a hordozható eszközön + A csevegés elindítható az alkalmazás „Beállítások / Adatbázis” menüjében vagy az alkalmazás újraindításával. + Kód ellenőrzése a hordozható eszközön Ön csatlakozott ehhez a csoporthoz. Kapcsolódás a meghívó csoporttaghoz. - a SimpleX Chat fejlesztőivel, ahol bármiről kérdezhet és értesülhet a friss hírekről.]]> + a SimpleX Chat fejlesztőivel, akiktől bármit kérdezhet és értesülhet a friss hírekről.]]> Nem kötelező üdvözlőüzenettel. Ismeretlen adatbázishiba: %s Elrejtheti vagy lenémíthatja a felhasználóprofiljait – koppintson (vagy számítógép-alkalmazásban kattintson) hosszan a profilra a felugró menühöz. @@ -1402,7 +1402,7 @@ %1$s nevű csoporthoz!]]> A hangüzenetek küldése le van tiltva ebben a csevegésben. Ön irányítja csevegését! - Kód hitelesítése a számítógépen + Kód ellenőrzése a számítógépen Az időzóna védelmének érdekében a kép-/hangfájlok UTC-t használnak. A csatlakozási kérése el lesz küldve ennek a csoporttagnak. Ha egy inkognitóprofilt oszt meg valamelyik partnerével, a rendszer ezt az inkognitóprofilt fogja használni azokban a csoportokban, ahová az adott partnere meghívja Önt. @@ -1414,12 +1414,12 @@ A kézbesítési jelentések küldése az összes partnere számára engedélyezve lesz. Protokoll időtúllépése kB-onként Az adatbázis jelmondatának módosítására tett kísérlet nem fejeződött be. - Ez a művelet nem vonható vissza – a kijelöltnél korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek. Ez több percet is igénybe vehet. A profilja csak a partnereivel van megosztva. Néhány kiszolgáló megbukott a teszten: Koppintson ide a csatlakozáshoz Ez a művelet nem vonható vissza – az összes fogadott és küldött fájl a médiatartalmakkal együtt törölve lesznek. Az alacsony felbontású képek viszont megmaradnak. - A kézbesítési jelentések engedélyezve vannak %d partnernél + A kézbesítési jelentések engedélyezve vannak %d partner számára Küldés a következőn keresztül: Köszönet a felhasználóknak a Weblate-en való közreműködésért! A kézbesítési jelentések küldése engedélyezve lesz az összes látható csevegési profilban lévő összes partnere számára. @@ -1443,7 +1443,7 @@ Köszönet a felhasználóknak a Weblate-en való közreműködésért! Jelmondat mentése a beállításokban Ennek a csoportnak több mint %1$d tagja van, a kézbesítési jelentések nem lesznek elküldve. - A második jelölés, amit kihagytunk! ✅ + A második pipa, ami már nagyon hiányzott! ✅ A továbbítókiszolgáló megvédi az IP-címét, de megfigyelheti a hívás időtartamát. Az utolsó üzenet tervezetének megőrzése a mellékletekkel együtt. A mentett WebRTC ICE-kiszolgálók el lesznek távolítva. @@ -1452,10 +1452,10 @@ Profil és kiszolgálókapcsolatok Egy üzenetváltó- és alkalmazásplatform, amely védi az adatait és biztonságát. Koppintson ide a profil aktiválásához. - A kézbesítési jelentések le vannak tiltva %d partnernél - Munkamenet kód + A kézbesítési jelentések le vannak tiltva %d partner számára + Munkamenet kódja Köszönet a felhasználóknak a Weblate-en való közreműködésért! - Kis csoportok (max. 20 tag) + Kis csoportok (legfeljebb 20 tag) Az Ön által elfogadott kapcsolat vissza lesz vonva! Élő üzenet küldése – az üzenet a címzett(ek) számára valós időben frissül, ahogy Ön beírja az üzenetet A KÉZBESÍTÉSI JELENTÉSEKET A KÖVETKEZŐ CÍMRE KELL KÜLDENI @@ -1519,7 +1519,7 @@ Számítógép elfoglalt Számítógép inaktív Csevegés újraindítása - Időtúllépés a számítógéphez való csatlakozáskor + Időtúllépés a számítógéphez való társításkor A számítógép le lett választva A kapcsolat megszakadt A kapcsolat megszakadt @@ -1555,7 +1555,7 @@ Privát jegyzetek Hiba történt a privát jegyzetek törlésekor Hiba történt az üzenet létrehozásakor - Kiüríti a privát jegyzeteket? + Üríti a privát jegyzetek tartalmát? Létrehozva Mentett üzenet Létrehozva: %s @@ -1586,7 +1586,7 @@ Az üdvözlőüzenet túl hosszú Az adatbázis átköltöztetése folyamatban van.\nEz eltarthat néhány percig. Hanghívás - A hívás befejeződött + Hívás vége Videóhívás Hiba történt a böngésző megnyitásakor A hívásokhoz egy alapértelmezett webböngésző szükséges. Állítson be egy alapértelmezett webböngészőt az eszközön, és osszon meg további információkat a SimpleX Chat fejlesztőivel. @@ -1613,14 +1613,14 @@ Hiba történt a beállítások mentésekor Hiba történt az archívum letöltésekor Hiba történt az archívum feltöltésekor - Hiba történt a jelmondat hitelesítésekor: + Hiba történt a jelmondat ellenőrzésekor: Az exportált fájl nem létezik A fájl törölve lett, vagy érvénytelen a hivatkozás %s letöltve Archívum importálása Feltöltés előkészítése - Az adatbázis jelmondatának hitelesítése - Jelmondat hitelesítése + Adatbázis jelmondatának ellenőrzése + Jelmondat ellenőrzése Jelmondat beállítása Kép a képben hívások Biztonságosabb csoportok @@ -1633,10 +1633,10 @@ A folytatáshoz a csevegést meg kell szakítani. Csevegés megállítása folyamatban Vagy ossza meg biztonságosan ezt a fájlhivatkozást - Csevegés indítása + Csevegés elindítása Nem szabad ugyanazt az adatbázist használni egyszerre két eszközön.]]> Az átköltöztetéshez erősítse meg, hogy emlékszik az adatbázis jelmondatára. - Átköltöztetés egy másik eszközről opciót az új eszközén és olvassa be a QR-kódot.]]> + Átköltöztetés egy másik eszközről beállítást az új eszközén és olvassa be a QR-kódot.]]> Átköltöztetés véglegesítése Átköltöztetés véglegesítése egy másik eszközön. Letöltés előkészítése @@ -1654,7 +1654,7 @@ Átköltöztetés egy másik eszközről Kvantumbiztos titkosítás Megpróbálhatja még egyszer. - Átköltöztetés befejezve + Átköltöztetés kész Átköltöztetés egy másik eszközre QR-kód használatával. Átköltöztetés Megjegyzés: ha két eszközön is ugyanazt az adatbázist használja, akkor biztonsági védelemként megszakítja a partnereitől érkező üzenetek visszafejtését.]]> @@ -1739,10 +1739,10 @@ Nem Nem védett Igen - NE használjon privát útválasztást. + NE legyen használva privát útválasztás. Privát útválasztás Privát útválasztás használata az ismeretlen kiszolgálókhoz. - Mindig használjon privát útválasztást. + Mindig legyen használva privát útválasztás. Üzenet-útválasztási mód Közvetlen üzenetküldés, ha az IP-cím védett és a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Közvetlen üzenetküldés, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. @@ -1816,7 +1816,7 @@ Ezt a hivatkozást egy másik hordozható eszközön már használták, hozzon létre egy új hivatkozást a számítógépén. Ellenőrizze, hogy a hordozható eszköz és a számítógép ugyanahhoz a helyi hálózathoz csatlakozik-e, valamint a számítógép tűzfalában engedélyezve van-e a kapcsolat.\nMinden további problémát osszon meg a fejlesztőkkel. Nem lehet üzenetet küldeni - A kijelölt csevegési beállítások tiltják ezt az üzenetet. + A kiválasztott csevegési beállítások tiltják ezt az üzenetet. Próbálja meg később. A kiszolgáló címe nem kompatibilis a hálózati beállításokkal: %1$s. Inaktív tag @@ -1843,7 +1843,7 @@ Újrakapcsolódás az összes kiszolgálóhoz Hiba történt a statisztikák visszaállításakor Visszaállítás - Az összes statisztika visszaállítása + Összes statisztika visszaállítása Visszaállítja az összes statisztikát? A kiszolgálók statisztikái visszaállnak – ez a művelet nem vonható vissza! Részletes statisztikák @@ -1976,12 +1976,12 @@ Engedélyeznie kell a hívásokat a partnere számára, hogy fel tudják hívni egymást. A(z) %1$s nevű partnerével folytatott beszélgetéseit továbbra is megtekintheti a csevegések listájában. Üzenet… - Kijelölés + Kiválasztás Az üzenetek az összes tag számára moderáltként lesznek megjelölve. - Nincs semmi kijelölve + Nincs semmi kiválasztva Az üzenetek törlésre lesznek jelölve. A címzett(ek) képes(ek) lesz(nek) felfedni ezt az üzenetet. Törli a tagok %d üzenetét? - %d kijelölve + %d kiválasztva Az üzenetek az összes tag számára törölve lesznek. Csevegési adatbázis exportálva Kapcsolatok- és kiszolgálók állapotának megjelenítése. @@ -2024,7 +2024,7 @@ CSEVEGÉSI ADATBÁZIS Profil megosztása Rendszerbeállítások használata - Csevegési profil kijelölése + Csevegési profil kiválasztása Ne használja a hitelesítési adatokat proxyval. Különböző proxy-hitelesítési adatok használata az összes profilhoz. Különböző proxy-hitelesítési adatok használata az összes kapcsolathoz. @@ -2048,7 +2048,7 @@ %1$s üzenet nem lett továbbítva Továbbít %1$s üzenetet? Továbbítja az üzeneteket fájlok nélkül? - Az üzeneteket törölték miután kijelölte őket. + Az üzeneteket törölték miután kiválasztotta őket. %1$s üzenet mentése Hiba történt az üzenetek továbbításakor Hang elnémítva @@ -2060,8 +2060,8 @@ Minden alkalommal, amikor elindítja az alkalmazást, új SOCKS-hitelesítési adatok lesznek használva. Alkalmazás munkamenete Az összes kiszolgálóhoz új, SOCKS-hitelesítési adatok lesznek használva. - Kattintson a címmező melletti info gombra a mikrofon használatának engedélyezéséhez. - Nyissa meg a Safari Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése lehetőséget. + Kattintson a címmező melletti információ gombra a mikrofon használatának engedélyezéséhez. + Nyissa meg a Safari / Beállítások / Weboldalak / Mikrofon menüt, majd válassza a helyi kiszolgálók engedélyezése beállítást. Hívások kezdeményezéséhez engedélyezze a mikrofon használatát. Fejezze be a hívást, és próbálja meg a hívást újra. Továbbfejlesztett hívásélmény Továbbfejlesztett üzenetdátumok. @@ -2178,7 +2178,7 @@ Értesítések és akkumulátor Az alkalmazás mindig fut a háttérben Elhagyja a csevegést? - Ön nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. + Nem fog több üzenetet kapni ebből a csevegésből, de a csevegés előzményei megmaradnak. Csevegés törlése Meghívás a csevegésbe Barátok hozzáadása @@ -2236,7 +2236,7 @@ Nincsenek olvasatlan csevegések Lista létrehozása Lista mentése - Az összes csevegés el lesz távolítva a következő listáról, és a lista is törlődik: %s + Az összes csevegés el lesz távolítva a(z) %s nevű listáról, és a lista is törölve lesz Törlés Törli a listát? Szerkesztés @@ -2293,7 +2293,7 @@ alapértelmezett (%s) Csevegési üzenetek törlése az eszközről. Módosítja az automatikus üzenettörlést? - Ez a művelet nem vonható vissza – a kijelölt üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. + Ez a művelet nem vonható vissza – a kiválasztott üzenettől korábban küldött és fogadott üzenetek törölve lesznek a csevegésből. A következő TCP-port használata, amikor nincs port megadva: %1$s. TCP-port az üzenetváltáshoz Webport használata @@ -2301,7 +2301,7 @@ Összes némítása Legfeljebb %1$s tagot említhet meg egy üzenetben! Az üzenetek jelentése a moderátorok felé engedélyezve van. - Az üzenetek a moderátorok felé történő jelentésének megtiltása. + Az üzenetek jelentése a moderátorok felé le van tiltva. Archiválja az összes jelentést? Archivál %d jelentést? Csak magamnak @@ -2393,7 +2393,7 @@ csatlakozási kérés elutasítva Ön elhagyta a csoportot a tag régi verziót használ - Hiba a csevegés törlésekor + Hiba történt a csevegés törlésekor Ön nem tud üzeneteket küldeni! a partner nem áll készen nincs szinkronizálva @@ -2450,9 +2450,9 @@ TCP-kapcsolat időtúllépése a háttérben Profil betöltése… Rövid leírás: - Saját névjegy: - Névjegy: - A névjegy túl hosszú + Saját életrajz: + Életrajz: + Az életrajz túl hosszú A leírás túl hosszú Partneri kapcsolatkérés elfogadása Üzleti kapcsolat @@ -2468,7 +2468,7 @@ Saját cím létrehozása Eltűnő üzenetek engedélyezése alapértelmezetten. Tartsa tisztán a csevegéseit - Névjegy és üdvözlőüzenet beállítása a profilokhoz. + Életrajz és üdvözlőüzenet beállítása a profilokhoz. Saját cím megosztása Rövid SimpleX-cím Cím frissítése @@ -2503,6 +2503,26 @@ A célkiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A továbbítókiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. A kiszolgáló címében szereplő ujjlenyomat nem egyezik a tanúsítvánnyal: %1$s. - nincs előfizetés - Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs előfizetés). + nincs feliratkozás + Ön nem kapcsolódott ahhoz a kiszolgálóhoz, amely az adott partnerétől érkező üzenetek fogadására szolgál (nincs feliratkozás). + Tag üzeneteinek törlése + Törli a tag üzeneteit? + Üzenetek törlése + A tag üzenetei törölve lesznek – ez a művelet nem vonható vissza! + Eltávolítás és az üzeneteinek törlése + Összes üzenet + Fájlok + Szűrő + Képek + Hivatkozások + Fájlok keresése + Képek keresése + Hivatkozások keresése + Videók keresése + Hangüzenetek keresése + Videók + Hangüzenetek + Nem sikerült létrehozni a kapcsolatot + sikertelen + Ha csatornákat hozott létre vagy csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg new file mode 100644 index 0000000000..fc1e09a3cb --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg new file mode 100644 index 0000000000..9f4edcfd98 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_bigtop_updates_padded.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml index 909c6c7cfe..2257d93efa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -2509,4 +2509,9 @@ Sidik jari di alamat server tidak cocok dengan sertifikat: %1$s. tidak berlangganan Anda tidak terhubung ke server yang digunakan untuk menerima pesan dari koneksi ini (tidak berlangganan). + Hapus pesan anggota + Hapus pesan anggota? + Hapus pesan + Pesan anggota akan dihapus - ini tidak dapat dibatalkan! + Hapus pesan diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml index 1c7e39d51e..fe4a658a68 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -2452,7 +2452,7 @@ Entra nel gruppo Apri la chat Apri una chat nuova - Apri un gruppo nuovo + Apri il nuovo gruppo Apri per accettare Apri per connettere Apri per entrare @@ -2541,4 +2541,24 @@ L\'impronta digitale nell\'indirizzo del server non corrisponde al certificato: %1$s. nessuna iscrizione Non sei connesso/a al server usato per ricevere messaggi da questa connessione (nessuna iscrizione). + Elimina i messaggi del membro + Eliminare i messaggi del membro? + Elimina i messaggi + I messaggi del membro verranno eliminati. Non è reversibile! + Rimuovi ed elimina i messaggi + Tutti i messaggi + File + Immagini + Link + Cerca file + Cerca immagini + Cerca link + Cerca video + Cerca messaggi vocali + Video + Messaggi vocali + Filtro + Connessione fallita + fallito + Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml index 6b413c9bfa..fb83b83735 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -2138,4 +2138,17 @@ פתח שיחה חדשה פתח קבוצה חדשה שלח את המשוב הפרטי שלך לקבוצות. + הסכמה לבקשת חבר + הערות + לא נבחר כלום להעברה! + התראות וסוללה + הוסף הודעה + אפשר קבצים ומדיה רק כאשר החבר מאשר אותם + אפשר לאנשי קשר שלך לשלוח קבצים ומדיה + אודות: + האודות ארוך מדי + בוט + אתה והאיש קשר שלך יכולים לשלוח קבצים ומדיה + צ\'אט עסקי + אי אפשר לשנות תמונת פרופיל diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml index cc85e49a8c..4c5b279ba7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -347,7 +347,7 @@ データベースパスフレーズ データベースをエクスポート データベースを削除 - データベースを読み込みますか? + データベースのインポート 新しいデータベースのアーカイブ 過去のデータベースアーカイブ ファイルを全て削除 @@ -1826,7 +1826,7 @@ SMPサーバーの構成 接続中 XFTPサーバーの構成 - チャトリスト切り替え + チャットリスト表示切り替え 連絡先 メッセージサーバ メディア&ファイルサーバ @@ -2042,4 +2042,23 @@ プライベートメッセージルーティング用のサーバーがありません。 メディアおよびファイルサーバーは存在しません。 ファイルを送信するサーバーがありません。 + ソーシャルメディア向け + サーバを利用する + あなたのサーバ + ビデオ + ファイル + 画像 + リンク + すべて + 音声メッセージ + フィルター + メンバーとして承認する + オブザーバーとして承認する + スパム + アーカイブ + 自己紹介 + 自己紹介の文字数が上限を超えています + ぼかし + 連絡先 + お気に入り diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml new file mode 100644 index 0000000000..0ea9328085 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -0,0 +1,837 @@ + + + Profîla niha bişuxulîne + Komê veke + Komeke nû veke + Lînka xelet + Databas tê vekirin… + xeletî + Profîl nikarîbû were çêkirin! + Profîl nikarîbû were guhertin! + Ti serverên medya & dosyayan nînin. + Ji min re + Ji hemû moderatoran re + Xeletî: %1$s + Cewab bide + Kopî bike + Qeyd bike + Biguhere + Melûmat + Lê bigere + Li sûretan bigere + Li vîdyoyan bigere + Li dosyayan bigere + Li lînkan bigere + Sûret + Vîdyo + Dosya + Lînk + Tarîx + Tarîx nîne + Cewab ji bo + Qeydkirî + Hatiye qeydkirin ji + Jê bibe + Veşêre + Bihêle + Gilî bike + Hilbijêre + Mezin bike + Şandina dosyayê bisekinîne? + Şandina dosyayê wê bê sekinandin. + Standina dosyayê bisekinîne? + Bisekinîne + Dosya wê ji serveran bê jêbirin. + Daxe + Lîste + Endam ne aktîv e + guhertî + şandî + şandin bi ser neket + nexwendî + Bi xêr hatî %1$s! + Bi xêr hatî! + Ev nivîs di eyaran de heye + Eyar + Bi navê %s bikeviyê + redkirî + Hemû + Lîste lê zêde bike + 1 gilîkirin + %d gilîkirin + Gilîkirinên endaman + Zêde sûret hene! + Zêde vîdyo hene! + Tenê 10 sûret karin di derbekê de werin şandin + Tenê 10 vîdyo karin di derbekê de werin şandin + Xeletiya dekodkirinê + Sûret nikare were dekodkirin. Bi xêra xwe, sûretekî dî biceribîne yan jî xeberê bide mielifan. + Dosya û medya memnû in! + Tenê xwediyên koman karin dosya û medya aktîv bikin. + Lînkên SimpleXê memnû in + Xwestinê bişîne + xwestin şandî ye + ne sinkronîzekirî ye + Bi xêra xwe xeberê bide admînê komê. + kom jêbirî ye + ji komê derxistî + tu derketî + Sûret + Li hêviya sûret e + Sûret hat şandin + Li hêviya sûret e + Vîdyo + Li hêviya vîdyo ye + Vîdyo hat şandin + Li hêviya vîdyoyê ye + Dosya + Koda emniyetê tesdîq bike + Kamera + Ji Galeriyê + Dosya + Dosyakê hilbijêre + Sûret + Vîdyo + Pêl pişkokê bike + li ser, piştre: + Komekê çêke: ji bo çêkirina komeke nû.]]> + Qebûl bike + Red bike + Heya kesê ko şandiye jê çênabe. + Endam hatiye jêbirin - nikare xwestinê qebûl bike + Jê bibe + Jê bibe + Xwendî nîşan bide + Nexwendî nîşan bide + Bêdeng bike + Hemûka bêdeng bike + Bêdengkirinê betal bike + Bike favorît + Ji favorîtan derxe + Behsên nexwendî + Lîste çêke + Li lîstê zêde bike + Lîstê biguhere + Lîstê qeyd bike + Navê lîstê... + Navê lîstê û emojiya wê divê ji bo her lîsteyî cuda be. + Jê bibe + Lîstê jê bibe? + Biguhere + Rêzê biguhere + sûretê profîlê + Pişkoka girtinê + Eyar + Koda QRyê + Adresa SimpleXê + arîkarî + Taximê SimpleXê + Logoya SimpleXê + E-poste + Bêhtir + Koda QRyê nîşan bide + Lînka 1-carê bi hevalekî re parve bike + Ji bo ko tu xwe ji guhertina lînka biparêzî, tu karî kodên emniyetê yên kontaktê qiyas bikî. + Yan jî vê kodê nîşan bide + Lînka timam + Lînka kurt + Profîlê parve bike + Profîl nikarîbû were guhertin + Yan jî koda QRyê skan bike + Dewetiya neşuxulandî bihêle? + Bihêle + Lînk tê çêkirin… + Dîsa biceribîne + Vê lînka 1-carê parve bike + Lînka ko te standiye bizeliqîne + Nivîsa ko te zeliqand ne lînkeke SimpleXê ye. + Pêl vir bike ji bo zeliqandina lînkê + Profîla te + Profîl nikare were guhertin + Kod skan bike + Koda emniyetê xelet e! + Koda emniyetê + Tesdîqkirî nîşan bide + Tesdîqkirinê jê bibe + %s tesdîqkirî ye + %s ne tesdîqkirî ye + Eyarên te + Adresa te yî SimpleXê + Li ser SimpleX Chat + Çawa tê şuxulandin + Arîkariya Markdownê + Pirsan û fikran bişîne + E-poste ji me re bişîne + Server bi kar bîne + Serverên SimpleX Chat tên şuxulandin. + Çilo + Çilo yek serverên xwe dişuxulîne + Mecbûrî + Neparastî + Ne ti carî + Erê + Wextê ko IP veşartî ye + Na + Girtî + Saxlem + Beta + %s (%s) daxe + Cihê dosyayê veke + Dûvre bîne bîra min + Adresê jê bibe? + Lînkê parve bike + Adresa SimpleXê çêke + Hew adresê parve bike? + Hew parve bike + Qebûlkirina ji ber xwe ve + Eyaran qeyd bike? + Eyarên adresa SimpleXê qeyd bike + Adresê jê bibe + Hevalan dewet bike + Em li SimpleX Chat qise bikin + Ji bo medyaya sosyal + Yan ji bo parvekirina şexsî + Adresa SimpleXê yan jî lînka 1-carê? + Lînka 1-carê çêke + Eyarên adresê + Sûret jê bibe + Tercihan qeyd bike? + Bê qeydkirinê derkeve + Profîlê veşêre + Şîfra profîlê qeyd bike + Li ser SimpleXê + Markdown çawa tê şuxulandin + qalin + xwehr + a + b + bi reng + tê telefonkirin… + Kamera + Kamera û mîkrofon + Van destûran bide ji bo telefonkirinê + Di eyaran de destûrê bide + Vê destûrê di eyarên Androidê de bibîne û bixwe destûrê bide. + Eyaran veke + Bluetooth + Profîla xwe çêke + Çawa dişuxule + SimpleX çawa dişuxule + Çawa tesîrê li pîlê dike + Her serê pêlekê + Di cih de + Notîfîkasyon û pîl + Tu karî serveran ji eyaran eyar bikî. + Dewam bike + Qebûl bike + Red bike + Qebûl bike + Nîşan bide + Eyar nikarîbû were guhertin + Dewam bike + Şîfrê ji eyaran bibe? + Jê bibe + Şîfra niha… + Şîfra nû… + Endaman dewet bike + Çêtir tecrûba karber + Adresa xwe parve bike + saniye + deqe + seet + roj + heftî + heyv + Hilbijêre + Telefonekê girê de + Telefonên girêdayî + Ji telefonê skan bike + Navê vê cihazê + (ev cihaz v%s)]]> + Telefona girêdayî + Girêdayî telefonê + Navê vê cihazê binivîsîne… + Xeletî + Ev cihaz + Cihaz + Cihaza mobîlê yî nû + %s hat qutkirin]]> + Girêdan sekinî + Girêdan sekinî + Hemû statîstîkan vala bike + Serveran qeyd bike? + Skan bike / Lînk bizeliqîne + Koda QRyê skan bike + Ji kompîterê koda QRyê skan bike + Koda QRyê ya serverê skan bike + %s (niha) + %s daxistî + lê bigere + Lê bigere yan jî lînka SimpleXê bizeliqîne + san + Ê diwan + Dora emîn + koda emniyetê hat guhertin + Xeletiyên şandinê + şandina dosyayan hê ne mimkun e + Tê şandin bi riya + Pêşdîtinên lînkan bişîne + Gilîkirinên şexsî bişîne + Wextê şandinê: + Cewaba şandî + Bi riya proksiyê şandî + Server + Adresa serverê + Adres + Adresa serverê li eyarên torê nayê. + SERVER + Melûmata serveran + Ceribandina serverê bi ser neket! + Versiyona serverê li eyarên torê nayê. + 1 roj deyne + Tercihên komê diyar bike + EYAR + Parve bike + Lînka 1-carê parve bike + Adresê parve bike + Adresê bi hişkereyî parve bike + Dosya parve bike… + Medya parve bike… + Adresa kevn parve bike + Lînka kevn parve bike + Adresa SimpleXê li medayaya sosyal parve bike. + Behsa kin: + Adresa SimpleXê yî kin + Nîşan bide: + Pêşdîtinê nîşan bide + Bigire + Bigire? + SimpleX + Adresa SimpleXê + Lînka qenala SimpleXê + Xizmeta SimpleX Chatê + Lînka komê ya SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê + Lînkên SimpleXê memnû in. + simplexmq: v%s (%2s) + Dewetiya yek carê ya SimpleXê + Mezinbûnî + Ser bakirina endaman ve derbas bibe + Funksiyona hêdî + Komên piçûk (herî zêde 20) + Servera SMPyê + Serverên SMPyê + Nerm + Bêdeng + Spam + Spam + Çarçik, girover, yan çi tiştê di neqebê de. + %s: %s + %s saniye + %s server + Li GitHubê stêrkê bide + dest pê dike… + Her serê pêlekê dest pê dike + Statîstîk + Bisekinîne + Dosyayê bisekinîne + xet/xêz/xîşk + Biqewet + Abonekirî + PIŞT BIDE SIMPLEX CHATÊ + Biguhere + Sîstem + Sîstem + Sîstem + Sîstem + Moda sîstemê + Terî/Dûvik + Pêl Adresa SimpleXê çêke di meniwê de ji bo ko tu dûvre çêkî. + Pêl Bikeve komê bike + Bikeviyê + Bikeve komê + Bikeve komê? + Bikeve komê? + Dikeve komê + Bikeve koma xwe? + %1$d dosya hê tê(n) daxistin. + %1$d dosya nikarîbû(n) wer(e/in) daxistin. + %1$d dosya hat(in) jêbirin. + %1$d dosya nehat(in) daxistin. + %1$d xeletiyên dosyayê ên dî. + %1$s ENDAM + 1 roj + 1 deqe + 1 heyv + lînka 1-carê + 1 heftî + 1 sal + 30 saniye + 5 deqe + Betal bike + Guhertina adresê betal bike + Guhertina adresê betal bike? + Li ser adresa SimpleXê + Qebûl bike + Qebûl bike + Qebûl bike + Wek endamekî qebûl bike + Şertan qebûl bike + %1$s hat qebûlkirin + Şertên qebûlkirî + dewetiyê qebûl kir + tu qebûl kirî + Endam qebûl bike + Girêdanên aktîv + Hevalan lê zêde bike + Guhertina adresê wê bê betalkirin. Adresa berê yî standinê wê bê şuxulandin. + Adres yan jî lînka 1-carê? + Serverekê lê zêde bike + Bi riya skankirina kodên QRyê serveran lê zêde bike. + Endamên têxim lê zêde bike + Cihazeke dî lê zêde bike + admîn + admîn + Admîn karin endamekî ji bo her kesî blok bikin. + Admîn karin lînkên lêzêdebûna koman çêkin. + Eyarên torê ên pêşketî + Eyarên pêşketî + Eyarên pêşketî + Hinek tiştên dî + hemû + Hemû dataya aplîkasyonê hat jêbirin. + hemû endam + Bihêle + Bihêle ko dosya û medya werin şandin. + Bihêle ko lînkên SimpleXê werin şandin. + Hemû profîl + Hemû server + Jixwe tê girêdan! + Jixwe dikeve komê! + hercar + Hercar + Hercar vekirî + û %d hewadîsên dî + Biguhere + Şîfra databasê biguhere? + rola %s hat guhertin %s + rola te hat guhertin %s + Rola komê biguhere? + Adresa standinê biguhere + Adresa standinê biguhere? + Rolê biguhere + adres tê guhertin… + adres tê guhertin… + adresa %s tê guhertin… + %1$d xeletiyên dosyayan:\n%2$s + %1$s dixwaze bi te re bikeve danûstandinê bi riya + Adresa serverê kontrol bike û dîsa biceribîne. + Girêdana xwe yî înternetê kontrol bike û dîsa biceribîne + Notên şexsî vala bike? + Pêl pişkoka melûmatê ya nêzîkî cihê adresê bike ji bo destûrdana mîkrofonê. + Moda reng + Di wextekî nêzîk de tê! + Dosya qiyas bike + timam + Timam bûye + Vala bike + Vala bike + Vala bike + Şert di %s de hatin qebûlkirin. + Şertên şuxulandinê + Şert wê di %s de bên qebûlkirin. + Serverên SMPyê ên eyarkirî + Serverên XFTPyê ên eyarkirî + Serverên ICEyê eyar bike + Dosyayên ji serverên nenas qebûl bike. + Eyarên torê tesdîq bike. + Şîfra nû dîsa binivîsîne… + Bi xwe re bikeve danûstandinê? + Bi riya lînkê bikeve danûstandinê + Bi riya lînkê bikeve danûstandinê? + Bi riya lînkê / koda QRyê bikeve danûstandinê + Bi riya lînka yek carê bikeve danûstandinê? + Bi %1$s re bikeve danûstandinê? + Muhtewa ne li gora şertên şuxulandinê ye + Îkona kontekstê + Dewam bike + Beşdar bibe + Tora xwe kontrol bike + Xeletiyê kopî bike + Çêke + Çêke + Adres çêke + Adresekê çêke ji bo ko xelk karibin bi te re bikevin danûstandinê. + Hat çêkirin + Wextê çêkirinê + Wextê çêkirinê: %s + Dosya çêke + Kom çêke + Lînka komê çêke + Lînk çêke + Lînkeke dewetiyê ya yek carî çêke + Profîl çêke + Profîl çêke + Dor çêke + Komeke veşartî çêke + Komeke veşartî çêke + Adresa xwe çêke + Lînka arşîvê tê çêkirin + kesê ko çêkiriye + Xeletiya cidî + (niha) + Profîla niha + Tarî + Tarî + Moda tarî + Rengên moda tarî + Xuyakirina tarî + IDya databasê + IDya databasê: %d + %dr + %d roj + %d roj + jiberxweve (%s) + jiberxweve (%s) + Jê bibe piştî + Hemû dosyayan jê bibe + Jêbirî + Wextê jêbirinê + Wextê jêbirinê: %s + Dosya jê bibe + Ji bo min jê bibe + Komê jê bibe + Komê jê bibe? + Lînkê jê bibe + Lînkê jê bibe? + Profîlê jê bibe + Dorê jê bibe + Serverê jê bibe + Xeletiyên jêbirinê + Gihan/Gihiştin + Cihazên kompîter + Kompîter mijûl e + Kompîter ne aktîv e + Girêdana bi kompîterê re qut bû + Detay + CIHAZ + %d dosya bi mezibnbûniya timam ya %s + %d hewadîsên komê + %d seet + %d seet + Bigire + girtî + girtî + Ji bo her kesî bigire + Ji bo hemû koman bigire + %d deqe + %d deqe + %d heyv + %d heyv + %d heyv + Adres çêneke + Dîsa nîşan nede + Daxe + Daxistî + Dosyayên daxistî + Xeletiyên daxistinê + Daxistin bi ser neket + Dosya daxe + Detayên lînkê tên daxistin + %d heftî + Profîla komê biguhere + Sûret biguhere + Veke + vekirî + Vekirî heta + ji te re vekirî + Ji bo her kesî veke + Ji bo hemû koman veke + xilasbûyî + Navê komê binivîsîne: + Şîfra rast binivîsîne. + Şîfrê binivîsîne + Şîfrê binivîsîne… + Di lêgeranê de şîfrê binivîsîne + Navê xwe binivîsîne: + Xeletî + Xeletî + Xeletî + Xeletî di betalkirina guhertina adresê de + Xeletî di qebûlkirina şertan de + Xeletî di qebûlkirina xwestina ketina danûstandinê de + Xeletî di qebûlkirina endêm de + Xeletî di lêzêdekirina endam(an) de + Xeletî di lêzêdekirina serverê de + Xeletî di guhertina adresê de + Xeletî di guhertina profîlê de + Xeletî di guhertina rolê de + Xeletî di çêkirina adresê de + Serverên te yên XFTPyê + Te dewetîke komê şand + Serverên te yên SMPyê + Serverên te + Adresa servera te + Servera te + Profîla te yî %1$s wê bê parvekirin. + Tercihên te + Serverên te yên ICEyê + Serverên te yên ICEyê + Koma te + te %1$s derxist + Te dewetiya komê red kir + Profîla te yî niha + Tu karî dûvre wê çêkî + Tu dikarî wê di Eyarên xuyakirinê de biguherî. + te %s blok kir + Tu hatiye dewetkirinî komê + Tu jixwe dikevî vê komê bi riya vê lînkê. + Tu dihêlî + te ev endam qebûl kir + tu: %1$s + TU + tu + Erê + erê + Serverên XFTPyê + Servera XFTPyê + Şîfra xelet! + Şîfra xelet ya databasê + Bi kêmtir xerckirina pîlê. + Bi kêmtir xerckirina pîlê. + Bê Tor yan VPNê, adresa te yî IPyê wê ji van relayên XFTPyê re xuya bike:\n%1$s, + Bê Tor yan jî VPNê, wê adresa te yî IPyê ji serverên dosyayen re xuya bike. + Etherneta bi qeblo + WiFi + Çi yî nû heye + Websîte + Serverên WebRTC ICEyê + Hişyarî: hinek dataya te kare winda bibe! + dixwaze bi te re bikeve danûstandinê! + Girtî + Bi xêra xwe aplîkasyonê ji nû ve veke. + Veşêre: + Navê profîlê: + Navê timam: + Qeyd bike û xeberê bide endamên komê + Şîfra nîşandanê + Şîfra profîla veşartî + Tu karî markdownê bişuxulînî ji bo formatkirina mesajan: + Bi şuxulandina SimpleX Chatê tu qebûl dikî ku tu:\n- di komên vekirî tenê muhtewaya qanûnî bişînî.\n- hurmeta karberên dî bigirî – spam çênabe. + Veke + bi riya relayê + Vidyo girtî + Vîdyo vekirî + Deng girtî + Deng vekirî + Ekrana aplîkasyonê biparêze + Ji ber xwe ve sûretan qebûl bike + Adresa IPyê biparêze + Girtî + Na + Bipirse + Ber lînka webê were vekirin? + Lînkê veke + Lînka timam veke + Lînka paqij veke + ARÎKARÎ + APLÎKASYON + DOSYA + Ji nû ve veke + PROKSIYA SOCKSÊ + Sûretên profîlan + Girêdana torê + Ji kompîterê bişuxulîne + Xeletiya databasê + Dosya: %s + Xeletî: %s + Xeletiya nenaskirî + dewetiya ji bo koma %1$s + Derkeve + Kom nehat dîtin! + Ev kom nema heye. + derket + tu derketî + %s û %s + %s, %s û %d endam + adresa ji bo te hat guhertin + te adresa ji bo %s guhert + te adres guhert + mielif + endam + moderator + xwedî + redkirî + derxistî + derketiye + nayê zanîn + Endam %1$s + Rola endamên nû + Rola pêşî + Ji komê derkeve + Lînka komê + Xeletî di şandina dewetiyê de + Halê dosyayê + Wextê standinê + Wextê nûkirina qeydiyê: %s + Halê dosyayê: %s + Wextê şandinê: %s + Wextê standinê: %s + nivîs nîne + Endam derxe? + Endaman derxe? + Endam derxe + Derxe + Endam derxe + Endam blok bike + Blok bike + Ji admîn blokkirî + blokkirî + ne aktîv + ENDAM + Rol + Kom + Te standin bi riya + Halê torê + Girêdanê biedilîne + Biedilîne + Navê timam î komê: + Profîla komê di cihazên endaman de qeydkirî ye, ne di serveran de. + Profîla komê qeyd bike + Serveran bişuxulîne + %s bişuxulîne + Li şertan meyzîne + Şertên nûkirî + %s bişuxulînî, şertên şuxulandinê qebûl bike.]]> + Ji bo dosyayan bişuxulîne + Serverên medya & dosyayê ên lêzedekirî + Şertan veke + Guhertinan veke + Protokola serverê hat guhertin. + Reqema PINGan + TCP keep-alive aktîv bike + Qeyd bike + Qeyd bike û dîse girê de + Eyarên torê nû bike? + Profîl lê zêde bike + Girêdanên profîl û serveran + Veşêre + Nîşan bide + Bêdeng bike + Bêdengkirinê betal bike + Profîlê bike şexsî! + Şîfra profîlê + Rehnik + Rehnik + Reş + Sernav + Cewaba standî + Sûret jê bibe + Mezinbûniya fontê + Şefafî + Êvara te bi xêr! + Sibeha te bi xêr! + Dagire + Endam karin dosya û medya bişînin. + Dosya û medya memnû in. + Endam karin lînkên SimpleXê bişînin. + %d san + %d heftî + UIa farisî + Eyarên nû yên medyayê + Aplîkasyonê bi yek destî bişuxulîne. + Mezinbûniya fontê zêde bike. + Sebeba qutbûna girêdanê: %s + Ev lînk bi telefoneke dî re hatiye şuxulandin, bi xêra xwe li kompîterê lînkeke nû çêke. + Girêdana bi kompîterê re qut bike? + Tenê yek cihaz kare di eynî wextî de bişuxule + Li hêviya girêdana telefonê: + Ji ber xwe ve girê de + %s ne aktîv e]]> + %s mijûl e]]> + %s re di halekî xirab de ye]]> + Siḧbetê veke + Xeletî di çêkirina lîsta siḧbetan de + Xeletî di vekirina siḧbetê de + Siḧbetê bisekinîne + Profîlên siḧbetê biguhere + Siḧbet + Ti siḧbetên te nînin + Ti siḧbet di lîsta %s de nînin. + Ti siḧbetên nexwendî nînin + Siḧbet nînin + Ti siḧbet nehatin dîtin + Siḧbeta hilbijartî nîne + Tiştekî hilbijartî nîne + %d hilbijartî + Favorît + Kom + %d siḧbetên bi endaman + 1 siḧbeta bi yek endamî + %d siḧbet + Robot + Kom + Navê siḧbetê deyne… + Siḧbeteke nû bide destpêkirin + Ji bo ko yek siḧbete nû bide destpêkirin + Siḧbet ber were valakirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! Wê mesaj TENÊ ji bo te bên jêbirin. + Siḧbetê vala bike + Hemû siḧbet wê ji lîsta %s bên jêbirin, û wê lîste bê jêbirin + Siḧbeta nû + Profîla sihbetê hilbijêre + Profîlên te yên siḧbetê + Profîla siḧbetê çêke + Ber serverên SimpleX Chatê werin şuxulandin? + Profîla siḧbetê + Tu siḧbeta xwe qontrol dikî! + Siḧbetê bişuxulîne + SIḦBET + Rengên siḧbetê + Siḧbet sekinandî ye + DATABASA SIḦBETÊ + Ber siḧbet were sekinandin? + Xeletî di sekinandina siḧbetê de + Ber profîla siḧbetê were jêbirin? + ne ti carî + Şîfra databasê lazim e ji bo vekirina siḧbetê. + Şîfre qeyd bike û siḧbetê veke + Siḧbetê veke + Siḧbet sekinandî ye + Ber siḧbet were destpêkirin? + Tu dixwazî ji siḧbetê derkevî? + Siḧbetê jê bibe + Ber siḧbet were jêbirin? + Wê siḧbet ji bo te bê jêbirin - ev nikare were betalkirin/vegerandin! + Ji siḧbetê derkeve + Tenê xwediyên siḧbetê karin tercihan biguherin. + Bi admînan re siḧbetê bike + Bi endam re siḧbetê bike + Wê endêm ji siḧbetê bê derxistin - ev nikare were betalkirin/vegerandin! + Wê endam ji siḧbetê bên derxistin - ev nikare were betalkirin/vegerandin! + Siḧbet + Wê profîla te yî siḧbetê ji endamên komê re bê şandin + Wê profîla te yî siḧbetê ji endamên siḧbetê re bê şandin + Serverên ji bo dosyayên nû ên profîla te yî siḧbetê ya niha + Ber profîla siḧbetê were jêbirin? + Hemû mesaj wê bên jêbirin - ev nikare were betalkirin/vegerandin! + Profîla sihbetê jê bibe + Profîla siḧbetê hew veşêre + Vegerîne temaya aplîkasyonê + Vegerîne temaya karber + Temaya serî/pêşî diyar bike + Moda reḧnik + na + vekirî + girtî` + Tercihên siḧbetê + Mesajên ko winda dibin li vê siḧbetê nayên qebûlkirin. + Jêbirina ko nikare were betalkirin/vegerandin di vê siḧbetê de nayê qebûlkirin. + Siḧbetên bi endam + Ti siḧbetên bi endam nînin + Siḧbetê jê bibe + Bi admînan re siḧbetê bike + Siḧbetê ji nû ve veke + Siḧbet tê sekinandin + Siḧbetê bide destpêkirin + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml index 0b59cc1b06..281a734ed3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -25,12 +25,12 @@ zmoderowane przez %s wysyłanie plików nie jest jeszcze obsługiwane odbieranie plików nie jest jeszcze obsługiwane - Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu. + Próba połączenia z serwerem, który służył do odbierania wiadomości z tego połączenia. Próbowanie połączenia z serwerem używanym do odbierania wiadomości od tego kontaktu (błąd: %1$s). nieznany format wiadomości SimpleX Ty - Jesteś połączony z serwerem używanym do odbierania wiadomości od tego kontaktu. + Jesteś połączony z serwerem, który służył do odbierania wiadomości z tego połączenia. Twój profil zostanie wysłany do kontaktu, od którego otrzymałeś ten link. udostępniłeś jednorazowy link incognito przez link grupowy @@ -71,10 +71,10 @@ Błąd usuwania kontaktu Błąd usuwania grupy Błąd usuwania oczekującego połączenia kontaku - Możliwe, że odcisk palca certyfikatu w adresie serwera jest nieprawidłowy + Odcisk palca w adresie serwera nie pasuje do certyfikatu. Bezpieczna kolejka Nadawca mógł usunąć prośbę o połączenie. - Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło + Serwer wymaga autoryzacji do tworzenia kolejek, sprawdź hasło. Test nie powiódł się na etapie %s. Błąd usuwania profilu użytkownika Błąd aktualizacji prywatności użytkownika @@ -141,7 +141,7 @@ Usunąć wiadomość członka\? edytowana Dla wszystkich - dołącz jako %s + Dołącz jako %s wysyłanie nie powiodło się wyślij Udostępnij plik… @@ -154,7 +154,7 @@ oznacz jako nieprzeczytane Witaj! Witaj %1$s! - jesteś zaproszony do grupy + Jesteś zaproszony do grupy Nie masz czatów Czaty Poproszony o odbiór obrazu @@ -179,7 +179,7 @@ Oczekiwanie na film Oczekiwanie na film jesteś obserwatorem - Nie możesz wysyłać wiadomości! + Jesteś obserwatorem Połączony Obecnie maksymalny obsługiwany rozmiar pliku to %1$s. Usuń kontakt @@ -428,9 +428,9 @@ Jak to działa Jak SimpleX działa Natychmiastowy - Można to później zmienić w ustawieniach. + Jak wpływa na baterię Nawiąż prywatne połączenie - dwuwarstwowego szyfrowania end-to-end.]]> + Tylko urządzenia klienckie przechowują profile użytkowników, kontakty, grupy i wiadomości. Okresowo Prywatne powiadomienia repozytorium GitHub.]]> @@ -814,10 +814,10 @@ %d mies %ds %d sek - Członkowie grupy mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) - Członkowie grupy mogą wysyłać bezpośrednie wiadomości. + Członkowie mogą nieodwracalnie usuwać wysłane wiadomości. (24 godziny) + Członkowie mogą wysyłać bezpośrednie wiadomości. Nieodwracalne usuwanie wiadomości jest na tym czacie zabronione. - Nieodwracalne usuwanie wiadomości jest w tej grupie zabronione. + Usuwanie wiadomości nieodwracalnych jest zabronione. Tylko Ty możesz nieodwracalnie usunąć wiadomości (Twój kontakt może oznaczyć je do usunięcia). (24 godziny) Tylko Ty możesz wysyłać znikające wiadomości. Tylko Ty możesz wysyłać wiadomości głosowe. @@ -827,7 +827,7 @@ Zabroń wysyłania bezpośrednich wiadomości do członków. Zabroń wysyłania znikających wiadomości. Wiadomości głosowe są zabronione na tym czacie. - Wiadomości głosowe są zabronione w tej grupie. + Wiadomości głosowe są zabronione. Administratorzy mogą tworzyć linki do dołączania do grup. Automatyczne akceptowanie próśb o kontakt anulowano %s @@ -921,7 +921,7 @@ %d dni Usuń Usuń wiadomości po - Znikające wiadomości są zabronione w tej grupie. + Znikające wiadomości są zabronione. Błąd usuwania prośby o kontakt Nie znaleziono pliku Błąd zapisu serwerów SMP @@ -939,9 +939,9 @@ zaproponował %s: %2s Tylko właściciele grup mogą włączyć wiadomości głosowe. Tylko Twój kontakt może nieodwracalnie usunąć wiadomości (możesz oznaczyć je do usunięcia). (24 godziny) - Hasło nie zostało znalezione w Keystore, wprowadź je ręcznie. Może się tak zdarzyć, gdy przywrócisz dane aplikacji za pomocą narzędzia do kopii zapasowych. Jeśli tak nie jest, skontaktuj się z programistami. - Członkowie grupy mogą wysyłać znikające wiadomości. - Członkowie grupy mogą wysyłać wiadomości głosowe. + Hasło nie znalezione w Keystore, proszę wpisać je ręcznie. Mogło się to zdarzyć, jeśli przywróciłeś dane aplikacji za pomocą narzędzia do tworzenia kopii zapasowej. Jeśli tak nie jest, skontaktuj się z deweloperami. + Członkowie mogą wysyłać znikające wiadomości. + Członkowie mogą wysyłać wiadomości głosowe. Grupa zostanie usunięta dla wszystkich członków - nie można tego cofnąć! Jak korzystać z Twoich serwerów zeskanować kod QR w rozmowie wideo, lub Twój rozmówca może udostępnić link z zaproszeniem.]]> @@ -951,7 +951,7 @@ Zaimportować bazę danych czatu\? Tryb incognito chroni Twoją prywatność używając nowego losowego profilu dla każdego kontaktu. pośrednie (%1$s) - pozwolić SimpleX na działanie tle w następnym oknie dialogowym. W przeciwnym razie powiadomienia zostaną wyłączone.]]> + Pozwól w następnym oknie dialogowym natychmiast otrzymywać powiadomienia.]]> Zainstaluj SimpleX Chat na terminal Nieprawidłowe potwierdzenie migracji zaproszenie do grupy %1$s @@ -985,8 +985,8 @@ Tego działania nie można cofnąć - wszystkie odebrane i wysłane pliki oraz media zostaną usunięte. Obrazy o niskiej rozdzielczości pozostaną. Adres odbiorczy zostanie zmieniony na inny serwer. Zmiana adresu zostanie zakończona gdy nadawca będzie online. Ten link nie jest prawidłowym linkiem połączenia! - SimpleX - zużywa ona kilka procent baterii dziennie.]]> - Aby chronić prywatność, zamiast identyfikatorów użytkowników używanych przez wszystkie inne platformy, SimpleX ma identyfikatory dla kolejek wiadomości, oddzielne dla każdego z Twoich kontaktów. + SimpleX działa w tle zamiast korzystać z powiadomień push.]]> + Aby chronić Twoją prywatność, SimpleX używa oddzielnych identyfikatorów dla każdego z Twoich kontaktów. Aby zweryfikować szyfrowanie end-to-end z Twoim kontaktem porównaj (lub zeskanuj) kod na waszych urządzeniach. Użyj dla nowych połączeń O ile Twój kontakt nie usunął połączenia lub ten link był już użyty, może to być błąd - zgłoś go. @@ -1142,7 +1142,7 @@ Dodaj adres do swojego profilu, aby Twoje kontakty mogły go udostępnić innym osobom. Aktualizacja profilu zostanie wysłana do Twoich kontaktów. Utwórz adres, aby ludzie mogli się z Tobą połączyć. Utwórz adres SimpleX - Zapisz ustawienia automatycznej akceptacji + Zapisz ustawienia adresów SimpleX Udostępnij kontaktom Możesz go utworzyć później Adres @@ -1173,10 +1173,10 @@ Wyślij znikającą wiadomość Zabroń reakcje wiadomości. Reakcje wiadomości - Członkowie grupy mogą dodawać reakcje wiadomości. + Członkowie mogą dodawać reakcje na wiadomości. godziny Reakcje wiadomości są zabronione na tym czacie. - Reakcje wiadomości są zabronione w tej grupie. + Reakcje na wiadomości są zabronione. minuty miesiące Tylko Ty możesz dodawać reakcje wiadomości. @@ -1245,9 +1245,9 @@ Pozwól na wysyłanie plików i mediów. Brak filtrowanych czatów Nieulubione - Członkowie grupy mogą wysyłać pliki i media. + Członkowie mogą wysyłać pliki i media. Zakaz wysyłania plików i mediów. - Pliki i media są zabronione w tej grupie. + Pliki i media są zabronione. Tylko właściciele grup mogą włączać pliki i media. Szukaj Wyłączono @@ -1378,7 +1378,7 @@ Błąd tworzenia kontaktu członka Wyślij wiadomość bezpośrednią aby połączyć wyślij wiadomość bezpośrednią - połącz bezpośrednio + Prośba o połączenie usunięto kontakt Utwórz grupę Utwórz profil @@ -1556,7 +1556,7 @@ członek %1$s zmienił na %2$s ustaw nowy adres kontaktu zaktualizowano profil - Były członek %1$s + Członek %1$s Zablokować członka dla wszystkich? Utworzony o Zachowano wiadomość @@ -1619,7 +1619,7 @@ Zakończ połączenie Połączenie wideo Błąd podczas otwierania przeglądarki - Do połączeń wymagana jest domyślna przeglądarka. Proszę skonfigurować domyślną przeglądarkę systemową, i podzielić się informacją z twórcami. + Do wykonywania połączeń wymagana jest domyślna przeglądarka internetowa. Skonfiguruj domyślną przeglądarkę w systemie i przekaż więcej informacji programistom. Ten czat jest chroniony przez szyfrowanie e2e odporne na ataki kwantowe. szyfrowanie end-to-end z perfect forward secrecy, zaprzeczalnością i odzyskiwaniem bezpieczeństwa po kompromitacji.]]> Otwórz ekran migrowania @@ -1712,7 +1712,7 @@ Wiadomości głosowe są niedozwolone Włączony dla właściciele - Linki SimpleX są zablokowane na tej grupie. + Linki SimpleX są zablokowane. Inne WiFi Połączenie ethernet (po kablu) @@ -1720,7 +1720,7 @@ wszyscy członkowie Zezwól na wysyłanie linków SimpleX. Sieć komórkowa - Członkowie grupy mogą wysyłać linki SimpleX. + Członkowie mogą wysyłać linki SimpleX. Brak połączenia z siecią Zabroń wysyłania linków SimpleX Linki SimpleX @@ -1929,7 +1929,7 @@ Wyświetlanie informacji dla Statystyki Sesje transportowe - Zaczynanie od %s. \nWszystkie dane są prywatne na Twoim urządzeniu. + Zaczynanie od %s.\nWszystkie dane są prywatne na Twoim urządzeniu. Połącz ponownie wszystkie serwery Połączyć ponownie serwer? Nie jesteś połączony z tymi serwerami. Prywatne trasowanie jest używane do dostarczania do nich wiadomości. @@ -2040,7 +2040,7 @@ Zaznacz Wiadomości zostaną usunięte dla wszystkich członków. Wiadomości zostaną oznaczone jako moderowane dla wszystkich członków. - Osiągalny pasek narzędzi czatu + Osiągalny pasek narzędzi Wyeksportowano bazę danych czatu Kontynuuj Serwery mediów i plików @@ -2094,7 +2094,7 @@ Dźwięk wyciszony Wybierz profil czatu Udostępnij profil - Twoje połączenie zostało przeniesione do %s, ale podczas przekierowania do profilu wystąpił nieoczekiwany błąd. + Twoje połączenie zostało przeniesione na %s, ale pojawił się błąd podczas zmiany profilu. Tryb systemu Przesłane archiwum bazy danych zostanie trwale usunięte z serwerów. Serwer @@ -2189,4 +2189,372 @@ Nowy członek chce dołączyć do grupy. 1 rok Akceptuj + rozmowa z członkiem grupy + Akceptuj + Akceptuj jako członek grupy + Akceptuj jako obserwator grupy + Akceptuj dodanie kontaktu + Akceptuj dodanie kontaktu + Akceptuj użytkownika grupy + Dodaj wiadomość + Wszystko + Wszystkie nowe wiadomości od tego użytkownika będą ukryte + Zezwól na wszystkie pliki i media tylko jeśli twój kontakt na to pozwala. + Zezwól na zgłaszanie raportów do moderatorów. + Zezwól twoim kontaktom na wysyłanie plików i mediów. + Zezwól na archiwizowanie raportów dla ciebie. + Wszystkie serwery + Zarchiwizować wszystkie raporty? + Zarchiwizować %d raportów? + Archiwizuj raporty + Lepsze działanie grupy + Lepsza prywatność i bezpieczeństwo + Opis: + Opis zbyt duży + Bot + Ty i twój kontakt możecie wysyłać pliki i media. + Kontakt służbowy + Uzywając SimpleX Chat zgadzasz się na:\n- wysyłanie tylko prawnie dopuszczonych treści na publicznych grupach.\n- szanowanie innych użytkowników - nie wysyłanie SPAM-u + Nie mogę zmienić profilu + nie mogę wysłać wiadomości + 4 nowe języki interfejsu + Wszystkie wiadomości + Blokowanie członków dla wszystkich? + Kataloński, indonezyjski, rumuński i wietnamski - dzięki naszym użytkownikom! + szyfrowanie end-to-end.]]> + tylko po zaakceptowaniu twojego żądania.]]> + Zmienić automatyczne usuwania wiadomości? + Czaty z członkami + Czat z administratorami + Czar z administratorami + Czat z administratorami + Czat z członkiem + Czatuj z członkami, zanim dołączą. + Konfigurowanie operatorów serwerów + Połącz + Połącz się szybciej! 🚀 + kontakt usunięty + kontakt wyłączony + kontakt nie gotowy + PROŚBY O KONTAKT OD GRUP + kontakt powinien zaakceptować… + Stwórz swój adres + %d czat(y) + %d czaty z członkami + domyślny (%s) + Usuń czat + Usuń wiadomości czatu z urządzenia. + Skasować czat z tym członkiem? + Skasuj wiadomości od tego członka + Skasować wiadomości od tego członka? + Skasuj wiadomości + Opcje wycofane + Opis jest zbyt duży + Bezpośrednie wiadomości między członkami są zabronione. + Wiadomości bezpośrednie między członkami są zabronione na tym czacie. + Wyłączyć automatyczne usuwanie wiadomości? + Zablokuj skasowane wiadomości + %d wiadomości + Nie przegap ważnych wiadomości. + %d raporty + Edytuj + Włącz domyślne znikanie wiadomości. + Włącz Flux w ustawieniach sieci i serwerów, aby uzyskać lepszą prywatność metadanych. + Włącz logi + Renegocjacja szyfrowania jest w toku. + Błąd podczas akceptacji warunków + Błąd podczas akceptacji członka + Błąd podczas dodawania serwera + Błąd podczas zmiany profilu + Błąd podczas tworzenia listy czatu + Błąd podczas tworzenia raportu + Błąd usuwania czatu + Błąd ładowania list czatu + Błąd oznaczania odczytu + Błąd otwierania czatu + Błąd otwierania grupy + Błąd odczytu bazy danych hasła + Błąd odrzucenia prośby o kontakt + Błąd zapisywania bazy danych + Błąd zapisywania serwerów + Błąd zapisywania ustawień + Błąd w konfiguracji serwerów. + Błąd aktualizowania listy czatu + Błąd aktualizacji serwera + Szybsze usuwania grup. + Szybsze wysyłanie wiadomości. + Ulubione + Plik jest zablokowany przez operatora serwera:\n%1$s. + Pliki + Pliki i media są zabronione na tym czacie. + Filtr + Odcisk palca w docelowym serwerze nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Odcisk palca w adresie serwera nie pasuje do certyfikatu: %1$s. + Napraw + Naprawić połączenie? + Dla wszystkich moderatorów + Lepsza prywatność metadanych. + Dla profilu czatu %s: + Na przykład, jeśli kontakt otrzyma wiadomości za pośrednictwem serwera czatu SimpleX, aplikacja dostarczy je za pośrednictwem serwera Flux. + Dla mnie + Dla prywatnego routingu + Dla mediów społecznościowych + Pełny link + Otrzymaj powiadomienie jeśli ktoś wspomni. + Grupa + grupa została usunięta + Grupy + Pomóż administratorom moderować ich grupy. + Jak to pomaga prywatności + Zdjęcia + Poprawiona nawigacja czatu + Niewłaściwa zawartość + Niewłaściwy profil + Zaproszenie do czatu + Dołącz do grupy + Zachowaj swoje czaty czyste + Opuść czat + Opuścić czat? + Mniejszy ruch w sieciach mobilnych. + Linki + Lista + Lista imion... + Nazwa i emoji powinny być inne dla wszystkich list. + Wczytywanie profilu… + Przyjęcie członkostwa + członek posiada starą wersję + Członek został usunięty - nie można przyjąć żądania + Wiadomości członkowskie zostaną usunięte - nie można tego cofnąć! + Raporty członkowskie + Członkowie mogą zgłaszać wiadomości moderatorom. + Członkowie zostaną usunięci z czatu - tego nie da się cofnąć! + Członkowie zostaną usunięci z grupy - nie można tego cofnąć! + Członek zostanie usunięty z czatu - nie można tego cofnąć! + Członek dołączy do grupy, czy zaakceptować tego członka? + Wspomnij członka 👋 + Wyślij wiadomość natychmiast po dotknięciu Połącz. + Wiadomość jest za duża! + Wiadomości od tych członków zostaną pokazane! + Wiadomości na tym czacie nigdy nie zostaną usunięte. + moderator + moderatorzy + Wycisz wszystko + Decentralizacja sieci + Operator sieci + Operatorzy sieci + Nowa rola w grupie: Moderator + Nowy serwer + Nie + Brak usług w tle + Żadnych czatów + Nie znaleziono żadnych czatów + Nie ma czatów na liście %s. + Żadnych rozmów z członkami + Brak mediów i serwerów plików multimedialnych. + Brak wiadomości + Brak serwerów wiadomości. + Brak prywatnej sesji routingu + Brak serwerów prywatnej sesji routingu + Brak serwerów do otrzymania plików. + Brak serwerów aby otrzymać wiadomości. + Brak serwerów do wysyłania plików. + brak subskrypcji + Notatki + Powiadomienia i bateria + nie zsynchronizowano + Brak nieprzeczytanych czatów + wyłączony + Wyłącz + Tylko właściciele czatu mogą zmieniać preferencje. + Widzą to tylko nadawca i moderatorzy + Widzisz to tylko Ty i moderatorzy + Tylko Ty możesz wysyłać pliki i multimedia. + Tylko Twój kontakt może wysyłać pliki i multimedia. + Otwórz zmiany + Otwórz czat + - Otwórz czat w pierwszej nieprzeczytanej wiadomości.\n- Przejdź do cytowanych wiadomości. + Otwórz czysty link + Otwórz warunki + Otwórz pełny link + Otwórz link + Otwórz linki z listy czatów + Otwórz nowy czat + Otwórz nową grupę + Otwórz by zaakceptować + Otwórz aby się połączyć + Otwórz aby dołączyć + Otwórz aby skorzystać z bota + Otworzyć link sieci web? + Otwórz z %s + Operator + Serwer Operatora + Organizuj czaty jako listy + Lub zaimportuj plik archiwalny + Lub udostępnij prywatnie + Nie można odczytać hasła w magazynie kluczy. Wprowadź je ręcznie. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + Nie można odczytać hasła w magazynie kluczy. Mogło się to zdarzyć po aktualizacji systemu niezgodnej z aplikacją. Jeśli tak nie jest, skontaktuj się z programistami. + oczekuje + oczekuje na zatwierdzenie + oczekuje na ocenę + Zmniejsz rozmiar wiadomości i wyślij ją ponownie. + Zmniejsz rozmiar wiadomości lub usuń multimedia i wyślij ponownie. + Poczekaj, aż moderatorzy grupy rozpatrzą Twoją prośbę o dołączenie do grupy. + Domyślne serwery + Domyślne serwery + Prywatność dla Twoich klientów. + Polityka prywatności i warunki korzystania. + Prywatne czaty, grupy i Twoje kontakty nie są dostępne dla operatorów serwerów. + Nazwy prywatnych plików multimedialnych. + Limit czasu routingu prywatnego + Zabroń raportowania wiadomości moderatorom. + Zabroń wysyłania plików i multimediów. + Limit czasu protokołu w tle + Dostępny pasek narzędzi czatu + Odrzuć + Odrzuć prośbę o kontakt + odrzucono + odrzucono + Odrzucić członka? + Zdalne telefony komórkowe + Usuń i skasuj wiadomości + usunięty z grupy + Usuń śledzenie linków + Usunąć członka? + Usuwa wiadomości i blokuje członków. + Zgłoś + Zgłoś treść: zobaczą ją tylko moderatorzy grupy. + Na tej grupie zabronione jest zgłaszanie wiadomości. + Zgłoś profil członka: będą go widzieć tylko moderatorzy grupy. + Zgłoś inne: zobaczą to tylko moderatorzy grupy. + Jaki jest powód zgłoszenia? + Zgłoszenie: %s + Zgłoszenia + Zgłoszenia wysłane do moderatorów + Zgłoś spam: tylko moderatorzy grupy będą to widzieć. + Zgłoś naruszenie: zobaczą je tylko moderatorzy grupy. + Prośba o połączenie od grupy %1$s + poproszono o połączenie + prośba została wysłana + prośba o dołączenie została odrzucona + ocena + Przejrzyj warunki + sprawdzone przez administratorów + Przejrzyj członków grupy + Przejrzyj później + Przejrzyj członków + Przejrzyj członków przed przyjęciem (pukanie). + Zapisać ustawienia wstępu? + Zapisz listę + Szukaj plików + Szukaj zdjęć + Szukaj linków + Szukaj wideo + Szukaj wiadomości głosowych + Wybierz operatora sieci + Wysłać prośbę o kontakt? + Wyślij prywatne zgłoszenia + Wyślij prośbę + Wyślij prośbę bez wiadomości + Wyślij swoją prywatną opinię do grup. + Wysłano do Twojego kontaktu po połączeniu. + Serwer dodany do operatora %s. + Operator serwera został zmieniony. + Operatorzy serwera + Protokół serwera zmieniony. + Ustaw nazwę czatu… + Ustaw przyjęcie członka + Ustaw datę wygaśnięcia wiadomości na czatach. + Ustaw biografię profilu i wiadomość powitalną. + Udostępnij adres publicznie + Udostępnij stary adres + Udostępnij stary link + Udostępnij adres SimpleX w mediach społecznościowych. + Udostępnij swój adres + Krótki opis: + Krótki link + Krótki adres SimpleX + Link do kanału na SimpleX + SimpleX Chat i Flux zawarły umowę na włączenie do aplikacji serwerów obsługiwanych przez Flux. + łącze przekaźnikowe SimpleX + Spam + Spam + %s serwery + Dotknij Połącz aby rozpocząć czat + Dotknij Połącz, aby wysłać prośbę + Dotknij Połącz aby użyć bota + Dotknij Stwórz adres SimpleX w menu aby utworzyć go później. + Dotknij Dołącz do grupy + Przekroczono limit czasu połączenia TCP + Port TCP dla wiadomości + Adres będzie krótki, a Twój profil zostanie udostępniony za pośrednictwem adresu. + Aplikacja chroni Twoją prywatność, korzystając z różnych operatorów w każdej rozmowie. + Połączenie osiągnęło limit niedostarczonych wiadomości, Twój kontakt może być offline. + Link będzie krótki, a profil grupowy zostanie udostępniony poprzez link. + Raport zostanie dla Ciebie zarchiwizowany. + Rola zostanie zmieniona na %s. Wszyscy uczestnicy czatu zostaną powiadomieni. + Drugi predefiniowany operator w aplikacji! + Nadawca NIE zostanie poinformowany. + Serwery dla nowych plików Twojego bieżącego profilu czatu + Tej akcji nie można cofnąć - wiadomości wysłane i otrzymane na tym czacie wcześniej niż wybrane zostaną usunięte. + Ten link wymaga nowszej wersji aplikacji. Zaktualizuj aplikację lub poproś osobę kontaktową o przesłanie kompatybilnego łącza. + Ta wiadomość została usunięta lub jeszcze nie otrzymana. + To ustawienie jest dla Twojego obecnego profilu. + Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. + Aby zabezpieczyć się przed wymianą łącza, możesz porównać kody bezpieczeństwa kontaktu. + Żeby odebrać + Żeby wysłać + Aby wysyłać polecenia, musisz być podłączony. + Aby po próbie połączenia skorzystać z innego profilu, usuń czat i użyj linku ponownie. + Przeźroczystość + Odblokować członków dla wszystkich? + Niedostarczone wiadomości + Nieprzeczytane wzmianki + Nieobsługiwane łącze połączenia + Aktualizacja + Warunki aktualizacji + Aktualizuj swój adres + Upgrade + Uaktualnij adres + Uaktualnić adres? + Uaktualnij link do grupy + Uaktualnić link do grupy? + Użyj dla plików + Użyj dla wiadomości + Użyj profilu incognito + Użyj %s + Użyj serwerów + Użyj portu TCP %1$s, jeśli nie określono żadnego portu. + Używaj portu TCP 443 tylko dla wstępnie ustawionych serwerów. + Użyj portu internetowego + Wideo + Zobacz warunki + Zobacz zaktualizowane warunki + Wiadomości głosowe + Strona Internetowa + Wiadomość powitalna + Powitaj swoje kontakty + Gdy włączony jest więcej niż jeden operator, żaden z nich nie ma metadanych pozwalających dowiedzieć się, kto się z kim komunikuje. + Tak + zaakceptowałeś tego członka + Nie masz połączenia z serwerem używanym do odbierania wiadomości z tego połączenia (brak subskrypcji). + Możesz skonfigurować operatorów w ustawieniach sieci i serwerów. + Serwery można skonfigurować w ustawieniach. + Możesz skopiować i zmniejszyć rozmiar wiadomości, aby ją wysłać. + Możesz wzmiankować do %1$s członków na wiadomość! + Możesz ustawić nazwę połączenia, aby zapamiętać, z kim link został udostępniony. + Nie możesz wysyłać wiadomości! + Możesz przeglądać swoje raporty na czacie z administratorami. + odszedłeś + Twój opis: + Twój kontakt biznesowy + Twój profil na czacie zostanie wysłany do członków czatu + Twój kontakt + Twoja grupa + Twój profil + Twoje serwery + Przestaniesz otrzymywać wiadomości z tego czatu. Historia czatu zostanie zachowana. + Połączenie nie powiodło się + niepowodzenie + Jeśli dołączyłeś do kanałów lub je utworzyłeś, przestaną one działać na stałe. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml index 95e5ed9409..5445c57055 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -340,7 +340,7 @@ Одноразовая ссылка Настройки - Ваш SimpleX адрес + Ваш адрес SimpleX База данных Подробнее о SimpleX Chat Как использовать @@ -428,7 +428,7 @@ Ваш профиль, контакты и доставленные сообщения хранятся на Вашем устройстве. Профиль отправляется только Вашим контактам. Имя профиля не может содержать пробелы. - Введите ваше имя: + Имя: Создать О SimpleX @@ -496,7 +496,7 @@ видеозвонок аудиозвонок - Аудио- и видеозвонки + Аудио и видеозвонки Ваши звонки Всегда соединяться через relay Звонки на экране блокировки: @@ -627,7 +627,7 @@ Текущий пароль… Новый пароль… Подтвердите новый пароль… - Поменять пароль + Сменить пароль Пожалуйста, введите правильный пароль. База данных НЕ зашифрована. Установите пароль, чтобы защитить Ваши данные. Android Keystore используется для безопасного хранения пароля - это позволяет стабильно получать уведомления в фоновом режиме. @@ -636,7 +636,7 @@ Пароль базы данных будет безопасно сохранён в Android Keystore после запуска чата или изменения пароля - это позволит стабильно получать уведомления. Пароль не сохранён на устройстве — Вы будете должны ввести его при каждом запуске чата. Зашифровать базу данных? - Поменять пароль базы данных? + Сменить пароль базы данных? База данных будет зашифрована. База данных будет зашифрована и пароль сохранён в Keystore. Пароль базы данных будет изменён и сохранён в Keystore. @@ -660,7 +660,7 @@ Введите пароль… Сохранить пароль и открыть чат Открыть чат - Попытка поменять пароль базы данных не была завершена. + Попытка изменить пароль базы данных не была завершена. Восстановить резервную копию Восстановить резервную копию? Введите предыдущий пароль после восстановления резервной копии. Это действие нельзя отменить. @@ -713,7 +713,7 @@ Вы поменяли роль себе на: %s Вы удалили %1$s Вы покинули группу - профиль группы обновлен + профиль группы обновлён поменял(а) адрес для Вас смена адреса… @@ -818,7 +818,7 @@ Включить TCP keep-alive Сохранить Обновить настройки сети? - Обновление настроек приведет к переподключению клиента ко всем серверам. + Обновление настроек приведёт к переподключению клиента ко всем серверам. Обновить Инкогнито @@ -880,7 +880,7 @@ Прямые сообщения между членами группы запрещены. Члены могут необратимо удалять отправленные сообщения. (24 часа) Необратимое удаление сообщений запрещено. - Члены могут отправлять голосовые сообщения. + Участники могут отправлять голосовые сообщения. Голосовые сообщения запрещены. Минимальный расход батареи. Вы получите уведомления только когда приложение запущено, без фонового сервиса.]]> Уведомления @@ -981,7 +981,7 @@ Только локальные данные профиля Сообщения Серверы для новых соединений Вашего текущего профиля чата - Ваши профили + Профили Все чаты и сообщения будут удалены - это нельзя отменить! Сборка приложения: %s Версия приложения: v%s @@ -1059,7 +1059,7 @@ Установите приветственное сообщение для новых членов группы. Нажмите на профиль, чтобы переключиться на него. Благодаря пользователям - добавьте переводы через Weblate! - Вы все равно получите звонки и уведомления в профилях без звука, когда они активные. + Вы всё равно получите звонки и уведомления в профилях без звука, когда они активные. Вы можете скрыть или отключить уведомления профиля - нажмите и удерживайте профиль, чтобы открыть меню. Изображение будет принято когда Ваш контакт его загрузит. Файл будет принят когда Ваш контакт загрузит его. @@ -1074,7 +1074,7 @@ версия базы данных новее чем приложения, но нет миграции для отката: %s разная миграция в приложении/базе данных: %s / %s Откатить версию и открыть чат - Предупреждение: Вы можете потерять какие то данные! + Предупреждение: Вы можете потерять некоторые данные! ID базы данных и опция Отдельные транспортные сессии. Показать опции для разработчиков Удалить профиль чата @@ -1203,7 +1203,7 @@ Изменить код самоуничтожения Самоуничтожение Код самоуничтожения включен! - Код доступа в приложение будет заменен кодом самоуничтожения. + Код доступа в приложение будет заменён кодом самоуничтожения. Включить код самоуничтожения Код самоуничтожения Код самоуничтожения изменен! @@ -1243,13 +1243,13 @@ Ваши контакты сохранятся. Настроить тему Создайте адрес, чтобы можно было соединиться с Вами. - Все Ваши контакты сохранятся. Обновленный профиль будет отправлен Вашим контактам. + Все Ваши контакты сохранятся. Обновлённый профиль будет отправлен Вашим контактам. Добавьте адрес в свой профиль, чтобы Ваши контакты могли поделиться им. Профиль будет отправлен Вашим контактам. Создать адрес SimpleX Поделиться с контактами Прекратить делиться адресом\? Автоприём - Введите приветственное сообщение... (опционально) + Введите приветственное сообщение... (по желанию) Сохранить настройки\? Прекратить делиться Продолжить @@ -1373,7 +1373,7 @@ Пересогласовать шифрование Быстрый поиск чатов Отчёты о доставке сообщений! - Еще несколько изменений + Ещё несколько изменений Отчёты о доставке! Включить Даже когда они выключены в разговоре. @@ -1622,7 +1622,7 @@ Ошибка создания сообщения Ошибка удаления заметки Венгерский и Турецкий интерфейс - Искать или вставьте ссылку SimpleX + Поиск или вставить ссылку SimpleX Этот QR-код не является SimpleX-ccылкой. С зашифрованными файлами и медиа. С уменьшенным потреблением батареи. @@ -1682,15 +1682,15 @@ Сохранённое сообщение неизвестно неизвестный статус - %d сообщений заблокировано администратором + %d сообщений заблокировано админом %s заблокирован %s разблокирован Вы разблокировали %s Разблокировать для всех Заблокировать члена группы для всех? заблокирован - заблокировано администратором - Заблокирован администратором + заблокировано админом + Заблокирован админом Заблокировать для всех Ошибка при блокировании члена группы для всех Разблокировать члена группы для всех? @@ -1750,7 +1750,7 @@ Завершить миграцию Или передайте эту ссылку Миграция завершена - Внимание: запуск чата на нескольких устройствах не поддерживается и приведет к сбоям доставки сообщений. + Внимание: запуск чата на нескольких устройствах не поддерживается и приведёт к сбоям доставки сообщений. не должны использовать одну и ту же базу данных на двух устройствах.]]> Проверьте подключение к Интернету и повторите попытку Подтвердите, что Вы помните пароль базы данных для её миграции. @@ -1815,7 +1815,7 @@ Ссылки SimpleX Разрешить отправлять ссылки SimpleX. Запретить отправку ссылок SimpleX - Члены могут отправлять SimpleX ссылки. + Участники могут отправлять ссылки SimpleX. админы все члены владельцы @@ -1836,9 +1836,9 @@ ФАЙЛЫ Новые темы чатов нет - Светлая - Системная - Цвета тёмного режима + Светлый + Системный + Цвета темного режима Получайте файлы безопасно Конфиденциальная доставка 🚀 Улучшенная доставка сообщений @@ -1871,7 +1871,7 @@ Всегда Подтверждать файлы с неизвестных серверов. Всегда использовать конфиденциальную доставку. - Тёмная + Тёмный Отладка доставки Ошибка инициализации WebView. Обновите Вашу систему до новой версии. Свяжитесь с разработчиками. \nОшибка: %s @@ -1948,7 +1948,7 @@ Неверный ключ или неизвестный адрес блока файла - скорее всего, файл удален. Выбранные настройки чата запрещают это сообщение. Ошибка файла - Сканировать / Вставить ссылку + Сканировать QR-код/ Вставить ссылку Другие XFTP-серверы Настроенные XFTP-серверы Загрузка %s (%s) @@ -2016,7 +2016,7 @@ Слабое Среднее Выключено - Доступная панель приложения + Панель приложения внизу Текущий профиль Нет информации, попробуйте перезагрузить Информация о серверах @@ -2217,7 +2217,7 @@ %s.]]> %s.]]> %s, примите условия использования.]]> - Для оправки + Для отправки Дополнительные серверы сообщений Использовать для файлов Открыть условия @@ -2341,11 +2341,11 @@ Ошибка обновления списка чата Ошибка создания списка чатов Список - Никаких чатов в списке %s. + Нет чатов в списке %s. Без непрочитанных чатов Никаких чатов Чаты не найдены - Все чаты будут удалены из списка %s, а сам список удален + Все чаты будут удалены из списка %s, а сам список удалён Добавить список Примечания Открыть в %s @@ -2380,7 +2380,7 @@ Улучшенная приватность и безопасность Ускорено удаление групп. Ускорена отправка сообщений. - Помогайте администраторам модерировать их группы. + Помогайте админам модерировать их группы. Организуйте чаты в списки Вы можете сообщить о нарушениях Установите время исчезания сообщений в чатах. @@ -2426,7 +2426,7 @@ модератор ожидает утверждения ожидает - Обновленные условия + Обновлённые условия Запретить жаловаться модераторам группы. Члены группы могут пожаловаться модераторам. Сообщения в этом чате никогда не будут удалены. @@ -2566,7 +2566,7 @@ Член группы удалён - невозможно принять запрос Чтобы использовать другой профиль после попытки соединения, удалите чат и используйте ссылку снова. Приветственное сообщение - О Вас: + О себе: Ваш профиль Описание слишком длинное Использовать профиль инкогнито @@ -2615,4 +2615,23 @@ Хэш в адресе сервера не соответствует сертификату: %1$s. Ссылка SimpleX relay Хэш в адресе сервера назначения не соответствует сертификату: %1$s. + Удалить сообщения участника + Удалить сообщения участника? + Удалить сообщения + Сообщения участника будут удалены - это действие не обратимо! + нет подписки + Вы не подключенны к серверу через который Вы получали сообщения от этого контакта (без подписки). + Удалить члена группы и удалить сообщения + Все сообщения + Файлы + Фильтр + Изображения + Ссылки + Поиск файлов + Поиск изображений + Поиск ссылок + Поиск видео + Поиск голосовых сообщений + Видео + Голосовые сообщения diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml new file mode 100644 index 0000000000..55344e5192 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sv/strings.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml index 501f07ea50..16d821637b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -836,7 +836,7 @@ Mesaj tepkileri Tercihleriniz Mesaj tepkileri yasaklıdır. - Bu kişiden mesaj almak için kullanılan sunucuya bağlısınız. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlısınız. Zaten %1$s e bağlısınız Doğrulanamadınız; lütfen tekrar deneyin. SimpleX Kilidini Ayarlar üzerinden açabilirsiniz. @@ -938,7 +938,7 @@ kapalı açık Kilit modunu değiştir - Bu kişiden mesaj almak için kullanılan sunucuya bağlanılmaya çalışılıyor. + Bu bağlantıdan gelen mesajları almak için kullanılan sunucuya bağlanmayı dene. Lütfen doğru bağlantıyı kullandığınızı kontrol edin veya irtibat kişinizden size başka bir bağlantı göndermesini isteyin. SimpleX arka planda çalışır.]]> Periyodik bildirimler @@ -2509,4 +2509,7 @@ Komutlar gönderebilmek için bağlanmanış olmanız gereklidir. Üye silinmiş - isteği kabul edemeyecek Grup linkini güncelle + Abonelik yok + Bu bağlantıdan mesaj almak için kullanılan sunucuya bağlı değilsiniz (abonelik yok). + SimpleX Relay Linki diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml index 5f729d4ea3..a67f00a459 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rCN/strings.xml @@ -2525,4 +2525,24 @@ 服务器地址证书和证书不匹配:%1$s。 无订阅 未连接到用于从该连接接收消息的服务器(无订阅)。 + 删除成员消息 + 删除成员消息吗? + 删除消息 + 成员消息将被删除 - 这无法撤销! + 移除并删除消息 + 所有消息 + 文件 + 筛选器 + 图片 + 链接 + 搜索文件 + 搜索图片 + 搜索链接 + 搜索视频 + 搜索语音消息 + 视频 + 语音消息 + 连接失败 + 失败 + 如果你加入了或创建了频道,它们会永远停止工作。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index f9a1a5b131..05997a9fec 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml @@ -1574,7 +1574,7 @@ 桌面設備 已連結桌面選項 已連結桌面 - 直接連線中 + 已請求連接 邀請 建立群組 修復群組成員不支援的問題 @@ -1677,7 +1677,7 @@ 連接中 錯誤 為群組停用回執? - 過往的成員 %1$s + 成員 %1$s 私密訊息路由 🚀 貼上封存連結 從桌面使用並掃描QR code。]]> @@ -1909,7 +1909,7 @@ 你分享了一個無效的檔案路徑。請將此問題報告給應用程式開發者。 如果沒有 Tor 或 VPN,你的 IP 位址將對以下 XFTP 中繼可見:\n%1$s。 檢視已崩潰 - 新增短連結 + 升級地址 接受了 %1$s 接受了你 新增團隊成員 @@ -2128,4 +2128,72 @@ 開啓新聊天 接受聯絡請求 接受聯絡請求 + 機械人 + 圖片 + 影片 + 檔案 + 連結 + 過濾器 + %d 個舉報 + 已棄用的選項 + 無訂閱 + 刪除訊息 + 搜尋圖片 + 搜尋影片 + 搜尋檔案 + 搜尋連結 + 語音訊息 + 所有訊息 + 重複加入請求? + 點擊以掃描 + 顯示內部錯誤 + 標準端對端加密 + 驗證資料庫密碼 + 顯示訊息狀態 + 設定預設主題 + 安全地接收檔案 + 臨時性檔案錯誤 + 重設所有統計 + 重設所有統計? + 有更新可用:%s + 略過此版本 + 更新下載已取消 + 儲存並重新連接 + 重設所有提示 + 自動升級應用程式 + 選擇聊天個人檔案 + 使用隨機憑證 + 儲存代理時發生錯誤 + 轉發訊息時發生錯誤 + 轉發 %1$s 條訊息? + 正在轉發 %1$s 條訊息 + 正在儲存 %1$s 條訊息 + 儲存伺服器時發生錯誤 + 接受條款時發生錯誤 + 公開地分享地址 + 用於社交媒體 + 用於訊息 + 用於私密路由 + 用於檔案 + 更新伺服器時發生錯誤 + 伺服器營運者已變更。 + 增加伺服器時發生錯誤 + 檢視已更新的條款 + 通知和電量 + 邀請加入聊天 + 使用 %s 開啟 + 沒有未讀聊天 + 找不到聊天 + 開啟以加入 + 開啟以連接 + 開啟以使用機械人 + 開啟以接受 + 搜尋或貼上 SimpleX 連結 + 你的每個訊息最多可以提及 %1$s 位成員! + 已透過代理傳送 + 伺服器統計資料將被重設—此操作無法撤銷! + 你沒有連接至這些伺服器。已使用私密路由將訊息傳送至這些伺服器。 + 不能在兩部裝置上使用同一資料庫。]]> + 警告:不支援在多個裝置上同時聊天,否則會導致訊息傳送失敗 + 或匯入封存檔案 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt index d3b8cdcb58..0f830e7b60 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Images.desktop.kt @@ -21,12 +21,33 @@ import kotlin.math.sqrt private fun errorBitmap(): ImageBitmap = ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode("iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg=="))).toComposeImageBitmap() +private val base64BitmapCache = Collections.synchronizedMap(object : LinkedHashMap(200, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry): Boolean = size > 200 +}) + +private const val MAX_IMAGE_DIMENSION = 4320 + actual fun base64ToBitmap(base64ImageString: String): ImageBitmap { + base64BitmapCache[base64ImageString]?.let { return it } val imageString = base64ImageString .removePrefix("data:image/png;base64,") .removePrefix("data:image/jpg;base64,") return try { - ImageIO.read(ByteArrayInputStream(Base64.getMimeDecoder().decode(imageString))).toComposeImageBitmap() + val bytes = Base64.getMimeDecoder().decode(imageString) + val stream = ImageIO.createImageInputStream(ByteArrayInputStream(bytes)) + val reader = ImageIO.getImageReaders(stream).next() + reader.setInput(stream) + val width = reader.getWidth(0) + val height = reader.getHeight(0) + if (width <= 0 || height <= 0 || width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION || height > width * 256) { + reader.dispose() + return errorBitmap() + } + val image = reader.read(0) + reader.dispose() + image.toComposeImageBitmap().also { + base64BitmapCache[base64ImageString] = it + } } catch (e: Throwable) { Log.e(TAG, "base64ToBitmap error: $e") errorBitmap() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 9f34891b37..59d71a83f1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -5,6 +5,7 @@ import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* +import uk.co.caprica.vlcj.factory.MediaPlayerFactory import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.player.base.State import uk.co.caprica.vlcj.player.component.AudioPlayerComponent @@ -12,20 +13,77 @@ import java.io.File import java.util.* import kotlin.math.max +internal val vlcFactory: MediaPlayerFactory by lazy { MediaPlayerFactory() } + actual class RecorderNative: RecorderInterface { + private var player: MediaPlayer? = null + private var progressJob: Job? = null + private var filePath: String? = null + private var recStartedAt: Long? = null + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { - /*LALAL*/ - return "" + VideoPlayerHolder.stopAll() + AudioPlayer.stop() + val fileToSave = File.createTempFile(generateNewFileName("voice", "${RecorderInterface.extension}_", tmpDir), ".tmp", tmpDir) + fileToSave.deleteOnExit() + val path = fileToSave.absolutePath + filePath = path + val mrl = when { + desktopPlatform.isMac() -> "qtsound://" + desktopPlatform.isLinux() -> "pulse://" + desktopPlatform.isWindows() -> "dshow://" + else -> { + AlertManager.shared.showAlertMsg(generalGetString(MR.strings.voice_recording_not_supported)) + return "" + } + } + val sout = ":sout=#transcode{vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000}:std{access=file,mux=mp4,dst=$path}" + val options = mutableListOf(sout, ":sout-avcodec-strict=-2") + if (desktopPlatform.isWindows()) { + options.add(":dshow-vdev=none") + options.add(":dshow-adev=") + } + RecorderInterface.stopRecording = { stop() } + progressJob = CoroutineScope(Dispatchers.Default).launch { + // Shared factory init may take a few seconds on first VLC use — progress shows 0 until recording starts + val p = vlcFactory.mediaPlayers().newMediaPlayer() + player = p + p.media().play(mrl, *options.toTypedArray()) + recStartedAt = System.currentTimeMillis() + while (isActive) { + val ms = progress() + onProgressUpdate(ms, false) + if (ms != null && ms >= MAX_VOICE_MILLIS_FOR_SENDING) { + stop() + break + } + delay(50) + } + }.apply { + invokeOnCompletion { onProgressUpdate(realDuration(path), true) } + } + return path } override fun stop(): Int { - /*LALAL*/ - return 0 + val path = filePath ?: return 0 + RecorderInterface.stopRecording = null + runCatching { player?.controls()?.stop() } + runCatching { player?.release() } + runBlocking { progressJob?.cancelAndJoin() } + progressJob = null + filePath = null + player = null + return (realDuration(path) ?: 0).also { recStartedAt = null } } + + private fun progress(): Int? = recStartedAt?.let { (System.currentTimeMillis() - it).toInt() } + + private fun realDuration(path: String): Int? = AudioPlayer.duration(path) ?: progress() } actual object AudioPlayer: AudioPlayerInterface { - private val player by lazy { AudioPlayerComponent().mediaPlayer() } + private val player by lazy { AudioPlayerComponent(vlcFactory).mediaPlayer() } override val currentlyPlaying: MutableState = mutableStateOf(null) private var progressJob: Job? = null @@ -170,7 +228,7 @@ actual object AudioPlayer: AudioPlayerInterface { override fun duration(unencryptedFilePath: String): Int? { var res: Int? = null try { - val helperPlayer = AudioPlayerComponent().mediaPlayer() + val helperPlayer = AudioPlayerComponent(vlcFactory).mediaPlayer() helperPlayer.media().startPaused(unencryptedFilePath) res = helperPlayer.duration helperPlayer.stop() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index 50eeaee604..c5a38ec4a1 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -10,6 +10,7 @@ import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent +import uk.co.caprica.vlcj.player.component.MediaPlayerSpecs import java.awt.Component import java.awt.image.BufferedImage import java.io.File @@ -32,7 +33,7 @@ actual class VideoPlayer actual constructor( override val duration: MutableState = mutableStateOf(defaultDuration) override val preview: MutableState = mutableStateOf(defaultPreview) - val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } } + val mediaPlayerComponent by lazy { getOrCreatePlayer() } val player by lazy { mediaPlayerComponent.mediaPlayer() } init { @@ -207,9 +208,9 @@ actual class VideoPlayer actual constructor( private fun initializeMediaPlayerComponent(): Component { return if (desktopPlatform.isMac()) { - CallbackMediaPlayerComponent() + CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) } else { - EmbeddedMediaPlayerComponent() + EmbeddedMediaPlayerComponent(MediaPlayerSpecs.embeddedMediaPlayerSpec().apply { withFactory(vlcFactory) }) } } @@ -277,7 +278,7 @@ actual class VideoPlayer actual constructor( private fun putPlayer(player: Component) = playersPool.add(player) - private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent() + private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent(MediaPlayerSpecs.callbackMediaPlayerSpec().apply { withFactory(vlcFactory) }) private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 9be10a584b..ed2f6e7859 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -23,6 +23,7 @@ private const val SERVER_HOST = "localhost" private const val SERVER_PORT = 50395 val connections = ArrayList() +// Spec: spec/services/calls.md#ActiveCallView @Composable actual fun ActiveCallView() { val scope = rememberCoroutineScope() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index 38054cb873..b4a24e3572 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* @@ -17,7 +18,7 @@ actual fun SimpleAndAnimatedImageView( ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { // LALAL make it animated too - ImageView(imageBitmap.toAwtImage().toPainter()) { + ImageView(BitmapPainter(imageBitmap)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, close) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index d541a5780e..8d69607c62 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.delay import java.io.ByteArrayInputStream import java.io.File import java.net.URI +import java.util.* import javax.imageio.ImageIO import kotlin.io.encoding.Base64 import kotlin.io.encoding.ExperimentalEncodingApi @@ -128,6 +129,14 @@ actual fun getAppFileUri(fileName: String): URI { } } +private val loadedImageCache = Collections.synchronizedMap(object : LinkedHashMap>(30, 0.75f, true) { + override fun removeEldestEntry(eldest: Map.Entry>): Boolean = size > 30 +}) + +actual fun clearImageCaches() { + loadedImageCache.clear() +} + actual suspend fun getLoadedImage(file: CIFile?): Pair? { var filePath = getLoadedFilePath(file) if (chatModel.connectedToRemote() && filePath == null) { @@ -135,10 +144,10 @@ actual suspend fun getLoadedImage(file: CIFile?): Pair? filePath = getLoadedFilePath(file) } return if (filePath != null) { - try { + loadedImageCache[filePath] ?: try { val data = if (file?.fileSource?.cryptoArgs != null) readCryptoFile(filePath, file.fileSource.cryptoArgs) else File(filePath).readBytes() val bitmap = getBitmapFromByteArray(data, false) - if (bitmap != null) bitmap to data else null + if (bitmap != null) (bitmap to data).also { loadedImageCache[filePath] = it } else null } catch (e: Exception) { Log.e(TAG, "Unable to read crypto file: " + e.stackTraceToString()) null diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index 1e7bda37c4..8f072539e8 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -73,6 +73,12 @@ compose { iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.icns")) appCategory = "public.app-category.social-networking" bundleID = "chat.simplex.app" + infoPlist { + extraKeysRawXml = """ + NSMicrophoneUsageDescription + SimpleX needs microphone access to record voice messages + """ + } val identity = rootProject.extra["desktop.mac.signing.identity"] as String? val keychain = rootProject.extra["desktop.mac.signing.keychain"] as String? val appleId = rootProject.extra["desktop.mac.notarization.apple_id"] as String? diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 1354ce0cf3..3446ef9c30 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,13 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=6.5-beta.5 -android.version_code=335 +android.version_name=6.5-beta.8 +android.version_code=340 android.bundle=false -desktop.version_name=6.5-beta.5 -desktop.version_code=131 +desktop.version_name=6.5-beta.8 +desktop.version_code=135 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/product/README.md b/apps/multiplatform/product/README.md new file mode 100644 index 0000000000..173def8ae7 --- /dev/null +++ b/apps/multiplatform/product/README.md @@ -0,0 +1,396 @@ +# SimpleX Chat Android & Desktop -- Product Overview + +> SimpleX Chat multiplatform product specification (Android + Desktop). 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. [Executive Summary](#executive-summary) +2. [Vision](#vision) +3. [Target Users](#target-users) +4. [Capability Map](#capability-map) +5. [Navigation Map](#navigation-map) +6. [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 Android and Desktop apps share a single **Kotlin Multiplatform + Compose Multiplatform** codebase. Common UI and business logic lives in a shared `common/` module, while platform-specific behavior (notifications, audio, video playback, file system access, call management) is abstracted through the Kotlin `expect`/`actual` pattern and a runtime `PlatformInterface` delegate. The Haskell core library is loaded via **JNI** (`external fun` declarations in `Core.kt`), exposing the full SimpleX Chat API (message send/receive, encryption, migration, file handling) through native FFI. + +Key platform differences: + +- **Android** uses a 2-column layout (`AndroidScreen`): chat list slides to chat view. Background messaging is handled by `SimplexService` (foreground service) + `MessagesFetcherWorker` (WorkManager periodic fetch). Calls use a dedicated `CallService` + `CallActivity`. +- **Desktop** uses a 3-column layout (`DesktopScreen`): chat list (start) | chat view (center) | detail panel (`ModalManager.end`). It includes `AppUpdater` for in-app update checking, `StoreWindowState` for window geometry persistence, and VLC-based video playback. Calls use browser-based WebRTC rendered inline. + +--- + +## 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 +- **Desktop users** wanting a native desktop client with the same privacy guarantees as the mobile app + +--- + +## Capability Map + +All source paths below are relative to `apps/multiplatform/`. The common source root is `common/src/commonMain/kotlin/chat/simplex/common/`. + +### 1. Messaging + +Core message composition, delivery, and interaction features. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Text with markdown | Rich text formatting with SimpleX markdown syntax | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Images | Compressed inline images with full-screen gallery | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt` | +| Video | Video message recording and playback | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVideoView.kt` | +| Voice messages | Audio recording and playback (5min / 510KB limit) | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt` | +| File sharing | Files up to 1GB via XFTP protocol | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt` | +| Link previews | OpenGraph metadata extraction and display | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/LinkPreviews.kt` | +| Message reactions | Emoji reactions on sent/received messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt` | +| Message editing | Edit previously sent messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Message deletion | Broadcast delete (for recipient) or internal-only delete | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt` | +| Timed messages | Self-destructing messages with configurable TTL | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt` | +| Quoted replies | Reply to specific messages with quote context | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt` | +| Forwarding | Forward messages between chats | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt` | +| Search | Full-text search within conversations | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` | +| Message reports | Report messages to group moderators | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupReportsView.kt` | +| Send message bar | Composable message input with attachments, voice, send button | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` | + +### 2. Contacts + +Establishing, managing, and verifying contacts. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Add via SimpleX address | Connect using a SimpleX contact address | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt` | +| Add via QR code | Scan QR code to establish connection | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ScanCodeView.kt` | +| Contact requests | Accept or reject incoming contact requests | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ContactRequestView.kt` | +| Local aliases | Set private display names for contacts | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Contact verification | Compare security codes out-of-band | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/VerifyCodeView.kt` | +| Blocking | Block contacts from sending messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt` | +| Incognito mode | Per-contact random profile generation | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Bot detection | Identify automated/bot contacts | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Contact list | Dedicated contact browsing view | `common/src/commonMain/kotlin/chat/simplex/common/views/contacts/ContactListNavView.kt` | + +### 3. Groups + +Multi-party encrypted conversations with role-based management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Create groups | Create new group with initial members | `common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddGroupView.kt` | +| Invite members | Invite by individual contact or link | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt` | +| Member roles | Owner, admin, moderator, member, observer | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Member admission | Queue-based admission with review workflow | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberAdmission.kt` | +| Group links | Shareable invite links for groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt` | +| Business chat mode | Structured business communication groups | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt` | +| Content moderation | Member reports and moderator actions | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt` | +| Group preferences | Configure group-level feature settings | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt` | +| Member direct contacts | Establish direct chats from group membership | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt` | +| Group mentions | @-mention members in group messages | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMentions.kt` | +| Welcome message | Custom welcome message for new group members | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/WelcomeMessageView.kt` | +| Group profile | Edit group name, image, description | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupProfileView.kt` | +| Member support chat | Scoped support threads between members and admins | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt` | + +### 4. Calling + +End-to-end encrypted audio and video communication. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encrypted calls | Audio/video calls via WebRTC with E2E encryption | `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` | +| Call manager | Call state machine and lifecycle management | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` | +| Call history | Call events displayed as chat items | `common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt` | +| Incoming call view | Dedicated UI for incoming call notifications | `common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt` | +| Android CallService | Foreground service for active calls on Android | `android/src/main/java/chat/simplex/app/CallService.kt` | +| Android CallActivity | Dedicated Activity for call UI on Android | `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | +| Desktop inline calls | Browser-based WebRTC rendered inline in desktop window | `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt` | + +### 5. Privacy & Security + +Encryption, authentication, and privacy controls. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| E2E encryption | Double-ratchet encryption for all messages | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Post-quantum encryption | Optional PQ key exchange for direct chats | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| Local authentication | Biometric (fingerprint/face) or app passcode lock | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/LocalAuthView.kt` | +| Passcode entry | Custom numeric/alphanumeric passcode UI | `common/src/commonMain/kotlin/chat/simplex/common/views/localauth/PasscodeView.kt` | +| Hidden profiles | Password-protected profiles invisible in UI | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt` | +| Database encryption | AES encryption of local SQLite database | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Screen privacy | Blur/hide app content when in app switcher | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt` | +| Encrypted file storage | Local files encrypted at rest | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Delivery receipts control | Toggle delivery/read receipts per contact/group | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SetDeliveryReceiptsView.kt` | +| App lock | Automatic lock on background/timeout with configurable delay | `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` | + +### 6. User Management + +Multiple profiles and identity management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Multiple profiles | Multiple user profiles within one app | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt` | +| Active user switching | Switch between profiles via user picker | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| Incognito contacts | Per-contact random identities | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/IncognitoView.kt` | +| Profile sharing | Share profile via contact address link | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt` | +| User muting | Mute notifications for specific profiles | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt` | +| User profile editing | Edit display name and profile image | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfileView.kt` | + +### 7. Network + +Server configuration, proxy support, and connectivity. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Custom SMP servers | Configure personal SMP relay servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Custom XFTP servers | Configure personal XFTP file servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/ProtocolServersView.kt` | +| Tor/onion support | Route traffic through Tor .onion addresses | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| SOCKS5 proxy | Route connections through SOCKS5 proxy | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Custom ICE servers | Configure WebRTC ICE/TURN servers | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/RTCServers.kt` | +| Network timeouts | Configure connection timeout parameters | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| Server operators | Configure and manage SMP/XFTP server operators | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/OperatorView.kt` | +| Server status | View aggregate server connectivity status | `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt` | +| Network & servers hub | Central network configuration entry point | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/NetworkAndServers.kt` | + +### 8. Customization + +Visual appearance and UI preferences. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Themes | Light, dark, SimpleX, black, and custom themes | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt` | +| Wallpapers | Preset and custom chat wallpapers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ChatWallpaper.kt` | +| Chat bubble styling | Customize message bubble appearance | `common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt` | +| One-handed UI mode | Compact layout for single-hand use (Android) | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Language selection | In-app language override | `common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt` | +| Theme mode editor | Interactive theme color and mode customization | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt` | + +### 9. Data Management + +Import, export, encryption, and storage management. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| Export/import profiles | Full database export and import | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt` | +| Database encryption | Encrypt/decrypt local database with passphrase | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt` | +| Local file encryption | Encrypt stored media and attachments | `common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt` | +| Database error handling | Recovery UI for database migration failures | `common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseErrorView.kt` | +| Device-to-device migration | Migrate full profile between devices | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt` | +| Receive migration | Accept incoming device migration transfer | `common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt` | +| Database utilities | Key storage, password management, helper functions | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | + +### 10. Desktop Features + +Desktop-specific functionality not present on Android. + +| Feature | Description | Key Source (Kotlin) | +|---------|-------------|---------------------| +| 3-column layout | Start (chat list) / center (chat) / end (detail) panels | `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (`DesktopScreen`) | +| ModalManager.end | Third-column detail panel for settings/info views | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (`ModalManager`) | +| App update checker | In-app notification for available updates | `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | +| Window state persistence | Save/restore window position and dimensions | `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | +| VLC video playback | Desktop video playback via VLC native libraries | `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | +| Desktop app entry | Main function, Haskell init, VLC loading | `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | +| Desktop notification manager | Platform-native desktop notifications | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Notifications.desktop.kt` | +| Connect mobile device | Pair desktop with a mobile device for remote access | `common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt` | +| Desktop platform abstraction | Desktop-specific PlatformInterface implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt` | +| Desktop app shell | Compose Desktop window, theming, lifecycle | `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | + +--- + +## Navigation Map + +### Android Navigation (2-column slide) + +``` +Onboarding + views/onboarding/SimpleXInfo.kt + -> SimpleXInfo -> CreateFirstProfile -> SetupDatabasePassphrase + -> ChooseServerOperators -> SetNotificationsMode + -> ChatListView (home) + +ChatListView (home) + views/chatlist/ChatListView.kt + -> ChatView .................. (tap conversation row, slides in) + -> NewChatSheet .............. (+ FAB button) + -> SettingsView .............. (gear icon) + -> UserPicker ................ (avatar tap) + -> TagListView ............... (tag filter bar) + -> ServersSummaryView ........ (server status indicator) + -> ShareListView ............. (share intent from external apps) + -> ChatHelpView .............. (empty state help) + +ChatView + views/chat/ChatView.kt + -> ChatInfoView .............. (contact name tap, direct chat) + -> GroupChatInfoView ......... (group name tap, group chat) + -> ActiveCallView ............ (call button, launches CallActivity) + -> ComposeView ............... (message input area) + -> ChatItemInfoView .......... (long press -> info) + -> MemberSupportChatView ..... (member support thread) + -> ScanCodeView .............. (scan QR) + -> CommandsMenuView .......... (/ commands) + +ChatInfoView + views/chat/ChatInfoView.kt + -> ContactPreferences ........ (preferences) + -> VerifyCodeView ............ (verify security code) + +GroupChatInfoView + views/chat/group/GroupChatInfoView.kt + -> GroupProfileView .......... (edit profile) + -> AddGroupMembersView ....... (invite members) + -> GroupLinkView ............. (manage group link) + -> MemberAdmission ........... (admission settings) + -> GroupPreferences .......... (group feature settings) + -> GroupMemberInfoView ....... (tap member) + -> WelcomeMessageView ........ (welcome message) + -> GroupReportsView .......... (view reports) + +NewChatSheet + views/newchat/NewChatSheet.kt + -> NewChatView ............... (QR scanner / paste link) + -> AddGroupView .............. (create group) + -> UserAddressView ........... (create SimpleX address) + +SettingsView + views/usersettings/SettingsView.kt + -> AppearanceView ............ (themes, wallpapers, UI) + -> NetworkAndServers ......... (SMP/XFTP/proxy config) + -> PrivacySettings ........... (privacy toggles) + -> NotificationsSettingsView . (notification mode) + -> DatabaseView .............. (export/import/encrypt) + -> CallSettings .............. (call preferences) + -> VersionInfoView ........... (about/version) + -> DeveloperView ............. (developer options) + -> HelpView .................. (help & support) + +UserPicker + views/chatlist/UserPicker.kt + -> UserProfilesView .......... (manage all profiles) + -> UserAddressView ........... (SimpleX address) + -> Preferences ............... (user preferences) + -> SettingsView .............. (app settings) + -> ConnectDesktopView ........ (pair with desktop) +``` + +### Desktop Navigation (3-column panels) + +``` ++---------------------------+----------------------------------+----------------------------+ +| START PANEL | CENTER PANEL | END PANEL | +| (DEFAULT_START_MODAL_ | (flexible width, min | (DEFAULT_END_MODAL_ | +| WIDTH) | DEFAULT_MIN_CENTER_MODAL_ | WIDTH) | +| | WIDTH) | | ++---------------------------+----------------------------------+----------------------------+ +| | | | +| ChatListView | ChatView | ChatInfoView | +| - chat rows | - message list | GroupChatInfoView | +| - search | - ComposeView | GroupMemberInfoView | +| - tag filters | - media viewer | ContactPreferences | +| - server status | | GroupPreferences | +| | OR (when no chat selected): | GroupProfileView | +| UserPicker (overlay) | "No selected chat" | AddGroupMembersView | +| - profile switcher | | MemberAdmission | +| - quick settings | OR (when modal open): | VerifyCodeView | +| | ModalManager.center content | SettingsView subtabs | +| ModalManager.start | (settings, new chat, etc.) | | +| - secondary modals | | ModalManager.end | +| | | - detail modals | ++---------------------------+----------------------------------+----------------------------+ + +ModalManager Placement (Desktop): + - ModalManager.start -> left panel overlay (settings subviews) + - ModalManager.center -> center panel (replaces chat, used when chatId is null) + - ModalManager.end -> right panel (detail/info views) + - ModalManager.fullscreen -> full window overlay (onboarding, auth, call) + +On Android, all ModalManager instances (start/center/end/fullscreen) collapse to a +single shared ModalManager that presents modals as full-screen overlays. + +Desktop-only navigation targets: + ConnectMobileView ......... (pair with mobile device) + AppUpdater notice ......... (update available notification) + Floating terminal ......... (developer console) + ActiveCallView ............ (inline WebRTC call, not separate Activity) +``` + +--- + +## Platform Abstraction + +The codebase uses two mechanisms for platform-specific behavior: + +### 1. `expect`/`actual` Declarations + +Kotlin Multiplatform `expect` declarations in `common/src/commonMain/kotlin/chat/simplex/common/platform/` with corresponding `actual` implementations in: +- `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` + +Key `expect`/`actual` abstractions: `appPlatform`, `BackHandler`, `VideoPlayer`, `AudioPlayer`, `RecorderNative`, `NtfManager`, `showToast`, `getKeyboardState`, `PlatformTextField`, image processing, file sharing, and more. + +### 2. Runtime `PlatformInterface` + +Defined in `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`, this interface provides platform-specific callbacks that cannot use `expect`/`actual` (because `android/` module code cannot be called from `common/androidMain/`). The `platform` variable is reassigned at app startup: +- **Android:** `SimplexApp` sets `platform` to an implementation with `CallService`, notification channels, orientation locking, status bar theming, and PiP support. +- **Desktop:** `Main.kt` sets `platform` to an implementation with `desktopShowAppUpdateNotice()`. + +### 3. Haskell Core (JNI/FFI) + +Native FFI bindings are declared in `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` as `external fun` declarations. These include: `chatMigrateInit`, `chatSendCmdRetry`, `chatRecvMsg`, `chatParseMarkdown`, `chatPasswordHash`, `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`, and more. The native library (`libapp-lib`) is loaded at startup from platform-specific resource directories. + +--- + +## Background Messaging (Android) + +Android has no equivalent to iOS NSE (Notification Service Extension). Instead, it uses: + +- **`SimplexService`** (`android/src/main/java/chat/simplex/app/SimplexService.kt`) -- A foreground service that keeps the Haskell core running to receive messages in real-time. Displays a persistent notification while active. +- **`MessagesFetcherWorker`** (`android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt`) -- A WorkManager-based periodic task that wakes the app at configurable intervals to fetch messages when the foreground service is not running (battery-optimized mode). +- **Notification modes:** Instant (foreground service always running), Periodic (WorkManager fetch every N minutes), Off. + +--- + +## Related Specifications + +### Product Layer (this directory) + +- [concepts.md](concepts.md) -- Feature concept index with bidirectional code links +- [glossary.md](glossary.md) -- Terminology definitions +- [rules.md](rules.md) -- Business rules and constraints +- [gaps.md](gaps.md) -- Known documentation gaps +- Views: [chat-list](views/chat-list.md), [chat](views/chat.md), [new-chat](views/new-chat.md), [settings](views/settings.md), [call](views/call.md), [contact-info](views/contact-info.md), [group-info](views/group-info.md), [onboarding](views/onboarding.md), [user-profiles](views/user-profiles.md) +- Flows: [messaging](flows/messaging.md), [calling](flows/calling.md), [onboarding](flows/onboarding.md), [group-lifecycle](flows/group-lifecycle.md), [connection](flows/connection.md), [file-transfer](flows/file-transfer.md) + +### Spec Layer + +- [spec/README.md](../spec/README.md) -- Technical specification overview +- [spec/architecture.md](../spec/architecture.md) -- JNI bridge, startup, lifecycle +- [spec/state.md](../spec/state.md) -- ChatModel, ChatsContext, Chat, AppPreferences +- [spec/api.md](../spec/api.md) -- Command/response protocol (CC, CR, ChatError) +- [spec/database.md](../spec/database.md) -- Migration, encryption, export/import +- Client: [navigation](../spec/client/navigation.md), [chat-list](../spec/client/chat-list.md), [chat-view](../spec/client/chat-view.md), [compose](../spec/client/compose.md) +- Services: [calls](../spec/services/calls.md), [theme](../spec/services/theme.md), [files](../spec/services/files.md), [notifications](../spec/services/notifications.md) + +### Source Entry Points + +- Haskell core: `../../src/Simplex/Chat/Controller.hs`, `../../src/Simplex/Chat/Types.hs` +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Android entry: `android/src/main/java/chat/simplex/app/SimplexApp.kt`, `MainActivity.kt` +- Desktop entry: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md new file mode 100644 index 0000000000..da33bf11d7 --- /dev/null +++ b/apps/multiplatform/product/concepts.md @@ -0,0 +1,120 @@ +# SimpleX Chat Android & Desktop -- Concept Index + +> SimpleX Chat multiplatform concept index. Maps every product concept to its documentation and source code with bidirectional links. +> +> **Related spec:** [spec/README.md](../spec/README.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 Kotlin multiplatform layer and the Haskell core library. All Kotlin source paths are relative to `apps/multiplatform/`. Haskell paths use `../../src/` prefix (relative to `apps/multiplatform/`). The common source root abbreviation used below is `common/src/commonMain/kotlin/chat/simplex/common/`. + +--- + +## Section 1: Feature Concepts + +| # | Concept | Product Docs | Spec Docs | Source Files (Kotlin) | Source Files (Haskell) | +|---|---------|-------------|-----------|----------------------|----------------------| +| PC1 | Chat List | [README.md](README.md) (Navigation Map) | [spec/client/chat-list.md](../spec/client/chat-list.md) | `common/.../views/chatlist/ChatListView.kt`, `ChatListNavLinkView.kt`, `ChatPreviewView.kt` | `Controller.hs` (`APIGetChats`) | +| PC2 | Direct Chat | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `ChatInfoView.kt` | `Types.hs` (`Contact`), `Messages.hs` | +| PC3 | Group Chat | [README.md](README.md) (Groups) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/chat/ChatView.kt`, `group/GroupChatInfoView.kt` | `Types.hs` (`GroupInfo`, `GroupMember`) | +| PC4 | Message Composition | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `SendMsgView.kt`, `ComposeVoiceView.kt`, `ComposeImageView.kt`, `ComposeFileView.kt` | `Controller.hs` (`APISendMessages`) | +| PC5 | Message Reactions | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/ChatItemView.kt` (ChatItemReactions composable) | `Controller.hs` (`APIChatItemReaction`) | +| PC6 | Message Editing | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/ComposeView.kt`, `ChatItemInfoView.kt` | `Controller.hs` (`APIUpdateChatItem`) | +| PC7 | Message Deletion | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/MarkedDeletedItemView.kt`, `DeletedItemView.kt` | `Controller.hs` (`APIDeleteChatItem`) | +| PC8 | Timed Messages | [README.md](README.md) (Messaging) | [spec/api.md](../spec/api.md) | `common/.../views/chat/item/CIChatFeatureView.kt` | `Types/Preferences.hs` (`TimedMessagesPreference`) | +| PC9 | Voice Messages | [README.md](README.md) (Messaging) | [spec/client/compose.md](../spec/client/compose.md) | `common/.../views/chat/item/CIVoiceView.kt`, `ComposeVoiceView.kt`, `platform/RecAndPlay.kt` | `Protocol.hs` (`MCVoice`) | +| PC10 | File Transfer | [README.md](README.md) (Messaging, Data Management) | [spec/services/files.md](../spec/services/files.md) | `common/.../views/chat/item/CIFileView.kt`, `platform/Files.kt` | `Files.hs`, `Store/Files.hs` | +| PC11 | Link Previews | [README.md](README.md) (Messaging) | [spec/client/chat-view.md](../spec/client/chat-view.md) | `common/.../views/helpers/LinkPreviews.kt` | `Protocol.hs` (`MCLink`) | +| PC12 | Contact Connection | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/NewChatView.kt`, `QRCode.kt`, `QRCodeScanner.kt`, `ConnectPlan.kt` | `Controller.hs` (`APIConnect`, `APIAddContact`) | +| PC13 | Contact Verification | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/chat/VerifyCodeView.kt` | `Controller.hs` (`APIVerifyContact`) | +| PC14 | Group Management | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/newchat/AddGroupView.kt`, `group/GroupChatInfoView.kt`, `group/GroupProfileView.kt` | `Controller.hs` (`APINewGroup`), `Store/Groups.hs` | +| PC15 | Group Links | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/GroupLinkView.kt` | `Controller.hs` (`APICreateGroupLink`) | +| PC16 | Member Roles | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../model/ChatModel.kt`, `group/GroupMemberInfoView.kt` | `Types/Shared.hs` (`GroupMemberRole`) | +| PC17 | Audio/Video Calls | [README.md](README.md) (Calling) | [spec/services/calls.md](../spec/services/calls.md) | `common/.../views/call/CallView.kt`, `CallManager.kt`, `WebRTC.kt`, `android/.../CallService.kt`, `android/.../views/call/CallActivity.kt` | `Call.hs` (`RcvCallInvitation`, `CallType`) | +| PC18 | Notifications | [README.md](README.md) (Background Messaging) | [spec/services/notifications.md](../spec/services/notifications.md) | `common/.../platform/NtfManager.kt`, `Notifications.kt`, `android/.../SimplexService.kt`, `android/.../MessagesFetcherWorker.kt`, `common/.../views/usersettings/NotificationsSettingsView.kt` | `Controller.hs` | +| PC19 | User Profiles | [README.md](README.md) (User Management) | [spec/state.md](../spec/state.md) | `common/.../views/usersettings/UserProfilesView.kt`, `UserProfileView.kt`, `views/chatlist/UserPicker.kt` | `Types.hs` (`User`), `Store/Profiles.hs` | +| PC20 | Incognito Mode | [README.md](README.md) (Contacts) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/IncognitoView.kt` | `ProfileGenerator.hs`, `Types.hs` | +| PC21 | Hidden Profiles | [README.md](README.md) (Privacy & Security) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/HiddenProfileView.kt` | `Controller.hs` (`APIHideUser`, `APIUnhideUser`) | +| PC22 | Local Authentication | [README.md](README.md) (Privacy & Security) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/localauth/LocalAuthView.kt`, `PasscodeView.kt`, `SetAppPasscodeView.kt`, `PasswordEntry.kt`, `AppLock.kt` | N/A (client-only) | +| PC23 | Database Encryption | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/database/DatabaseEncryptionView.kt`, `DatabaseView.kt`, `views/helpers/DatabaseUtils.kt` | `Controller.hs` (`APIExportArchive`) | +| PC24 | Theme System | [README.md](README.md) (Customization) | [spec/services/theme.md](../spec/services/theme.md) | `common/.../ui/theme/ThemeManager.kt`, `Theme.kt`, `Color.kt`, `Type.kt`, `Shape.kt` | `Types/UITheme.hs` | +| PC25 | Network Configuration | [README.md](README.md) (Network) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/usersettings/networkAndServers/NetworkAndServers.kt`, `ProtocolServersView.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` | `Controller.hs` (`APISetNetworkConfig`) | +| PC26 | Device Migration | [README.md](README.md) (Data Management) | [spec/database.md](../spec/database.md) | `common/.../views/migration/MigrateFromDevice.kt`, `MigrateToDevice.kt` | `Archive.hs` | +| PC27 | Remote Desktop | [README.md](README.md) (Desktop Features) | [spec/architecture.md](../spec/architecture.md) | `common/.../views/remote/ConnectDesktopView.kt`, `ConnectMobileView.kt` | `Remote.hs`, `Remote/Types.hs` | +| PC28 | Chat Tags | [README.md](README.md) (Navigation Map) | [spec/state.md](../spec/state.md) | `common/.../views/chatlist/TagListView.kt`, `ChatListView.kt` | `Types.hs` (`ChatTag`), `Controller.hs` | +| PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | +| PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | + +**Legend for abbreviated paths:** +- `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` +- `android/.../` expands to `android/src/main/java/chat/simplex/app/` +- Haskell files are in `../../src/Simplex/Chat/` (relative to `apps/multiplatform/`) + +--- + +## 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.activeCallInvitation` | `CallManager.kt`, `IncomingCallAlertView.kt` | Updated on call accept/reject in `CallManager.kt` | Removed on call end/reject; `Controller.hs` | + +--- + +## Platform-Specific Source Index + +Key files that exist only on one platform, grouped by concern. + +### Android-Only + +| File | Purpose | +|------|---------| +| `android/src/main/java/chat/simplex/app/SimplexApp.kt` | Application subclass, PlatformInterface setup, Haskell init | +| `android/src/main/java/chat/simplex/app/MainActivity.kt` | Main Activity, deep link handling, lifecycle | +| `android/src/main/java/chat/simplex/app/SimplexService.kt` | Foreground service for persistent messaging | +| `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` | WorkManager periodic message fetch | +| `android/src/main/java/chat/simplex/app/CallService.kt` | Foreground service for active calls | +| `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` | Dedicated Activity for call UI | +| `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` | Android notification channels and manager | +| `common/src/androidMain/kotlin/chat/simplex/common/platform/*.android.kt` | All `actual` implementations for Android | + +### Desktop-Only + +| File | Purpose | +|------|---------| +| `desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt` | JVM entry point, Haskell/VLC init, PlatformInterface setup | +| `common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Compose Desktop window creation and lifecycle | +| `common/src/desktopMain/kotlin/chat/simplex/common/StoreWindowState.kt` | Window position/size persistence | +| `common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/AppUpdater.kt` | In-app update checker | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt` | VLC-based video detection | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt` | VLC video player implementation | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/Platform.desktop.kt` | Desktop platform detection (Linux/macOS/Windows) | +| `common/src/desktopMain/kotlin/chat/simplex/common/platform/*.desktop.kt` | All `actual` implementations for Desktop | + +--- + +## Cross-References + +- Product overview: [README.md](README.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`) +- Kotlin model: `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` +- Kotlin API bridge: `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` +- Kotlin FFI layer: `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` +- Platform abstraction: `common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt` (`PlatformInterface`) diff --git a/apps/multiplatform/product/flows/calling.md b/apps/multiplatform/product/flows/calling.md new file mode 100644 index 0000000000..fae7f42031 --- /dev/null +++ b/apps/multiplatform/product/flows/calling.md @@ -0,0 +1,220 @@ +# Calling Flow + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Overview + +SimpleX Chat supports audio and video calls using WebRTC, with signaling delivered over the existing SMP messaging channels. Calls are end-to-end encrypted with an additional shared key layer on top of WebRTC's SRTP encryption. + +The architecture differs by platform: +- **Android**: Calls run in a dedicated `CallActivity` (separate from `MainActivity`) with a `WebView` hosting the WebRTC JavaScript. A foreground `CallService` keeps the process alive and shows a persistent notification. +- **Desktop**: Calls open the system browser pointed at a local NanoHTTPD/NanoWSD embedded server on `localhost:50395`, which serves the WebRTC HTML/JS and communicates with the app via WebSocket. + +Both platforms share a common signaling flow through the Haskell core API. + +## Prerequisites + +- Both parties must have an established direct contact connection. +- Microphone permission is required; camera permission is required for video calls. +- On Android, the `CallOnLockScreen` preference controls lock-screen call behavior: `DISABLE`, `SHOW`, or `ACCEPT`. + +--- + +## 1. Outgoing Call (Caller Side) + +### 1.1 Initiate Call + +1. User taps the audio or video call button in `ChatView`. +2. `startChatCall(remoteHostId, chatInfo, media)` is called (in `ChatView.kt`). +3. A `Call` object is created with `callState = CallState.WaitCapabilities`: + ```kotlin + Call( + remoteHostId = remoteHostId, + contact = contact, + callUUID = null, + callState = CallState.WaitCapabilities, + initialCallType = media, // Audio or Video + userProfile = profile, + androidCallState = platform.androidCreateActiveCallState() + ) + ``` +4. `ChatModel.activeCall` is set and `ChatModel.showCallView` is set to `true`. +5. A `WCallCommand.Capabilities(media)` command is added to `ChatModel.callCommand`. + +### 1.2 WebRTC Capabilities Response + +1. The WebRTC engine (WebView on Android, browser on Desktop) receives the `Capabilities` command. +2. It responds with `WCallResponse.Capabilities(capabilities)` containing encryption support info. +3. The app calls `ChatController.apiSendCallInvitation(rh, contact, callType)` to send the invitation via SMP. +4. Call state transitions to `CallState.InvitationSent`. +5. A connecting sound starts playing via `CallSoundsPlayer.startConnectingCallSound`. + +### 1.3 Offer Exchange + +1. When the callee accepts, the WebRTC engine generates an offer. +2. `WCallResponse.Offer(offer, iceCandidates, capabilities)` is received. +3. `ChatController.apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` sends it. +4. Call state transitions to `CallState.OfferSent`. + +### 1.4 Answer and Connection + +1. The callee's answer arrives via SMP as a chat event. +2. The app dispatches `WCallCommand.Answer(answer, iceCandidates)` to the WebRTC engine. +3. Call state transitions to `CallState.Negotiated`, then to `CallState.Connected` once the ICE connection succeeds. +4. `Call.connectedAt` is set to the current timestamp. + +--- + +## 2. Incoming Call (Callee Side) + +### 2.1 Receive Invitation + +1. An incoming call event arrives from the core as `CR.CallInvitation`. +2. `CallManager.reportNewIncomingCall(invitation)` is called. +3. A `RcvCallInvitation` is stored in `ChatModel.callInvitations` keyed by contact ID. +4. If the invitation is recent (within 3 minutes), a system notification is shown and `ChatModel.activeCallInvitation` is set. +5. On Android, `CallActivity` may be launched on the lock screen if `callOnLockScreen` is `SHOW` or `ACCEPT`. + +### 2.2 Accept Call + +1. User taps "Accept" on the `IncomingCallAlertView` or lock-screen alert. +2. `CallManager.acceptIncomingCall(invitation)` is called. +3. If another call is active, it is ended first (with `switchingCall` flag set). +4. A new `Call` is created with `callState = CallState.InvitationAccepted`. +5. ICE servers are loaded from preferences (`getIceServers()`). +6. `WCallCommand.Start(media, aesKey, iceServers, relay)` is dispatched to the WebRTC engine. +7. The call invitation is removed from `callInvitations` and the notification is cancelled. + +### 2.3 Reject Call + +1. User taps "Reject" or the invitation times out. +2. `CallManager.endCall(invitation)` is called. +3. `ChatController.apiRejectCall(rh, contact)` notifies the caller. +4. The invitation is removed from `callInvitations`. + +--- + +## 3. Call State Machine + +``` +Outgoing: WaitCapabilities -> InvitationSent -> OfferSent -> AnswerReceived -> Negotiated -> Connected -> Ended +Incoming: InvitationAccepted -> OfferReceived -> Negotiated -> Connected -> Ended +``` + +| State | Description | +|-------|-------------| +| `WaitCapabilities` | Querying local WebRTC capabilities | +| `InvitationSent` | Caller sent invitation via SMP | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | Caller sent SDP offer | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | Media flowing | +| `Ended` | Call terminated | + +--- + +## 4. Ending a Call + +1. User taps the end-call button, or the remote side ends the call. +2. `CallManager.endCall(call)` is called. +3. `ChatController.apiEndCall(rh, contact)` notifies the remote side via SMP. +4. `ChatModel.showCallView` is set to `false`. +5. `ChatModel.activeCall` is set to `null`. +6. On Android, `CallService` is stopped and the `WebView` is destroyed. +7. On Desktop, `WCallCommand.End` is sent to the browser via WebSocket, and the NanoWSD server is stopped. + +--- + +## 5. Android-Specific: CallActivity and CallService + +### 5.1 CallActivity + +- `CallActivity` is a separate `ComponentActivity` (not `MainActivity`). +- It is launched via `platform.androidStartCallActivity(acceptCall, remoteHostId, chatId)`. +- It hosts `ActiveCallView` with a `WebView` for WebRTC. +- Supports lock-screen display: `setShowWhenLocked(true)` and `setTurnScreenOn(true)`. +- Supports Picture-in-Picture (PiP) mode for video calls. + - On Android 12+, PiP auto-enters when the user navigates away. + - On older versions, PiP is entered via `enterPictureInPictureMode()` on `onUserLeaveHint`. + - PiP layout switches to `LayoutType.RemoteVideo` to show only the remote video feed. +- The activity finishes itself when both `invitation == null` and (`!showCallView || call == null`) and `!switchingCall`. + +### 5.2 CallService + +- `CallService` is a foreground `Service` that keeps the process alive during calls. +- Started via `CallService.startService()` which calls `ContextCompat.startForegroundService`. +- Acquires a partial `WakeLock` to prevent CPU sleep. +- Shows a persistent notification with: + - Contact name and call type (audio/video). + - An "End Call" action button. + - A chronometer showing call duration (from `connectedAt`). +- The notification taps open `CallActivity`. +- Foreground service type includes `MICROPHONE`, `CAMERA` (if video), and `MEDIA_PLAYBACK`. + +--- + +## 6. Desktop-Specific: Browser-Based WebRTC + +### 6.1 NanoWSD Embedded Server + +1. When a call starts, `startServer(onResponse)` creates a `NanoWSD` server on `localhost:50395`. +2. The server serves static WebRTC HTML/JS from bundled resources at `/assets/www/desktop/call.html`. +3. The system browser is opened to `http://localhost:50395/simplex/call/`. + +### 6.2 WebSocket Communication + +1. The browser page connects back via WebSocket to the same `localhost:50395` server. +2. Commands from the app to the browser are serialized as `WVAPICall(corrId, command)` JSON. +3. Responses from the browser arrive as `WVAPIMessage(corrId, resp, command)` JSON. +4. The `WebRTCController` composable manages the command queue: + - Collects commands from `ChatModel.callCommand` (a `SnapshotStateList`). + - Sends them to the browser via the WebSocket connection. + - Processes responses through the same `WCallResponse` handling as Android. +5. On dispose, `WCallCommand.End` is sent, the server is stopped, and connections are cleared. + +--- + +## 7. Common Signaling API + +| API Function | Purpose | +|-------------|---------| +| `apiSendCallInvitation(rh, contact, callType)` | Send call invitation via SMP | +| `apiRejectCall(rh, contact)` | Reject incoming call | +| `apiSendCallOffer(rh, contact, rtcSession, rtcIceCandidates, media, capabilities)` | Send SDP offer | +| `apiSendCallAnswer(rh, contact, rtcSession, rtcIceCandidates)` | Send SDP answer | +| `apiSendCallExtraInfo(rh, contact, rtcIceCandidates)` | Send additional ICE candidates | +| `apiEndCall(rh, contact)` | End active call | +| `apiCallStatus(rh, contact, status)` | Report WebRTC connection status | + +--- + +## 8. In-Call Media Controls + +During an active call, the user can toggle media sources via `WCallCommand.Media(source, enable)`: + +| Source | Control | +|--------|---------| +| `CallMediaSource.Mic` | Mute/unmute microphone | +| `CallMediaSource.Camera` | Enable/disable camera | +| `CallMediaSource.ScreenAudio` | Screen share audio | +| `CallMediaSource.ScreenVideo` | Screen share video | + +Camera switching (front/back) is done via `WCallCommand.Camera(VideoCamera.User / VideoCamera.Environment)`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `Call` | `views/call/WebRTC.kt` | Active call state: contact, callState, media sources, encryption | +| `CallState` | `views/call/WebRTC.kt` | Enum: WaitCapabilities through Ended | +| `RcvCallInvitation` | `views/call/WebRTC.kt` | Incoming call invitation with contact, callType, sharedKey | +| `CallManager` | `views/call/CallManager.kt` | Manages call lifecycle: accept, end, report | +| `WCallCommand` | `views/call/WebRTC.kt` | Commands to WebRTC engine: Capabilities, Start, Offer, Answer, Ice, Media, Camera, End | +| `WCallResponse` | `views/call/WebRTC.kt` | Responses from WebRTC: Capabilities, Offer, Answer, Ice, Connection, Connected, End | +| `CallActivity` | `android/.../views/call/CallActivity.kt` | Android Activity hosting the call UI and WebView | +| `CallService` | `android/.../CallService.kt` | Android foreground Service for call persistence | +| `NanoWSD` | `desktopMain/.../views/call/CallView.desktop.kt` | Desktop embedded HTTP+WebSocket server | diff --git a/apps/multiplatform/product/flows/connection.md b/apps/multiplatform/product/flows/connection.md new file mode 100644 index 0000000000..1b1123b535 --- /dev/null +++ b/apps/multiplatform/product/flows/connection.md @@ -0,0 +1,233 @@ +# Connection Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Establishing a contact connection in SimpleX Chat follows an invitation-link model. One party creates a connection link (one-time invitation or long-term address), shares it out-of-band, and the other party connects via that link. The process uses SMP queues for the handshake, with no central server involved in identity management. + +Connections support incognito mode, where a random profile is used per-connection instead of the user's real profile. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For connecting: a valid SimpleX connection link (invitation or address). + +--- + +## 1. Creating a Connection Link (Inviter Side) + +### 1.1 One-Time Invitation Link + +1. User navigates to "New Chat" and selects "Add Contact" (or uses the "+" action). +2. `ChatController.apiAddContact(rh, incognito)` is called: + +```kotlin +suspend fun apiAddContact(rh: Long?, incognito: Boolean): Pair?, (() -> Unit)?> +``` + +3. Internally, `CC.APIAddContact(userId, incognito)` is sent to the core. +4. The core creates a new SMP queue pair and returns: + - `CR.Invitation` with `connLinkInvitation: CreatedConnLink` and `connection: PendingContactConnection`. +5. The `CreatedConnLink` contains the invitation URI (long form and short link). +6. The link is displayed as a QR code in `NewChatView` and can be copied or shared. +7. A `PendingContactConnection` appears in the chat list while waiting. + +### 1.2 Long-Term Contact Address + +1. User goes to Settings and creates a SimpleX address. +2. This creates a persistent address link that multiple people can use. +3. Incoming connection requests from the address require explicit acceptance (see section 4). + +--- + +## 2. Connecting via Link (Connector Side) + +### 2.1 Preview the Connection Plan + +Before connecting, the link is analyzed: + +```kotlin +suspend fun apiConnectPlan(rh: Long?, connLink: String, inProgress: MutableState): Pair? +``` + +1. User pastes or scans a link. +2. `apiConnectPlan` sends `CC.APIConnectPlan(userId, connLink)` to the core. +3. The core resolves short links, validates the link, and returns a `ConnectionPlan`: + +```kotlin +sealed class ConnectionPlan { + class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() + class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan() + class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan() + class Error(val chatError: ChatError): ConnectionPlan() +} +``` + +4. For `InvitationLinkPlan`: + - `Ok`: Fresh invitation, safe to connect. + - `OwnLink`: User's own link, alert shown. + - `Connecting(contact_)`: Already connecting to this contact. + - `Known(contact)`: Already connected, existing contact shown. + +5. For `ContactAddressPlan`: + - `Ok`: Fresh address, safe to connect. + - `OwnLink`: User's own address. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(contact)`: Connection in progress, cannot duplicate. + - `Known(contact)`: Already a contact. + - `ContactViaAddress(contact)`: Contact already exists via this address. + +6. For `GroupLinkPlan`: + - `Ok`: Fresh group link, safe to join. + - `OwnLink(groupInfo)`: User's own group. + - `ConnectingConfirmReconnect`: Was connecting, offer to retry. + - `ConnectingProhibit(groupInfo_)`: Connection in progress. + - `Known(groupInfo)`: Already a member. + +### 2.2 High-Level Connect Flow: planAndConnect + +The `planAndConnect` function in `ConnectPlan.kt` orchestrates the full connect experience: + +```kotlin +suspend fun planAndConnect( + rhId: Long?, + shortOrFullLink: String, + close: (() -> Unit)?, + cleanup: (() -> Unit)? = null, + filterKnownContact: ((Contact) -> Unit)? = null, + filterKnownGroup: ((GroupInfo) -> Unit)? = null, +): CompletableDeferred +``` + +1. A progress indicator is shown. +2. `apiConnectPlan` is called to analyze the link. +3. Based on the plan type, the appropriate UI is shown: + - For `Ok` plans: proceed to `apiConnect`. + - For `Known`: navigate to the existing contact/group. + - For `OwnLink`: show alert. + - For `Connecting`: show reconnect confirmation or prohibit. +4. Returns a `CompletableDeferred` indicating success. + +### 2.3 Execute Connection + +```kotlin +suspend fun apiConnect(rh: Long?, incognito: Boolean, connLink: CreatedConnLink): PendingContactConnection? +``` + +1. `CC.APIConnect(userId, incognito, connLink)` is sent to the core. +2. The core initiates the SMP handshake: + - For invitation links: `CR.SentConfirmation` is returned. + - For contact addresses: `CR.SentInvitation` is returned. +3. A `PendingContactConnection` is returned and appears in the chat list. +4. The connect progress indicator is shown via `ConnectProgressManager`. + +--- + +## 3. Connection Handshake Completion + +### 3.1 For Invitation Links + +1. After the connector sends confirmation, the inviter's core receives it. +2. Both sides complete the SMP handshake automatically. +3. A `CR.ContactConnected` event is received on both sides. +4. The `PendingContactConnection` in the chat list is replaced by a full `Contact`. +5. Both parties can now exchange messages. + +### 3.2 For Contact Addresses + +1. The connector's confirmation arrives as a `ContactRequest` on the address owner's side. +2. The address owner must explicitly accept or reject (see section 4). +3. Once accepted, the handshake completes and `CR.ContactConnected` fires. + +--- + +## 4. Contact Request Acceptance + +### 4.1 Accept a Contact Request + +```kotlin +suspend fun apiAcceptContactRequest(rh: Long?, incognito: Boolean, contactReqId: Long): Contact? +``` + +1. The address owner sees a contact request notification in the chat list. +2. User taps to open and selects "Accept". +3. `CC.ApiAcceptContact(incognito, contactReqId)` is sent to the core. +4. The core responds with `CR.AcceptingContactRequest` and a `Contact` object. +5. The SMP handshake continues; once complete, `CR.ContactConnected` fires. +6. The `incognito` flag determines whether the real profile or a random profile is shared. + +### 4.2 Reject a Contact Request + +```kotlin +suspend fun apiRejectContactRequest(rh: Long?, contactReqId: Long): Contact? +``` + +1. User selects "Reject" on the contact request. +2. `CC.ApiRejectContact(contactReqId)` is sent to the core. +3. The core responds with `CR.ContactRequestRejected`. +4. The contact request is removed from the chat list. +5. The connector's side eventually times out or receives an error. + +--- + +## 5. Incognito Mode + +### 5.1 Per-Connection Incognito + +1. The `incognito` parameter is available on both `apiAddContact` and `apiConnect`. +2. When `incognito = true`: + - A random display name is generated for this connection. + - The real user profile is not shared with the contact. + - The incognito profile is stored per-connection in the database. +3. The global incognito toggle is in `AppPreferences.incognito`. +4. Incognito status is visible in the chat info view. + +### 5.2 Accept with Incognito + +1. When accepting a contact request with `incognito = true`, a random profile is used. +2. The accepted contact only sees the random profile. +3. The user can have some contacts with real profile and others with incognito profiles. + +--- + +## 6. Connection Progress and UI + +### 6.1 ConnectProgressManager + +```kotlin +object ConnectProgressManager { + fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) + fun stopConnectProgress() + fun cancelConnectProgress() +} +``` + +1. When a connection is initiated, `startConnectProgress` is called. +2. After a 1-second delay, a progress indicator appears if the operation is still in progress. +3. On completion (success or failure), `stopConnectProgress` is called. +4. The user can cancel via `cancelConnectProgress`. + +### 6.2 Pending Connection States + +While connecting, the chat list shows a `PendingContactConnection` with status: +- Waiting for the other party to scan/use the link. +- Connecting (handshake in progress). +- Connected (transitions to a full Contact chat). + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CreatedConnLink` | `model/SimpleXAPI.kt` | Connection link with full URI and short link | +| `PendingContactConnection` | `model/ChatModel.kt` | In-progress connection shown in chat list | +| `ConnectionPlan` | `model/SimpleXAPI.kt` | Sealed class: InvitationLink, ContactAddress, GroupLink, Error | +| `InvitationLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, Connecting, Known | +| `ContactAddressPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `GroupLinkPlan` | `model/SimpleXAPI.kt` | Ok, OwnLink, ConnectingConfirmReconnect, ConnectingProhibit, Known | +| `ConnectProgressManager` | `model/ChatModel.kt` | Manages connect progress indicator with timeout | +| `Contact` | `model/ChatModel.kt` | Established contact with profile, connection status | +| `ContactRequest` | `model/ChatModel.kt` | Pending inbound contact request | diff --git a/apps/multiplatform/product/flows/file-transfer.md b/apps/multiplatform/product/flows/file-transfer.md new file mode 100644 index 0000000000..edbb565c07 --- /dev/null +++ b/apps/multiplatform/product/flows/file-transfer.md @@ -0,0 +1,252 @@ +# File Transfer Flow + +> **Related spec:** [spec/services/files.md](../../spec/services/files.md) + +## Overview + +SimpleX Chat transfers files using two protocols based on file size: inline delivery through SMP messages for small files, and XFTP (SimpleX File Transfer Protocol) for larger files. All locally stored files can be AES-encrypted via CryptoFile. The system supports automatic receiving of small media, manual download for larger files, and cancellation at any stage. + +## Prerequisites + +- An active chat connection (direct contact or group). +- Sufficient storage space on the device. +- For XFTP: network connectivity to XFTP relay servers. + +--- + +## 1. File Size Thresholds and Constants + +| Constant | Value | Purpose | +|----------|-------|---------| +| `MAX_IMAGE_SIZE` | 261,120 bytes (255 KB) | Maximum inline image thumbnail size (base64 in message body) | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for images | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 bytes (510 KB) | Auto-receive threshold for voice messages | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 bytes (1023 KB) | Auto-receive threshold for video thumbnails | +| `MAX_FILE_SIZE_SMP` | 8,000,000 bytes (~7.6 MB) | Maximum file size for SMP inline transfer | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 bytes (1 GB) | Maximum file size for XFTP transfer | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | No limit for local files | + +These constants are defined in `views/helpers/Utils.kt`. + +The core decides the transfer protocol: +- Files within the SMP inline threshold are embedded directly in SMP messages. +- Files exceeding the inline threshold (up to 1 GB) use XFTP with chunked, encrypted upload/download through relay servers. + +--- + +## 2. CryptoFile Encryption + +### 2.1 Overview + +When `privacyEncryptLocalFiles` is enabled (default: `true`), files stored on device are AES-GCM encrypted. The encryption/decryption is performed via JNI calls to the Haskell core. + +### 2.2 Key Types + +```kotlin +// model/ChatModel.kt +@Serializable +data class CryptoFileArgs( + val fileKey: String, // AES-256 key (base64) + val fileNonce: String // GCM nonce (base64) +) + +@Serializable +data class CryptoFile { + val filePath: String + val cryptoArgs: CryptoFileArgs? // null for unencrypted files +} +``` + +### 2.3 Write (Encrypt) + +```kotlin +fun writeCryptoFile(path: String, data: ByteArray): CryptoFileArgs +``` + +1. `ChatController.getChatCtrl()` obtains the active controller handle. +2. Data is placed in a `DirectByteBuffer`. +3. `chatWriteFile(ctrl, path, buffer)` is called via JNI. +4. The core generates a random AES key and nonce, encrypts the data, writes to `path`. +5. Returns `CryptoFileArgs(fileKey, fileNonce)` needed for decryption. +6. On error, throws an exception with the error message. + +### 2.4 Read (Decrypt) + +```kotlin +fun readCryptoFile(path: String, cryptoArgs: CryptoFileArgs): ByteArray +``` + +1. `chatReadFile(path, cryptoArgs.fileKey, cryptoArgs.fileNonce)` is called via JNI. +2. Returns a two-element array: `[status: Int, data: ByteArray]`. +3. If `status == 0`, the decrypted data is returned. +4. Otherwise, an exception is thrown with the error message. + +### 2.5 File-to-File Encryption + +```kotlin +fun encryptCryptoFile(fromPath: String, toPath: String): CryptoFileArgs +``` + +Encrypts a plaintext file at `fromPath` to an encrypted file at `toPath`. Used when saving user-selected files to the app's encrypted storage. + +### 2.6 File-to-File Decryption + +```kotlin +fun decryptCryptoFile(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String) +``` + +Decrypts an encrypted file at `fromPath` to plaintext at `toPath`. Used when exporting/sharing files. + +--- + +## 3. Sending Files + +### 3.1 Attach and Send via ComposeView + +1. User attaches a file via the file picker. +2. File size is validated: `fileSize <= MAX_FILE_SIZE_XFTP` (1 GB). +3. If valid, `ComposeState.preview` is set to `ComposePreview.FilePreview(fileName, uri)`. +4. If too large, an alert is shown with the maximum supported size. +5. On send, the file is copied to the app files directory. +6. If `privacyEncryptLocalFiles` is enabled, the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `cryptoArgs`. +7. A `ComposedMessage` is created with: + - `fileSource`: the `CryptoFile` (path + optional cryptoArgs). + - `msgContent`: `MsgContent.MCFile(text)` for generic files, `MsgContent.MCImage(text, thumbnail)` for images, `MsgContent.MCVideo(text, thumbnail, duration)` for videos, or `MsgContent.MCVoice(text, duration)` for voice. +8. `ChatController.apiSendMessages(...)` dispatches the message. +9. The core determines the transfer protocol and begins the upload. + +### 3.2 Standalone File Upload (XFTP) + +For uploading files outside of a chat message context: + +```kotlin +suspend fun uploadStandaloneFile(user: UserLike, file: CryptoFile, ctrl: ChatCtrl? = null): Pair +``` + +1. `CC.ApiUploadStandaloneFile(userId, file)` is sent to the core. +2. On success, `CR.SndStandaloneFileCreated` returns a `FileTransferMeta`. +3. The meta contains a file description URI that can be shared for download. + +### 3.3 Upload Progress + +1. The core emits `SndFileProgressXFTP` events periodically during upload. +2. `CIFileStatus` on the chat item transitions through: + - `SndStored` (queued) + - `SndTransfer(sndProgress, sndTotal)` (uploading) + - `SndComplete` (upload finished, link sent) +3. The UI updates the progress indicator on the file attachment. + +--- + +## 4. Receiving Files + +### 4.1 Auto-Receive + +When `privacyAcceptImages` is enabled (default: `true`), small media files are auto-received: + +1. On receiving a message with a file attachment, the auto-receive logic checks: + - `MCImage` files with `fileSize <= MAX_IMAGE_SIZE_AUTO_RCV` (510 KB) + - `MCVideo` files with `fileSize <= MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB) + - `MCVoice` files with `fileSize <= MAX_VOICE_SIZE_AUTO_RCV` (510 KB) and not already accepted +2. If criteria are met, `receiveFile` is called automatically. + +### 4.2 Manual Receive + +For files that are not auto-received: + +1. The chat item shows a download button with file size info. +2. File size is validated: `fileSizeValid(file)` checks `file.fileSize <= getMaxFileSize(file.fileProtocol)`. +3. User taps the download button. +4. `ChatController.receiveFile(rhId, user, fileId, userApprovedRelays, auto)` is called: + +```kotlin +suspend fun receiveFile(rhId: Long?, user: UserLike, fileId: Long, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +5. This delegates to `receiveFiles` which handles relay approval: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, userApprovedRelays: Boolean = false, auto: Boolean = false) +``` + +6. For each file, `CC.ReceiveFile(fileId, userApprovedRelays, encrypted, inline)` is sent to the core. +7. If the file requires unapproved XFTP relays, the user is prompted to approve them. +8. Relay approval errors (`FileError.Auth` with `SMP AUTH` and `PROXY BROKER`) trigger relay approval alerts. +9. Other errors are collected and shown after all files are processed. + +### 4.3 Batch Receive + +Multiple files can be received at once: + +```kotlin +suspend fun receiveFiles(rhId: Long?, user: UserLike, fileIds: List, ...) +``` + +1. Iterates through all `fileIds`. +2. Files needing relay approval are batched and prompted once. +3. After approval, those files are retried with `userApprovedRelays = true`. +4. Errors for individual files are aggregated. + +### 4.4 Download Progress + +1. The core emits `RcvFileProgressXFTP` events during download. +2. `CIFileStatus` transitions through: + - `RcvAccepted` (download initiated) + - `RcvTransfer(rcvProgress, rcvTotal)` (downloading) + - `RcvComplete` (download finished) +3. On completion, if the file is encrypted, it remains encrypted on disk with `cryptoArgs` stored in the database. +4. When the user opens/views the file, `readCryptoFile` or `decryptCryptoFile` is called on demand. + +--- + +## 5. Cancelling a File Transfer + +### 5.1 Cancel via API + +```kotlin +suspend fun cancelFile(rh: Long?, user: User, fileId: Long) +``` + +1. `apiCancelFile(rh, fileId)` sends `CC.CancelFile(fileId)` to the core. +2. The core cancels any in-progress upload or download. +3. On success, the chat item is updated via `chatItemSimpleUpdate`. +4. `cleanupFile(chatItem)` removes any partial local files. + +### 5.2 Cancel via UI + +1. User long-presses a file message and selects "Cancel". +2. `cancelFileAlertDialog(fileId, cancelFile, cancelAction)` shows a confirmation dialog. +3. `CancelAction` provides the appropriate alert text based on direction (sending/receiving). +4. On confirmation, `cancelFile` is called. + +### 5.3 Compose Cancel + +Before sending, user can cancel the file attachment: + +1. User taps the "X" on the file preview in the compose area. +2. `ComposeState.preview` is reset to `ComposePreview.NoPreview`. +3. No API call is needed since the file was not yet sent. + +--- + +## 6. File Cleanup + +1. Files pending deletion are tracked in `ChatModel.filesToDelete`. +2. When a chat item with a file is deleted, the file path is added to `filesToDelete`. +3. The actual file deletion happens asynchronously. +4. Encrypted files require no special cleanup beyond deleting the encrypted file; the key exists only in the database record. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `CryptoFile` | `model/ChatModel.kt` | File reference with path and optional encryption args | +| `CryptoFileArgs` | `model/ChatModel.kt` | AES key + nonce for encrypted files | +| `WriteFileResult` | `model/CryptoFile.kt` | Result of `writeCryptoFile`: success with args or error | +| `CIFile` | `model/ChatModel.kt` | Chat item file metadata: fileId, fileName, fileSize, fileStatus, fileProtocol | +| `CIFileStatus` | `model/ChatModel.kt` | File transfer status: SndStored, SndTransfer, SndComplete, RcvInvitation, RcvAccepted, RcvTransfer, RcvComplete, etc. | +| `FileProtocol` | `model/ChatModel.kt` | Transfer protocol: XFTP, SMP, LOCAL | +| `FileTransferMeta` | `model/ChatModel.kt` | Metadata for standalone XFTP uploads | +| `ComposePreview.FilePreview` | `views/chat/ComposeView.kt` | Compose state for file attachment | diff --git a/apps/multiplatform/product/flows/group-lifecycle.md b/apps/multiplatform/product/flows/group-lifecycle.md new file mode 100644 index 0000000000..60311f7b47 --- /dev/null +++ b/apps/multiplatform/product/flows/group-lifecycle.md @@ -0,0 +1,283 @@ +# Group Lifecycle Flow + +> **Related spec:** [spec/api.md](../../spec/api.md) | [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Overview + +Groups in SimpleX Chat are decentralized: there is no central group server. The group owner's device coordinates membership, and messages are delivered via pairwise SMP connections between members. Groups support roles, invitation links, member admission review, blocking, and profile updates. + +## Prerequisites + +- Chat is initialized and running. +- An active user profile exists. +- For creating a group: no special requirements. +- For joining: a group invitation link or a direct invitation from an existing member. + +--- + +## 1. Creating a Group + +### 1.1 Create Group + +1. User navigates to "New Chat" and selects "Create Group". +2. The `AddGroupView` collects a group profile: display name, full name, optional image, and optional description. +3. `ChatController.apiNewGroup(rh, incognito, groupProfile)` is called: + +```kotlin +suspend fun apiNewGroup(rh: Long?, incognito: Boolean, groupProfile: GroupProfile): GroupInfo? +``` + +4. `CC.ApiNewGroup(userId, incognito, groupProfile)` is sent to the core. +5. The core creates the group and returns `CR.GroupCreated` with a `GroupInfo` object. +6. The creating user is automatically assigned the `Owner` role. +7. The new group appears in the chat list. +8. If `incognito = true`, a random profile is used for the user within this group. + +### 1.2 Update Group Profile + +```kotlin +suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile): GroupInfo? +``` + +1. Owner or Admin navigates to group info and edits the profile. +2. `CC.ApiUpdateGroupProfile(groupId, groupProfile)` is sent to the core. +3. On success, `CR.GroupUpdated` returns the updated `GroupInfo` with `toGroup`. +4. The chat model is updated via `chatModel.chatsContext.updateGroup(rh, groupInfo)`. +5. Profile changes are propagated to all connected members. + +--- + +## 2. Adding Members + +### 2.1 Invite a Contact + +1. Owner or Admin opens group info and taps "Add Members". +2. `AddGroupMembersView` displays the user's contacts eligible for invitation. +3. A role is selected for the invitee (default: `Member`). +4. `ChatController.apiAddMember(rh, groupId, contactId, memberRole)` is called: + +```kotlin +suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? +``` + +5. `CC.ApiAddMember(groupId, contactId, memberRole)` is sent to the core. +6. The core sends a group invitation to the contact via their direct SMP connection. +7. `CR.SentGroupInvitation` returns a `GroupMember` in `Invited` status. +8. The member list updates to show the pending invitation. + +### 2.2 Invitee Joins + +1. The invited contact receives a group invitation event. +2. A group invitation chat item appears in their chat list. +3. The invitee taps "Join" to accept. +4. `ChatController.apiJoinGroup(rh, groupId)` is called. +5. `CC.ApiJoinGroup(groupId)` is sent to the core. +6. `CR.UserAcceptedGroupSent` confirms the join request was sent. +7. The owner's/admin's device processes the join and establishes pairwise connections with existing members. +8. `CR.MemberConnected` events fire as connections to each member are established. + +--- + +## 3. Member Roles + +### 3.1 Role Hierarchy + +```kotlin +enum class GroupMemberRole(val memberRole: String) { + Observer("observer"), // Can only read messages + Author("author"), // Can send messages but limited + Member("member"), // Standard member + Moderator("moderator"), // Can moderate content + Admin("admin"), // Can manage members + Owner("owner") // Full control, can delete group +} +``` + +Selectable roles for assignment: `Observer`, `Member`, `Moderator`, `Admin`, `Owner`. + +### 3.2 Change Member Role + +```kotlin +suspend fun apiMembersRole(rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole): List +``` + +1. Owner or Admin navigates to member info in `GroupMemberInfoView`. +2. Selects a new role from the role picker. +3. `CC.ApiMembersRole(groupId, memberIds, memberRole)` is sent to the core. +4. The core responds with `CR.MembersRoleUser` returning updated `GroupMember` objects. +5. The change is propagated to all group members. +6. Supports batch role changes (multiple `memberIds`). + +--- + +## 4. Removing and Blocking Members + +### 4.1 Remove Members + +```kotlin +suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean): Pair>? +``` + +1. Owner or Admin selects a member and taps "Remove". +2. `CC.ApiRemoveMembers(groupId, memberIds, withMessages)` is sent. +3. If `withMessages = true`, the removed member's messages are also deleted from all members. +4. `CR.UserDeletedMembers` returns the updated `GroupInfo` and removed `GroupMember` list. +5. The removed member receives a notification and loses access to the group. + +### 4.2 Block Members for All + +```kotlin +suspend fun apiBlockMembersForAll(rh: Long?, groupId: Long, memberIds: List, blocked: Boolean): List +``` + +1. Owner, Admin, or Moderator selects a member and taps "Block for all". +2. `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` is sent. +3. `blocked = true` blocks; `blocked = false` unblocks. +4. `CR.MembersBlockedForAllUser` returns the updated member list. +5. Blocked members' messages are hidden from all group members. +6. The blocked member can still see the group but their messages are not delivered. + +--- + +## 5. Group Links + +### 5.1 Create Group Link + +```kotlin +suspend fun apiCreateGroupLink(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin navigates to group info and taps "Create Group Link". +2. `CC.APICreateGroupLink(groupId, memberRole)` is sent. +3. A default role for joiners is specified (default: `Member`). +4. `CR.GroupLinkCreated` returns a `GroupLink` containing the link URI. +5. The link is displayed in `GroupLinkView` as a QR code and copyable text. + +### 5.2 Update Group Link Role + +```kotlin +suspend fun apiGroupLinkMemberRole(rh: Long?, groupId: Long, memberRole: GroupMemberRole = GroupMemberRole.Member): GroupLink? +``` + +1. Owner or Admin changes the default role for new members joining via link. +2. `CC.APIGroupLinkMemberRole(groupId, memberRole)` is sent. +3. `CR.CRGroupLink` returns the updated link with the new default role. + +### 5.3 Get Group Link + +```kotlin +suspend fun apiGetGroupLink(rh: Long?, groupId: Long): GroupLink? +``` + +1. Retrieves the existing group link for display. +2. `CC.APIGetGroupLink(groupId)` is sent. +3. Returns `null` if no link exists. + +### 5.4 Delete Group Link + +```kotlin +suspend fun apiDeleteGroupLink(rh: Long?, groupId: Long): Boolean +``` + +1. Owner or Admin navigates to group link settings and taps "Delete Link". +2. `CC.APIDeleteGroupLink(groupId)` is sent. +3. `CR.GroupLinkDeleted` confirms deletion. +4. The link becomes invalid; anyone with the old link can no longer join. + +--- + +## 6. Member Admission Workflow + +### 6.1 Admission Configuration + +Group owners can require review of new members before they are fully admitted: + +```kotlin +data class GroupMemberAdmission( + val review: MemberCriteria? = null +) + +enum class MemberCriteria { + All // All joining members require review +} +``` + +1. Owner opens group info and navigates to "Member Admission" (`MemberAdmissionView`). +2. The `review` field is set to `MemberCriteria.All` to require review of all new members. +3. The admission configuration is saved by updating the group profile: + - `groupProfile.copy(memberAdmission = admission)` is passed to `apiUpdateGroup`. +4. Changes are tracked with unsaved-changes detection (save/discard prompt on navigation). + +### 6.2 Accept a Pending Member + +```kotlin +suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? +``` + +1. When admission review is enabled, new members joining via link arrive in a pending state. +2. Owner or Admin sees pending members in the member support chat / member list. +3. User selects "Accept" and optionally adjusts the role. +4. `CC.ApiAcceptMember(groupId, groupMemberId, memberRole)` is sent. +5. `CR.MemberAccepted` returns the updated `GroupInfo` and accepted `GroupMember`. +6. The member is now fully connected and can participate in the group. + +### 6.3 Reject a Pending Member + +1. Owner or Admin selects "Reject" on a pending member. +2. The member is removed via `apiRemoveMembers`. +3. The rejected member receives a removal notification. + +--- + +## 7. Leaving a Group + +```kotlin +suspend fun apiLeaveGroup(rh: Long?, groupId: Long): GroupInfo? +``` + +1. User navigates to group info and taps "Leave Group". +2. A confirmation dialog is shown. +3. `CC.ApiLeaveGroup(groupId)` is sent to the core. +4. `CR.LeftMemberUser` returns the updated `GroupInfo`. +5. The user's membership status changes and they can no longer send or receive messages. +6. The group remains in the chat list in a "left" state, and can be deleted locally. + +--- + +## 8. Listing Members + +```kotlin +suspend fun apiListMembers(rh: Long?, groupId: Long): List +``` + +1. When opening group info or the member list, `apiListMembers` is called. +2. `CC.ApiListMembers(groupId)` is sent to the core. +3. `CR.GroupMembers` returns the member list. +4. `ChatModel.groupMembers` and `ChatModel.groupMembersIndexes` are updated. +5. `ChatModel.membersLoaded` is set to `true`. + +--- + +## 9. Group Chat Scope (Support Channels) + +Groups support scoped conversations for member support: + +- `GroupChatScope` parameter on message APIs allows sending messages within a specific scope (e.g., member support chat). +- `MemberSupportChatView` and `MemberSupportView` provide UI for admin-to-member private conversations within the group context. +- `GroupReportsView` shows moderation reports scoped to the group. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `GroupInfo` | `model/ChatModel.kt` | Group metadata: groupId, groupProfile, membership, fullGroupPreferences | +| `GroupProfile` | `model/ChatModel.kt` | Group display info: displayName, fullName, description, image, memberAdmission | +| `GroupMember` | `model/ChatModel.kt` | Member info: groupMemberId, memberRole, memberStatus, memberProfile | +| `GroupMemberRole` | `model/ChatModel.kt` | Enum: Observer, Author, Member, Moderator, Admin, Owner | +| `GroupMemberAdmission` | `model/ChatModel.kt` | Admission settings: review criteria | +| `MemberCriteria` | `model/ChatModel.kt` | Enum: All (require review for all) | +| `GroupLink` | `model/SimpleXAPI.kt` | Group link: connLinkContact, acceptMemberRole, userContactLinkId, shortLinkDataSet, shortLinkLargeDataSet, groupLinkId | +| `GroupChatScope` | `model/ChatModel.kt` | Scoped conversation within a group | +| `ConnectionPlan.GroupLink` | `model/SimpleXAPI.kt` | Plan result when connecting via a group link | diff --git a/apps/multiplatform/product/flows/messaging.md b/apps/multiplatform/product/flows/messaging.md new file mode 100644 index 0000000000..771eae1c4e --- /dev/null +++ b/apps/multiplatform/product/flows/messaging.md @@ -0,0 +1,195 @@ +# Messaging Flow + +> **Related spec:** [spec/client/compose.md](../../spec/client/compose.md) | [spec/api.md](../../spec/api.md) + +## Overview + +Messaging is the core interaction in SimpleX Chat. Users compose and send text, images, video, voice notes, files, and link previews. Messages can be replied to, edited, deleted, forwarded, and reacted to with emoji. Special modes include timed (disappearing) messages, live messages (real-time typing), and message reports for moderation. + +All message operations flow through the Haskell core via `ChatController.apiSendMessages`, with responses updating `ChatModel` and triggering Compose UI recomposition. + +## Prerequisites + +- Chat is initialized and running (`ChatModel.chatRunning == true`). +- An active user exists (`ChatModel.currentUser != null`). +- A chat is open (`ChatModel.chatId != null`) with an established connection. + +--- + +## 1. Sending a Text Message + +### 1.1 Compose and Send + +1. User types in the compose field. `ComposeState.message` is updated as a `ComposeMessage(text, selection)`. +2. The compose area tracks context via `ComposeContextItem`: `NoContextItem` for a fresh message, `QuotedItem` for a reply, `EditingItem` for an edit, `ForwardingItems` for forwarding, or `ReportedItem` for a report. +3. User taps the send button. The `ComposeView` builds a `ComposedMessage`: + ```kotlin + class ComposedMessage( + val fileSource: CryptoFile?, + val quotedItemId: Long?, + val msgContent: MsgContent, + val mentions: Map + ) + ``` +4. For plain text, `msgContent` is `MsgContent.MCText(text)`. +5. `ChatController.apiSendMessages(rh, type, id, scope, live, ttl, composedMessages)` is called. +6. The core command `CC.ApiSendMessages` is dispatched via `sendCmd`. +7. On success, the response `CR.NewChatItems` returns a list of `AChatItem`. +8. `ChatModel` is updated and the chat item list recomposes to show the new message. +9. `ComposeState` is reset to its default. + +### 1.2 Link Preview + +1. As the user types, the text is parsed for URLs. +2. If `privacyLinkPreviews` preference is enabled and a URL is detected, a `LinkPreview` is fetched asynchronously. +3. The compose preview is set to `ComposePreview.CLinkPreview(linkPreview)`. +4. When sent, the `msgContent` is `MsgContent.MCLink(text, preview)`. + +--- + +## 2. Sending Media (Image, Video, Voice) + +### 2.1 Image + +1. User picks or captures an image. +2. The image is resized (max inline data size `MAX_IMAGE_SIZE` = 255 KB for the preview thumbnail). +3. The full-size file is saved to the app files directory. +4. If local file encryption is enabled (`privacyEncryptLocalFiles`), the file is encrypted via `encryptCryptoFile`, producing a `CryptoFile` with `CryptoFileArgs(fileKey, fileNonce)`. +5. Compose preview becomes `ComposePreview.MediaPreview(images, content)`. +6. On send, `msgContent` is `MsgContent.MCImage(text, imageBase64)` and `fileSource` is the `CryptoFile`. +7. The core handles inline delivery (for small files) or XFTP upload (for larger files). + +### 2.2 Video + +1. User picks or records a video. +2. A thumbnail image is extracted and resized. +3. The video file is saved and optionally encrypted. +4. On send, `msgContent` is `MsgContent.MCVideo(text, image, duration)`. + +### 2.3 Voice Message + +1. User records a voice note. Recording state is tracked via `RecordingState` (NotStarted, Started, Finished). +2. The compose preview becomes `ComposePreview.VoicePreview(voice, durationMs, finished)`. +3. On send, `msgContent` is `MsgContent.MCVoice(text, durationSeconds)`. +4. A file attachment carries the actual audio data. + +--- + +## 3. Sending Files + +1. User picks a file via the file chooser. +2. File size is validated against `MAX_FILE_SIZE_XFTP` (1 GB). +3. Compose preview becomes `ComposePreview.FilePreview(fileName, uri)`. +4. On send, `msgContent` is `MsgContent.MCFile(text)` and the `fileSource` is populated. +5. Delivery via inline (small files under SMP threshold) or XFTP (large files) is determined by the core. + +--- + +## 4. Receiving Messages + +1. The `ChatController` receiver loop calls `chatRecvMsgWait` on the Haskell core. +2. Incoming messages arrive as `CR.NewChatItems` events. +3. `ChatModel` chat items list is updated, triggering recomposition. +4. For media messages, images below `MAX_IMAGE_SIZE_AUTO_RCV` (510 KB), videos below `MAX_VIDEO_SIZE_AUTO_RCV` (1023 KB), and voice notes below `MAX_VOICE_SIZE_AUTO_RCV` (510 KB) are auto-received if `privacyAcceptImages` is enabled. +5. Larger files require manual download initiation (see File Transfer Flow). + +--- + +## 5. Editing a Message + +1. User long-presses a sent message and selects "Edit". +2. `ComposeContextItem` becomes `EditingItem(chatItem)`. +3. The original text populates the compose field. +4. On send, `ChatController.apiUpdateChatItem(rh, type, id, scope, itemId, updatedMessage, live)` is called. +5. `updatedMessage` is an `UpdatedMessage(msgContent, mentions)`. +6. The core responds with `CR.ChatItemUpdated` or `CR.ChatItemNotChanged`. +7. The chat item in `ChatModel` is updated in place. + +--- + +## 6. Deleting a Message + +1. User long-presses a message and selects "Delete". +2. A delete mode is chosen: `CIDeleteMode.cidmBroadcast` (delete for everyone), `CIDeleteMode.cidmInternal` (delete for self), or `CIDeleteMode.cidmInternalMark` (mark as deleted internally). +3. `ChatController.apiDeleteChatItems(rh, type, id, scope, itemIds, mode)` is called. +4. The core responds with `CR.ChatItemsDeleted`, returning a list of `ChatItemDeletion`. +5. For group chats by moderators, `apiDeleteMemberChatItems(rh, groupId, itemIds)` is used. +6. Deleted items are either removed from the UI or replaced with a "deleted" marker. + +--- + +## 7. Reacting to a Message + +1. User long-presses a message and selects an emoji reaction. +2. `ChatController.apiChatItemReaction(rh, type, id, scope, itemId, add, reaction)` is called. +3. `reaction` is a `MsgReaction` (typically emoji). +4. `add = true` to add, `add = false` to remove a reaction. +5. The core responds with `CR.ChatItemReaction`, and the chat item's reaction list is updated. +6. In groups, `apiGetReactionMembers` can be called to see who reacted. + +--- + +## 8. Replying to a Message + +1. User swipes or long-presses a message and selects "Reply". +2. `ComposeContextItem` becomes `QuotedItem(chatItem)`. +3. The quoted item preview is shown above the compose field. +4. On send, the `ComposedMessage.quotedItemId` is set to the quoted item's ID. +5. The sent message renders with the quoted content inline. + +--- + +## 9. Forwarding Messages + +1. User selects one or more messages and taps "Forward". +2. `ChatController.apiPlanForwardChatItems(rh, fromChatType, fromChatId, fromScope, chatItemIds)` is called first to get a `CR.ForwardPlan` with forwardable/non-forwardable item categorization. +3. `ComposeContextItem` becomes `ForwardingItems(chatItems, fromChatInfo)`. +4. User picks a destination chat. +5. `ChatController.apiForwardChatItems(rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl)` is called. +6. New chat items are created in the destination chat. + +--- + +## 10. Timed (Disappearing) Messages + +1. Timed messages are enabled per-chat via chat feature preferences. +2. When composing, a TTL (time-to-live) in seconds is passed as the `ttl` parameter to `apiSendMessages`. +3. The core attaches the TTL to the message metadata. +4. After the TTL expires, the message is automatically deleted on both sides. +5. The UI shows a countdown indicator on timed messages via `CIMetaView`. + +--- + +## 11. Live Messages + +1. User enables live message mode (long-press on send button if `liveMessageAlertShown` preference allows). +2. `ComposeState.liveMessage` is set to a `LiveMessage(chatItem, typedMsg, sentMsg, sent)`. +3. As the user types, `apiSendMessages` is called with `live = true` for the initial send, then `apiUpdateChatItem` with `live = true` for subsequent updates. +4. The recipient sees the message content updating in real-time. +5. When the user finalizes (taps send), a final `apiUpdateChatItem` with `live = false` is sent. + +--- + +## 12. Message Reports + +1. User long-presses a message and selects "Report". +2. `ComposeContextItem` becomes `ReportedItem(chatItem, reason)` where `reason` is a `ReportReason`. +3. On send, `msgContent` is `MsgContent.MCReport(text, reason)`. +4. The report is sent to group owners/admins for moderation review. +5. Group admins see reports in the `GroupReportsView`. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `ComposeState` | `views/chat/ComposeView.kt` | Tracks compose field state | +| `ComposePreview` | `views/chat/ComposeView.kt` | Preview type: NoPreview, CLinkPreview, MediaPreview, VoicePreview, FilePreview | +| `ComposeContextItem` | `views/chat/ComposeView.kt` | Context: NoContextItem, QuotedItem, EditingItem, ForwardingItems, ReportedItem | +| `ComposedMessage` | `model/SimpleXAPI.kt` | Wire format for sending: fileSource, quotedItemId, msgContent, mentions | +| `UpdatedMessage` | `model/SimpleXAPI.kt` | Wire format for editing: msgContent, mentions | +| `MsgContent` | `model/ChatModel.kt` | Sealed class: MCText, MCLink, MCImage, MCVideo, MCVoice, MCFile, MCReport, MCChat, MCUnknown | +| `LiveMessage` | `views/chat/ComposeView.kt` | Tracks live message state | +| `MsgReaction` | `model/ChatModel.kt` | Emoji reaction type | +| `ChatItemDeletion` | `model/ChatModel.kt` | Deletion result with old/new item | diff --git a/apps/multiplatform/product/flows/onboarding.md b/apps/multiplatform/product/flows/onboarding.md new file mode 100644 index 0000000000..b6b3e835a5 --- /dev/null +++ b/apps/multiplatform/product/flows/onboarding.md @@ -0,0 +1,205 @@ +# Onboarding Flow + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/architecture.md](../../spec/architecture.md) + +## Overview + +Onboarding is the first-run experience that initializes the Haskell chat core, creates the local database, sets up the user profile, configures server operators, and (on Android) selects the notification mode. The flow is tracked by the `OnboardingStage` enum persisted in `AppPreferences.onboardingStage`. + +The initialization path differs slightly between Android and Desktop, but both converge on the common `chatMigrateInit` JNI call and shared `ChatController` logic. + +## Prerequisites + +- Fresh install or database reset. +- On Android: `SimplexApp.onCreate()` has been called. +- On Desktop: `main()` has been called. + +--- + +## 1. Platform Initialization + +### 1.1 Android: SimplexApp.onCreate() + +1. `SimplexApp.onCreate()` is called by the Android framework. +2. `AppContextProvider.initialize(this)` sets the application context. +3. Phoenix process detection: if this is a restart process, return early. +4. A global error handler is registered. +5. `initHaskell(packageName)` loads the native `libapp-lib.so` and calls `initHS()` to initialize the Haskell runtime. +6. `initMultiplatform()` sets up: + - `androidAppContext` reference. + - `ntfManager` (notification manager bridge to Android `NtfManager`). + - `platform` interface implementation with Android-specific callbacks for services, notifications, call management, and UI configuration. +7. `reconfigureBroadcastReceivers()` ensures notification-related receivers match saved preferences. +8. `runMigrations()` performs any pending app-level data migrations. +9. Temp directory is cleaned and recreated. +10. If a migration state exists (`chatModel.migrationState.value != null`), onboarding is forced to `Step1_SimpleXInfo`. +11. Otherwise, if authentication keys are available, `initChatControllerOnStart()` is called. + +### 1.2 Desktop: Main.kt main() + +1. `initHaskell()` loads native libraries: + - On Linux/macOS: `libapp-lib.so` / `libapp-lib.dylib`. + - On Windows: `libcrypto-3-x64.dll`, `libsimplex.dll`, `libapp-lib.dll` plus VLC libraries. +2. `initHS()` initializes the Haskell runtime. +3. `platform` interface is set with Desktop-specific callbacks (app update notice). +4. `runMigrations()` performs pending app-level data migrations. +5. `setupUpdateChecker()` configures the desktop update channel. +6. `initApp()` initializes common app state. +7. Temp directory is cleaned and recreated. +8. `showApp()` launches the Compose Desktop window, which renders the `AppView`. + +--- + +## 2. Database Initialization (chatMigrateInit) + +### 2.1 initChatController + +1. `initChatController(useKey, confirmMigrations, startChat)` is called (from `Core.kt`). +2. If `ctrlInitInProgress` is already true, return (prevents double initialization). +3. The database key is resolved: + - From `useKey` parameter if provided. + - Otherwise from `DatabaseUtils.useDatabaseKey()` which reads from the keystore. +4. Migration confirmation mode is determined: + - `MigrationConfirmation.YesUp` (auto-confirm forward migrations) by default. + - `MigrationConfirmation.Error` if developer tools + confirm upgrades are enabled. +5. `chatMigrateInit(dbPath, dbKey, confirm)` is called via JNI. This: + - Opens (or creates) the SQLite database at `dbAbsolutePrefixPath`. + - Runs all pending schema migrations. + - Returns a `ChatCtrl` handle (Long) and a `DBMigrationResult`. +6. On `DBMigrationResult.OK`: + - The `ChatCtrl` is stored globally. + - `ChatModel.chatDbStatus` is set. + - App file paths are configured via `apiSetAppFilePaths`. + - `apiGetActiveUser` checks for an existing user. +7. If an active user exists, `startChat(user)` is called. +8. If no user exists, `startChatWithoutUser()` is called and onboarding begins at `Step1_SimpleXInfo`. + +### 2.2 Error Handling + +- `DBMigrationResult.ErrorNotADatabase`: Wrong passphrase or corrupted DB. User is prompted. +- `DBMigrationResult.ErrorMigration`: Migration failed. Details shown to user. +- `DBMigrationResult.ErrorKeyNotSet`: Encryption key missing. +- `DBMigrationResult.InvalidConfirmation`: Migrations need manual confirmation (developer mode). +- On any error, `ChatModel.chatDbStatus` is set and the UI shows the appropriate database error screen. + +--- + +## 3. Onboarding Stages + +The onboarding flow is controlled by `OnboardingStage`, persisted in `AppPreferences.onboardingStage`: + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### 3.1 Step1_SimpleXInfo + +1. The `SimpleXInfo` screen is shown. +2. Explains what SimpleX Chat is: privacy, no user identifiers, decentralized. +3. User taps "Create your profile" to proceed. +4. On Desktop, a "Link a Mobile" option is also available. + +### 3.2 Step2_CreateProfile + +1. The `CreateProfile` screen is shown. +2. User enters a display name (validated via `chatValidName` JNI) and optional full name. +3. On submit, `ChatController.apiCreateActiveUser(rh, profile)` is called: + ```kotlin + suspend fun apiCreateActiveUser(rh: Long?, p: Profile?, pastTimestamp: Boolean = false, ctrl: ChatCtrl? = null): User? + ``` +4. The core command `CC.CreateActiveUser(p, pastTimestamp)` creates the user in the database. +5. On success, `CR.ActiveUser` returns the new `User` object. +6. `ChatModel.currentUser` is set. +7. If the chat is not yet running, `startChat(user)` is called: + - `apiSetNetworkConfig` configures network settings. + - `apiStartChat` starts the message receiver. + - `startReceiver()` begins polling for incoming messages. +8. Onboarding advances to `Step3_ChooseServerOperators`. + +### 3.3 LinkAMobile (Desktop Only) + +1. Available as an alternative to creating a profile on Desktop. +2. Shows a QR code for linking with a mobile device. +3. The desktop acts as a remote host controlled by the mobile app. + +### 3.4 Step2_5_SetupDatabasePassphrase (Desktop Only) + +1. On Desktop, after profile creation, the user is optionally prompted to set a database passphrase. +2. If skipped, a random passphrase is used (`desktopOnboardingRandomPassword` flag). +3. `ChatModel.desktopOnboardingRandomPassword` tracks this state. + +### 3.5 Step3_ChooseServerOperators + +1. The `ChooseServerOperators` screen is shown. +2. User selects which preset server operators to use for messaging and file transfer. +3. Server operator conditions may need to be accepted. +4. The selection is saved via the server configuration APIs. + +### 3.6 Step3_CreateSimpleXAddress + +1. User is prompted to create a SimpleX address for receiving contact requests. +2. This calls the address creation API. +3. Can be skipped. + +### 3.7 Step4_SetNotificationsMode (Android Only) + +1. The `SetNotificationsMode` screen is shown. +2. Three modes are available: + - `NotificationsMode.SERVICE`: Persistent background service (instant notifications). + - `NotificationsMode.PERIODIC`: Periodic background work (delayed notifications). + - `NotificationsMode.OFF`: No background processing (manual check only). +3. On selection, `appPrefs.notificationsMode` is set. +4. On Desktop, this step is skipped entirely. + +### 3.8 OnboardingComplete + +1. `appPrefs.onboardingStage` is set to `OnboardingComplete`. +2. The chat list view (`ChatListView`) is shown. +3. On Android, `SimplexService.showBackgroundServiceNoticeIfNeeded()` may show additional setup prompts. +4. On Android with `NotificationsMode.SERVICE`, `SimplexService.start()` is called. + +--- + +## 4. startChat Flow + +After the user is created and onboarding progresses, `ChatController.startChat(user)` orchestrates the final setup: + +1. `apiSetNetworkConfig(getNetCfg())` applies network configuration. +2. `apiCheckChatRunning()` checks if the core is already running. +3. `listUsers(null)` loads all user profiles into `ChatModel.users`. +4. If chat is not running: + - `ChatModel.currentUser` is set. + - `apiStartChat()` starts the core's message processing. + - `startReceiver()` begins the message receive loop. + - `setLocalDeviceName` sets the device name for remote access. +5. `apiGetChats` loads the chat list. +6. `chatModel.chatsContext.updateChats(chats)` populates the UI. +7. User address and chat item TTL are loaded. +8. `appPrefs.chatLastStart` is updated. +9. `ChatModel.chatRunning` is set to `true`. +10. `platform.androidChatInitializedAndStarted()` is called for Android-specific post-start tasks. + +--- + +## Key Types Reference + +| Type | Location | Purpose | +|------|----------|---------| +| `OnboardingStage` | `views/onboarding/OnboardingView.kt` | Enum tracking onboarding progress | +| `SimplexApp` | `android/.../SimplexApp.kt` | Android Application class, entry point | +| `Main.kt` | `desktop/.../Main.kt` | Desktop entry point | +| `ChatController` | `model/SimpleXAPI.kt` | Core API controller, manages chat lifecycle | +| `ChatModel` | `model/ChatModel.kt` | Global observable state | +| `DBMigrationResult` | `views/helpers/DatabaseUtils.kt` | Database migration outcome | +| `chatMigrateInit` | `platform/Core.kt` | JNI function: initialize DB and run migrations | +| `initChatController` | `platform/Core.kt` | High-level initialization orchestrator | +| `AppPreferences` | `model/SimpleXAPI.kt` | Persistent user preferences | diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md new file mode 100644 index 0000000000..25535d8003 --- /dev/null +++ b/apps/multiplatform/product/gaps.md @@ -0,0 +1,290 @@ +# Known Gaps & Recommendations -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document catalogs known gaps in the multiplatform codebase (Android and Desktop) with severity, impact, and recommendations. + +--- + +## Table of Contents + +1. [UI: Error Feedback](#gap-01-ui-error-feedback) +2. [UI: Loading States](#gap-02-ui-loading-states) +3. [Security: Database Passphrase Not Enforced](#gap-03-security-database-passphrase-not-enforced) +4. [Security: No Forward Secrecy Indicator](#gap-04-security-no-forward-secrecy-indicator) +5. [Documentation: Haskell Store Layer Not Fully Specified](#gap-05-documentation-haskell-store-layer-not-fully-specified) +6. [Desktop: Recording Not Implemented](#gap-06-desktop-recording-not-implemented) +7. [Desktop: Cryptor Not Implemented](#gap-07-desktop-cryptor-not-implemented) + +--- + +## GAP-01: UI Error Feedback + +**Severity:** Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Many API calls through `ChatController.sendCmd()` return `API.Error` responses that are logged but not surfaced to the user. The general pattern is: + +```kotlin +val r = sendCmd(rh, cmd) +if (r is API.Result && r.res is CR.ExpectedResponse) return r.res.value +Log.e(TAG, "someFunction bad response: ${r.responseType} ${r.details}") +return null +``` + +When the call fails, the caller receives `null` and either silently does nothing or shows a generic error. The specific `ChatError` details (which may contain actionable information like quota exceeded, server unreachable, or store errors) are lost to the user. + +### Affected Locations + +- `SimpleXAPI.kt` -- `getAgentSubsTotal()`, `getAgentServersSummary()`, and dozens of similar `api*` functions +- Throughout the codebase wherever `sendCmd` results are pattern-matched + +### Impact + +Users experience silent failures with no indication of what went wrong. This is particularly problematic for: +- Connection attempts that fail due to network issues +- File transfer failures +- Group operations that fail due to role permissions +- Server configuration errors + +### Recommendation + +1. Introduce a structured error-handling utility that maps `ChatError` subtypes to user-visible messages, similar to how `retryableNetworkErrorAlert` already handles a subset of `AgentErrorType.BROKER` errors. +2. At minimum, surface a dismissible snackbar/toast with a summary when an API call fails unexpectedly. +3. For critical operations (send message, join group, create connection), show a dialog with retry/cancel options (the `sendCmdWithRetry` pattern already exists for some cases -- extend it). + +--- + +## GAP-02: UI Loading States + +**Severity:** Low-Medium +**Category:** UI / UX +**Platforms:** Android, Desktop + +### Description + +Several long-running operations lack loading indicators, leaving the user uncertain whether the action is in progress. The `ComposeState.inProgress` flag and `progressByTimeout` mechanism exist for the compose area, and `ConnectProgressManager` handles connection progress, but many other flows have no visual feedback. + +### Affected Locations + +- Group member list loading (`ChatModel.membersLoaded` exists but is not always checked before displaying stale data) +- Server configuration validation (`ApiValidateServers` can take several seconds with no indicator) +- Database export/import (`ApiExportArchive`, `ApiImportArchive`) +- Profile switching (`changeActiveUser_` acquires `changingActiveUserMutex` but the UI may appear frozen) + +### Impact + +Users may tap actions multiple times, causing duplicate requests, or assume the app is frozen and force-quit during a long operation like database export. + +### Recommendation + +1. Introduce a centralized `ProgressOverlay` composable that can be shown/hidden via a `ChatModel` flag. +2. Wrap all operations that acquire `changingActiveUserMutex` or take > 1 second with a visible loading state. +3. Use `ChatModel.switchingUsersAndHosts` (which already exists) more consistently as a gate for showing a blocking progress indicator. + +--- + +## GAP-03: Security: Database Passphrase Not Enforced + +**Severity:** High +**Category:** Security +**Platforms:** Android, Desktop + +### Description + +When the app is first installed, a random database passphrase is generated and stored in encrypted preferences. The user is never required to set a custom passphrase. The `initialRandomDBPassphrase` flag tracks this state, and a setup prompt exists in onboarding (`SetupDatabasePassphrase`), but the user can skip it. + +On Android, the encrypted passphrase is stored via the Android Keystore, which provides hardware-backed security. On Desktop, the `Cryptor` is a **placeholder** (see GAP-07), meaning the passphrase is stored in plaintext. + +### Affected Locations + +- `SimpleXAPI.kt` -- `AppPreferences.storeDBPassphrase`, `AppPreferences.initialRandomDBPassphrase`, `AppPreferences.encryptedDBPassphrase` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/SetupDatabasePassphrase.kt` + +### Impact + +- Users who skip passphrase setup rely entirely on device security. If the device is compromised, the database can be decrypted using the stored passphrase. +- On Desktop, the passphrase is effectively stored in plaintext (see GAP-07), meaning anyone with filesystem access can read the database. + +### Recommendation + +1. Consider making passphrase setup mandatory during onboarding (or at least prominently warn users who skip it). +2. On Desktop, implement proper key storage (GAP-07) before any passphrase enforcement is meaningful. +3. Add a periodic reminder for users who still have `initialRandomDBPassphrase == true`. + +--- + +## GAP-04: Security: No Forward Secrecy Indicator + +**Severity:** Medium +**Category:** Security / UI +**Platforms:** Android, Desktop + +### Description + +The double-ratchet algorithm provides forward secrecy per message, and PQ key exchange provides resistance to quantum attacks. The `Connection` type tracks `pqSupport`, `pqEncryption`, `pqSndEnabled`, and `pqRcvEnabled`. However, the UI does not prominently display the current forward secrecy state or PQ encryption status for a given conversation. + +### Affected Locations + +- `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, `Connection.pqRcvEnabled` +- Contact info views, group member info views + +### Impact + +Users cannot easily verify whether their conversations are using PQ-enhanced encryption. Security-conscious users have no visual indicator of the ratchet state or whether PQ key exchange was successful. + +### Recommendation + +1. Add a security badge/icon in the chat header or contact info screen showing: + - Whether PQ key exchange is active (both peers support it) + - Whether the connection has been verified (security code comparison) + - The ratchet state (in-sync vs. needs re-sync) +2. The `connectionCode` field on `Connection` can be used to show verification status. +3. The `Call.encryptionStatus` pattern (used in call views) could be adapted for the chat view. + +--- + +## GAP-05: Documentation: Haskell Store Layer Not Fully Specified + +**Severity:** Medium +**Category:** Documentation / Architecture +**Platforms:** Android, Desktop + +### Description + +The Kotlin client communicates with the Haskell core via a text-based command protocol (`CC.cmdString` -> FFI -> Haskell). The Haskell store layer (SQLite operations, migration logic, and the exact semantics of `StoreError` variants) is not documented from the Kotlin side. The `ChatErrorStore` error type wraps a `StoreError` whose variants are defined in Haskell and deserialized by the Kotlin client, but the conditions under which each error occurs are not specified. + +### Affected Locations + +- `SimpleXAPI.kt:6986` -- `ChatErrorStore(storeError: StoreError)` +- `SimpleXAPI.kt` -- `StoreError` sealed class (deserialized from Haskell responses) +- `SimpleXAPI.kt` -- `ChatErrorDatabase(databaseError: DatabaseError)` for migration errors + +### Impact + +- Developers cannot predict which `StoreError` will occur for a given operation without reading the Haskell source. +- Error handling in the Kotlin layer is necessarily generic since the error semantics are not specified. +- Migration failures (`ChatErrorDatabase`) are particularly opaque. + +### Recommendation + +1. Create a specification document mapping each `CC` command to its possible `StoreError` / `DatabaseError` responses. +2. Document the database migration versioning scheme and the conditions under which `confirmDBUpgrades` is triggered. +3. Add inline documentation to the `StoreError` sealed class variants explaining their trigger conditions. + +--- + +## GAP-06: Desktop: Recording Not Implemented + +**Severity:** High +**Category:** Feature / Platform +**Platform:** Desktop only + +### Description + +The `RecorderNative` class on Desktop is a placeholder. Both `start()` and `stop()` are stubbed with `/*LALAL*/` comments and return dummy values (empty string and 0, respectively). Users cannot record voice messages on Desktop. + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +actual class RecorderNative: RecorderInterface { + override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { + /*LALAL*/ + return "" + } + + override fun stop(): Int { + /*LALAL*/ + return 0 + } +} +``` + +Audio playback IS implemented on Desktop (via VLC/`vlcj` library), so received voice messages can be played. Only recording is missing. + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt:15-25` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt` -- `RecorderInterface` + +### Impact + +Desktop users cannot send voice messages. The record button either does nothing or produces a zero-length file. + +### Recommendation + +1. Implement `RecorderNative` using a JVM audio capture library (e.g., `javax.sound.sampled`, or integrate with the existing `vlcj` dependency for capture). +2. The output format should match the mobile app's voice message format (likely Opus in an OGG container) for cross-platform compatibility. +3. Until implemented, the record button should be hidden or disabled on Desktop with a tooltip explaining the limitation. + +### Additional Desktop LALAL Placeholders + +Several other Desktop features are also marked with `LALAL` placeholders: +- **QR Code Scanner** (`QRCodeScanner.desktop.kt:12`) -- scanning QR codes is not implemented on Desktop +- **Animated Drawables** (`Utils.desktop.kt:179`) -- animated image support (e.g., GIF in-line rendering) is not implemented +- **Animated Chat Images** (`CIImageView.desktop.kt:19`) -- animated image rendering in chat items +- **isImage detection** (`Images.desktop.kt:168`) -- image type detection (implemented but marked as incomplete) + +--- + +## GAP-07: Desktop: Cryptor Not Implemented + +**Severity:** Critical +**Category:** Security / Platform +**Platform:** Desktop only + +### Description + +The `CryptorInterface` implementation on Desktop is a non-functional placeholder. All three methods are stubbed: + +```kotlin +// common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? { + return String(data) // LALAL + } + + override fun encryptText(text: String, alias: String): Pair { + return text.toByteArray() to text.toByteArray() // LALAL + } + + override fun deleteKey(alias: String) { + // LALAL + } +} +``` + +- `decryptData` returns the data as-is (no decryption) +- `encryptText` returns the plaintext as both "encrypted data" and "IV" +- `deleteKey` is a no-op + +### Affected Locations + +- `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` -- uses `cryptor` for passphrase encryption + +### Impact + +**This is a critical security gap.** On Desktop: +- The database passphrase is stored **in plaintext** in the preferences file. Anyone with read access to the user's home directory can extract the passphrase and decrypt the database. +- The self-destruct passphrase is similarly stored in plaintext. +- The app passphrase (for local authentication) provides no real protection. +- Key deletion is a no-op, so "deleting" a key has no effect. + +This directly undermines RULE-02 (Database Encryption at Rest) and RULE-04 (Self-Destruct Profile) on the Desktop platform. + +### Recommendation + +1. **Priority: Critical.** Implement proper key storage on Desktop using one of: + - **OS Keychain integration:** macOS Keychain, Windows Credential Manager, Linux Secret Service (via `libsecret`/GNOME Keyring/KWallet) + - **Java Cryptography Architecture (JCA)** with a PKCS#12 keystore file protected by a master password + - **Bouncy Castle** library for platform-independent key management +2. Until a real implementation exists, display a prominent warning to Desktop users that their database passphrase is not securely stored. +3. Consider requiring the user to enter their passphrase on each app launch (do not store it) as an interim measure. + +### Related + +- GAP-03 (Database Passphrase Not Enforced) is compounded by this gap on Desktop. +- The `testCrypto()` function referenced in `AppCommon.desktop.kt:39` is commented out with a `// LALAL` marker, suggesting crypto testing was planned but never completed. diff --git a/apps/multiplatform/product/glossary.md b/apps/multiplatform/product/glossary.md new file mode 100644 index 0000000000..10203d8a2a --- /dev/null +++ b/apps/multiplatform/product/glossary.md @@ -0,0 +1,561 @@ +# Domain Term Glossary -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This glossary is self-contained and covers the Android and Desktop (Kotlin/Compose Multiplatform) codebase only. + +--- + +## Table of Contents + +1. [Protocols & Cryptography](#1-protocols--cryptography) +2. [Core Data Types](#2-core-data-types) +3. [Commands & Events](#3-commands--events) +4. [Connection & Identity](#4-connection--identity) +5. [Messaging Features](#5-messaging-features) +6. [Calling & Media](#6-calling--media) +7. [Notifications & Background](#7-notifications--background) +8. [Application Architecture](#8-application-architecture) +9. [Configuration & Preferences](#9-configuration--preferences) + +--- + +## 1. Protocols & Cryptography + +### SMP (SimpleX Messaging Protocol) +The core message-relay protocol. Clients send and receive messages through SMP relay servers without exposing sender/receiver identity correlation. The protocol uses unidirectional queues -- each contact pair maintains separate send and receive queues on potentially different servers. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `SMPErrorType`, `SMPProxyMode`, `SMPProxyFallback`, `SMPWebPortServers` + +### XFTP (SimpleX File Transfer Protocol) +Protocol for transferring files through relay servers. Files are chunked, encrypted, and uploaded to XFTP relays. Recipients download chunks and reassemble locally. Supports inline transfer for small files. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `CC.ApiUploadStandaloneFile`, `CC.ApiDownloadStandaloneFile`, `CC.ApiStandaloneFileInfo` + +### E2E Encryption (End-to-End Encryption) +All messages are encrypted end-to-end. The app never transmits plaintext to relay servers. Encryption keys are negotiated during connection establishment using X3DH-like key agreement and then maintained via the double-ratchet algorithm. + +### Double Ratchet +The core key-management algorithm. After initial key agreement, each message derives a new symmetric key, providing forward secrecy per message. Ratchet state can be re-synchronized via `APISyncContactRatchet` / `APISyncGroupMemberRatchet` commands. + +*See:* `SimpleXAPI.kt` -- `CC.APISyncContactRatchet(contactId, force)`, `CC.APISyncGroupMemberRatchet(groupId, groupMemberId, force)`, `CR.ContactRatchetSync`, `CR.GroupMemberRatchetSync` + +### PQ (Post-Quantum) +Post-quantum key exchange support. Connections track PQ state via `Connection.pqSupport`, `Connection.pqEncryption`, `Connection.pqSndEnabled`, and `Connection.pqRcvEnabled` fields. When both peers support PQ, the key exchange incorporates a post-quantum KEM to resist future quantum attacks. + +*See:* `ChatModel.kt` -- `Connection.pqSupport`, `Connection.pqEncryption`; `SimpleXAPI.kt` -- `SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED` (legacy, no longer used) + +### SMP Proxy / Private Routing +Messages can be sent through an intermediate SMP proxy relay to hide the sender's IP from the destination relay. Controlled by `SMPProxyMode` (Always, Unknown, Unprotected, Never) and `SMPProxyFallback` (Allow, AllowProtected, Prohibit). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSMPProxyMode`, `AppPreferences.networkSMPProxyFallback` + +### Transport Session Mode +Controls how TCP sessions to SMP relays are multiplexed. Options: `User` (one session per user profile), `Session` (single shared session), `Server` (one per server), `Entity` (one per queue/entity -- maximum metadata protection). + +*See:* `SimpleXAPI.kt` -- `AppPreferences.networkSessionMode`, `TransportSessionMode` + +--- + +## 2. Core Data Types + +### ChatItem +A single item in a conversation -- a sent or received message, call event, group event, connection event, feature change, or moderation action. Contains direction (`CIDirection`), metadata (`CIMeta`), content (`CIContent`), optional formatted text, mentions, quoted item, reactions, and file attachment. + +*See:* `ChatModel.kt:2720` -- `data class ChatItem` + +### ChatInfo +The top-level discriminated union representing a conversation. Variants: +- `ChatInfo.Direct` -- wraps a `Contact` +- `ChatInfo.Group` -- wraps a `GroupInfo` +- `ChatInfo.Local` -- wraps a `NoteFolder` (saved messages / notes to self) +- `ChatInfo.ContactRequest` -- wraps a `UserContactRequest` +- `ChatInfo.ContactConnection` -- wraps a `PendingContactConnection` +- `ChatInfo.InvalidJSON` -- fallback for unrecognized data + +*See:* `ChatModel.kt:1391` -- `sealed class ChatInfo` + +### CIContent (Chat Item Content) +The content payload of a `ChatItem`. Over 30 variants including: +- `SndMsgContent` / `RcvMsgContent` -- regular message with `MsgContent` +- `SndCall` / `RcvCall` -- call event with status and duration +- `RcvIntegrityError` -- message integrity violation +- `RcvDecryptionError` -- decryption failure with error type and count +- `RcvGroupInvitation` / `SndGroupInvitation` -- group invite +- `RcvGroupEventContent` / `SndGroupEventContent` -- group lifecycle events +- `RcvChatFeature` / `SndChatFeature` -- per-chat feature toggle notifications +- `SndModerated` / `RcvModerated` / `RcvBlocked` -- moderation events +- `RcvDirectEventContent` -- direct chat lifecycle events + +*See:* `ChatModel.kt:3554` -- `sealed class CIContent` + +### MsgContent +The wire-format message body. Variants: `MCText`, `MCLink`, `MCImage`, `MCVideo`, `MCVoice`, `MCFile`, `MCReport`, `MCUnknown`. Each carries text plus optional media/file metadata. + +*See:* `ChatModel.kt` -- `sealed class MsgContent` + +### User +The local user profile. Fields: `userId`, `userContactId`, `localDisplayName`, `profile` (LocalProfile), `fullPreferences` (FullChatPreferences), `activeUser`, `activeOrder`, `showNtfs`, `sendRcptsContacts`, `sendRcptsSmallGroups`, `viewPwdHash` (for hidden profiles), `uiThemes`, `remoteHostId` (Long?), `autoAcceptMemberContacts` (Boolean). + +*See:* `ChatModel.kt:1208` -- `data class User` + +### Contact +A remote contact. Fields: `contactId`, `localDisplayName`, `profile` (LocalProfile), `activeConn` (Connection?), `viaGroup`, `contactUsed`, `contactStatus`, `chatSettings`, `userPreferences`, `mergedPreferences`, `preparedContact`, `contactRequestId`, `contactGroupMemberId`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:1711` -- `data class Contact` + +### GroupInfo +Metadata for a group conversation. Fields: `groupId`, `localDisplayName`, `groupProfile` (GroupProfile), `businessChat` (BusinessChatInfo?), `fullGroupPreferences`, `membership` (GroupMember -- the local user's membership), `chatSettings`, `preparedGroup`, `membersRequireAttention`, `chatTags`, `chatItemTTL`. + +*See:* `ChatModel.kt:2004` -- `data class GroupInfo` + +### GroupMember +A member of a group. Fields: `groupMemberId`, `groupId`, `memberId`, `memberRole` (GroupMemberRole), `memberCategory` (GroupMemberCategory), `memberStatus` (GroupMemberStatus), `memberSettings` (GroupMemberSettings), `blockedByAdmin`, `invitedBy`, `localDisplayName`, `memberProfile`, `memberContactId`, `memberContactProfileId`, `activeConn` (Connection?), `supportChat` (GroupSupportChat?). + +*See:* `ChatModel.kt:2177` -- `data class GroupMember` + +### GroupMemberRole +Enumeration of group roles, ordered for comparison: `Observer` < `Author` < `Member` < `Moderator` < `Admin` < `Owner`. Selectable roles for assignment: Observer, Member, Moderator, Admin, Owner. + +*See:* `ChatModel.kt:2369` -- `enum class GroupMemberRole` + +### Connection +An active or pending cryptographic connection to a peer. Fields: `connId`, `agentConnId`, `peerChatVRange` (VersionRange), `connStatus` (ConnStatus), `connLevel`, `viaGroupLink`, `customUserProfileId`, `connectionCode` (SecurityCode?), `pqSupport`, `pqEncryption`, `pqSndEnabled`, `pqRcvEnabled`, `connectionStats`, `authErrCounter`, `quotaErrCounter`. + +*See:* `ChatModel.kt:1882` -- `data class Connection` + +### Chat +A composite type holding `chatInfo` (ChatInfo), `chatItems` (list of ChatItem), and `chatStats` (ChatStats -- unread count, min unread item ID, etc.). Represents a full conversation for the chat list. + +*See:* `ChatModel.kt` -- `data class Chat` + +### PendingContactConnection +Represents an in-progress connection that has not yet been established. Contains the connection link and state but no contact profile yet. + +*See:* `ChatModel.kt` -- referenced in `ChatInfo.ContactConnection` + +### CryptoFile +A file reference that optionally carries `CryptoFileArgs` (key + nonce) for local encryption. `CryptoFile.plain(path)` creates an unencrypted reference. + +*See:* `ChatModel.kt` -- `data class CryptoFile` + +--- + +## 3. Commands & Events + +The codebase uses short type names for the command/event protocol: `CC` (Chat Command), `CR` (Chat Response -- also carries asynchronous events), `API` (top-level response wrapper), and `ChatError` (error hierarchy). There is no separate "ChatEvent" class; asynchronous events from the core (new messages, connection changes, call signaling) are all `CR` subclasses received via the `recvMsg` loop. + +### CC (Chat Command) +The sealed class representing all commands the app can send to the Haskell core library. Over 140 command variants organized by domain: + +**User management:** `ShowActiveUser`, `CreateActiveUser`, `ListUsers`, `ApiSetActiveUser`, `ApiHideUser`, `ApiUnhideUser`, `ApiMuteUser`, `ApiUnmuteUser`, `ApiDeleteUser` + +**Chat lifecycle:** `StartChat`, `CheckChatRunning`, `ApiStopChat`, `ApiSetAppFilePaths`, `ApiSetEncryptLocalFiles` + +**Database:** `ApiExportArchive`, `ApiImportArchive`, `ApiDeleteStorage`, `ApiStorageEncryption`, `TestStorageEncryption` + +**Messaging:** `ApiSendMessages`, `ApiUpdateChatItem`, `ApiDeleteChatItem`, `ApiDeleteMemberChatItem`, `ApiChatItemReaction`, `ApiForwardChatItems`, `ApiPlanForwardChatItems`, `ApiReportMessage` + +**Groups:** `ApiNewGroup`, `ApiAddMember`, `ApiJoinGroup`, `ApiAcceptMember`, `ApiMembersRole`, `ApiBlockMembersForAll`, `ApiRemoveMembers`, `ApiLeaveGroup`, `ApiListMembers`, `ApiUpdateGroupProfile`, `APICreateGroupLink`, `APIDeleteGroupLink`, `APIGetGroupLink`, `ApiAddGroupShortLink` + +**Connections:** `APIAddContact`, `APIConnect`, `APIConnectPlan`, `APIPrepareContact`, `APIPrepareGroup`, `APIConnectPreparedContact`, `APIConnectPreparedGroup`, `ApiConnectContactViaAddress` + +**Contacts:** `ApiDeleteChat`, `ApiClearChat`, `ApiListContacts`, `ApiUpdateProfile`, `ApiSetContactPrefs`, `ApiSetContactAlias` + +**Address:** `ApiCreateMyAddress`, `ApiDeleteMyAddress`, `ApiShowMyAddress`, `ApiAddMyAddressShortLink`, `ApiSetProfileAddress`, `ApiSetAddressSettings` + +**Calls:** `ApiGetCallInvitations`, `ApiSendCallInvitation`, `ApiRejectCall`, `ApiSendCallOffer`, `ApiSendCallAnswer`, `ApiSendCallExtraInfo`, `ApiEndCall`, `ApiCallStatus` + +**Server config:** `ApiGetServerOperators`, `ApiSetServerOperators`, `ApiGetUserServers`, `ApiSetUserServers`, `ApiValidateServers`, `APITestProtoServer` + +**Network:** `APISetNetworkConfig`, `APIGetNetworkConfig`, `APISetNetworkInfo`, `ReconnectServer`, `ReconnectAllServers` + +**Files:** `ReceiveFile`, `CancelFile`, `ApiUploadStandaloneFile`, `ApiDownloadStandaloneFile`, `ApiStandaloneFileInfo` + +**Remote access:** `SetLocalDeviceName`, `ListRemoteHosts`, `StartRemoteHost`, `SwitchRemoteHost`, `StopRemoteHost`, `DeleteRemoteHost`, `StoreRemoteFile`, `GetRemoteFile`, `ConnectRemoteCtrl`, `FindKnownRemoteCtrl`, `ConfirmRemoteCtrl`, `VerifyRemoteCtrlSession`, `ListRemoteCtrls`, `StopRemoteCtrl`, `DeleteRemoteCtrl` + +**Read status:** `ApiChatRead`, `ApiChatItemsRead`, `ApiChatUnread` + +**Settings:** `APISetChatSettings`, `ApiSetMemberSettings`, `APISetChatItemTTL`, `APIGetChatItemTTL`, `APISetChatTTL`, `ApiSaveSettings`, `ApiGetSettings` + +**Ratchet & verification:** `APISwitchContact`, `APISwitchGroupMember`, `APIAbortSwitchContact`, `APIAbortSwitchGroupMember`, `APISyncContactRatchet`, `APISyncGroupMemberRatchet`, `APIGetContactCode`, `APIGetGroupMemberCode`, `APIVerifyContact`, `APIVerifyGroupMember` + +Each command variant has a `cmdString` property that serializes it to the text protocol consumed by the Haskell FFI. + +*See:* `SimpleXAPI.kt:3529` -- `sealed class CC` + +### CR (Chat Response) +The sealed class representing all responses / events received from the Haskell core. Over 130 response types. Examples: + +- `ActiveUser`, `UsersList` -- user management results +- `ChatStarted`, `ChatRunning`, `ChatStopped` -- lifecycle +- `ApiChats`, `ApiChat` -- chat list data +- `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted` -- message events +- `ContactConnected`, `ContactConnecting`, `ContactSndReady` -- connection lifecycle +- `GroupCreated`, `ReceivedGroupInvitation`, `JoinedGroupMemberConnecting`, `MemberAccepted` -- group events +- `RcvFileStart`, `RcvFileComplete`, `SndFileComplete` -- file transfer progress +- `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` -- call signaling +- `ChatError` -- error wrapper + +*See:* `SimpleXAPI.kt:6114` -- `sealed class CR` + +### API +The top-level response wrapper. Two variants: +- `API.Result(remoteHostId, res: CR)` -- successful response +- `API.Error(remoteHostId, err: ChatError)` -- error response + +Properties: `ok` (Boolean -- true if `CR.CmdOk`), `result` (CR?), `rhId` (Long? -- remote host ID). + +*See:* `SimpleXAPI.kt:5975` -- `sealed class API` + +### ChatError +The error hierarchy returned from the Haskell core: +- `ChatErrorChat(errorType: ChatErrorType)` -- application-level errors (NoActiveUser, UserUnknown, DifferentActiveUser, etc.) +- `ChatErrorAgent(agentError: AgentErrorType)` -- SMP agent errors (BROKER, SMP, PROXY, etc.) +- `ChatErrorStore(storeError: StoreError)` -- database/store errors +- `ChatErrorDatabase(databaseError: DatabaseError)` -- database migration/encryption errors +- `ChatErrorRemoteHost(remoteHostError)` -- remote host control errors +- `ChatErrorRemoteCtrl(remoteCtrlError)` -- remote controller errors +- `ChatErrorInvalidJSON(json)` -- parse failure + +*See:* `SimpleXAPI.kt:6974` -- `sealed class ChatError` + +### sendCmd / recvMsg +The core FFI bridge. `sendCmd(rhId, cmd)` serializes a `CC` command and sends it to the Haskell backend via `chatSendCmd`. `recvMsg(ctrl)` blocks on `chatRecvMsg` to receive the next `API` response/event. The receiver loop runs in `ChatController.startReceiver()` on `Dispatchers.IO`. + +*See:* `SimpleXAPI.kt` -- `ChatController.sendCmd()`, `ChatController.startReceiver()` + +--- + +## 4. Connection & Identity + +### SimpleX Address (User Address) +A long-lived contact address that others can use to send connection requests. Created via `ApiCreateMyAddress`, retrieved via `ApiShowMyAddress`, deleted via `ApiDeleteMyAddress`. Can optionally include a short link (`ApiAddMyAddressShortLink`). Stored as `ChatModel.userAddress` (`UserContactLinkRec`). + +### Contact Link / Connection Link +A one-time or reusable invitation link. The `CreatedConnLink` type wraps the link string. Contact links can be one-time (single use) or long-lived (user address). Created via `APIAddContact` (one-time) or `ApiCreateMyAddress` (reusable). + +### Group Link +A reusable invitation link for joining a group. Created via `APICreateGroupLink(groupId, memberRole)`. The default role for new members joining via the link is configurable. Can also have a short link variant via `ApiAddGroupShortLink`. + +### Short Link +A compact form of a contact or group link. Created via `ApiAddMyAddressShortLink` (for user addresses) or `ApiAddGroupShortLink` (for groups). Short links resolve to the full connection link data including `ContactShortLinkData` or `GroupShortLinkData`. + +### Incognito Mode +When enabled (`AppPreferences.incognito`), the app generates a random profile name for new connections instead of using the user's real profile. Each connection gets a unique random identity. The `customUserProfileId` on a `Connection` tracks which incognito profile is used for that connection. + +*See:* `SimpleXAPI.kt` -- `AppPreferences.incognito`; `ChatModel.kt` -- `Connection.customUserProfileId` + +### Hidden Profile +A user profile protected by a password (`viewPwdHash`). Hidden profiles do not appear in the profile list unless unlocked with the password. Created via `ApiHideUser(userId, viewPwd)`, revealed via `ApiUnhideUser(userId, viewPwd)`. When switching away from a hidden profile, its notifications are cancelled. + +*See:* `SimpleXAPI.kt` -- `CC.ApiHideUser`, `CC.ApiUnhideUser`; `ChatModel.kt` -- `User.viewPwdHash` + +### Connection Verification (Security Code) +Each connection has an optional `SecurityCode` (`Connection.connectionCode`). Users can verify connections out-of-band by comparing security codes displayed via `APIGetContactCode` / `APIGetGroupMemberCode` and confirming via `APIVerifyContact` / `APIVerifyGroupMember`. + +### Connection Plan +Before connecting via a link, `APIConnectPlan` analyzes the link and returns a `ConnectionPlan` indicating whether the link leads to an existing contact, a new contact, a group join, etc. This prevents duplicate connections. + +*See:* `SimpleXAPI.kt` -- `CC.APIConnectPlan`, `CR.CRConnectionPlan` + +### Prepared Contact / Prepared Group +An intermediate state in the connection flow. `APIPrepareContact` / `APIPrepareGroup` creates the local record and displays the contact/group preview before the user confirms the connection. The user can then change the active profile (`APIChangePreparedContactUser` / `APIChangePreparedGroupUser`) and finally confirm via `APIConnectPreparedContact` / `APIConnectPreparedGroup`. + +--- + +## 5. Messaging Features + +### Delivery Receipt +Confirmation that a message was delivered to the recipient's device. Controlled per-user via `sendRcptsContacts` and `sendRcptsSmallGroups` on `User`. The global setting flow is triggered by `ChatModel.setDeliveryReceipts`. Individual overrides per-contact are managed via `ApiSetUserContactReceipts` / `ApiSetUserGroupReceipts`. + +*See:* `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts`; `AppPreferences.privacyDeliveryReceiptsSet` + +### Timed Message (Disappearing Message) +Messages with a time-to-live after which they are automatically deleted. Configured as a `ChatFeature` / `GroupFeature` with a TTL parameter in seconds. The `customDisappearingMessageTime` preference stores the last custom duration used. Per-chat TTL can be set via `APISetChatTTL`. Global TTL via `APISetChatItemTTL`. + +*See:* `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL`; `AppPreferences.customDisappearingMessageTime` + +### Live Message +A message that updates in real-time as the sender types. Controlled by `CC.ApiSendMessages` with `live=true`. The `ComposeState.liveMessage` tracks the current live message being composed. An alert is shown on first use (`AppPreferences.liveMessageAlertShown`). + +### Message Reactions +Emoji reactions on messages. Added/removed via `ApiChatItemReaction(type, id, scope, itemId, add, reaction)`. Reaction members in groups can be queried via `ApiGetReactionMembers`. Each `ChatItem` carries a `reactions: List`. + +### Message Forwarding +Messages can be forwarded between chats. `ApiPlanForwardChatItems` checks feasibility (e.g., file availability), and `ApiForwardChatItems` performs the forward. A `ForwardConfirmation` may be required if files need downloading first. + +### Message Reports +Users can report messages in groups via `ApiReportMessage(groupId, chatItemId, reportReason, reportText)`. Admins can archive (`ApiArchiveReceivedReports`) or delete (`ApiDeleteReceivedReports`) reports. + +### Mentions +In-message mentions of group members. Stored as `mentions: Map` on `ChatItem` and `mentions: MentionedMembers` on `ComposeState`. + +### Link Previews +Automatic preview generation for URLs in messages. Controlled by `AppPreferences.privacyLinkPreviews`. An alert is shown on first use (`privacyLinkPreviewsShowAlert`). + +### Local File Encryption +Files stored on device can be encrypted. Controlled by `AppPreferences.privacyEncryptLocalFiles` and toggled via `CC.ApiSetEncryptLocalFiles(enable)`. + +### Chat Tags +User-defined tags for organizing conversations. CRUD via `ApiCreateChatTag`, `ApiUpdateChatTag`, `ApiDeleteChatTag`, `ApiReorderChatTags`. Assignment via `ApiSetChatTags`. The model tracks `userTags`, `presetTags` (system-defined categories), `unreadTags`, and the active filter (`activeChatTagFilter`). + +--- + +## 6. Calling & Media + +### WebRTC +The real-time communication framework used for audio and video calls. The app uses WebRTC for peer-to-peer media streams, with SMP used only for call signaling (offer/answer/ICE candidates). + +### Call (data class) +Represents an active call session. Fields: `remoteHostId`, `userProfile`, `contact`, `callUUID`, `callState` (CallState enum), `initialCallType` (Audio/Video), `localMediaSources`, `localCapabilities`, `peerMediaSources`, `sharedKey` (for E2E call encryption), `connectionInfo`, `connectedAt`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt:14` + +### CallState +Enum tracking call progression: `WaitCapabilities` -> `InvitationSent` / `InvitationAccepted` -> `OfferSent` / `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. + +### WCallCommand / WCallResponse +The command/response protocol between the Kotlin app and the WebRTC JavaScript layer: +- **Commands:** `Capabilities`, `Permission`, `Start`, `Offer`, `Answer`, `Ice`, `Media`, `Camera`, `Description`, `Layout`, `End` +- **Responses:** `Capabilities`, `Offer`, `Answer`, `Ice`, `Connection`, `Connected`, `PeerMedia`, `End`, `Ended`, `Ok`, `Error` + +*See:* `WebRTC.kt:88` -- `sealed class WCallCommand`; `WebRTC.kt:103` -- `sealed class WCallResponse` + +### CallManager +Manages incoming call invitations and the active call lifecycle. Handles reporting new incoming calls, accepting calls, switching between calls, and ending calls. Interacts with `ChatModel.callInvitations`, `ChatModel.activeCall`, and the platform notification manager. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` + +### Android: CallActivity +A dedicated Android `Activity` that displays the call UI. Launched when accepting an incoming call or initiating an outgoing call. Uses an Android `WebView` to host the WebRTC JavaScript. + +*See:* `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` + +### Android: CallService +An Android foreground `Service` that keeps the call alive when the app is in the background. Holds a `WakeLock`, displays an ongoing call notification, and manages the call lifecycle. Uses notification channel `CALL_SERVICE_NOTIFICATION`. + +*See:* `android/src/main/java/chat/simplex/app/CallService.kt` + +### Desktop: Browser-based WebRTC via NanoWSD +On Desktop, calls are implemented by opening the system browser to a locally-hosted WebSocket server. A `NanoHTTPD`/`NanoWSD` server runs on `localhost:50395`, serving the WebRTC call page and communicating with the Kotlin app via WebSocket messages. Commands are sent as JSON-serialized `WVAPICall` objects; responses are parsed as `WVAPIMessage` objects. + +*See:* `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` + +### ICE Servers +STUN/TURN servers used for WebRTC NAT traversal. Configurable via `AppPreferences.webrtcIceServers`. The relay policy (`AppPreferences.webrtcPolicyRelay`) controls whether calls must use TURN relays (for IP privacy) or can attempt direct connections. + +### CallMediaType +Enum: `Video`, `Audio`. Determines the initial media type of the call. + +### CallMediaSource +Enum: `Mic`, `Camera`, `ScreenAudio`, `ScreenVideo`. Used in `WCallCommand.Media` to toggle individual media streams. + +--- + +## 7. Notifications & Background + +### Android: SimplexService +A foreground `Service` that keeps the chat backend running in the background. Uses a `WakeLock` and displays a persistent notification ("SimpleX Chat service" channel). Started with `START_STICKY` for automatic restart. Manages the `chatRecvMsg` loop indirectly by keeping the process alive. + +Notification channel: `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` ("SimpleX Chat service") + +*See:* `android/src/main/java/chat/simplex/app/SimplexService.kt` + +### Android: MessagesFetcherWorker +A `WorkManager` periodic worker that wakes the app to fetch new messages when the foreground service is not running (i.e., when `NotificationsMode` is `PERIODIC`). Provides a battery-friendly alternative to the always-on service. + +*See:* `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` + +### Android: NotificationsMode +Enum controlling background message fetching: +- `OFF` -- no background activity; messages received only when app is open +- `PERIODIC` -- uses `MessagesFetcherWorker` for periodic fetches +- `SERVICE` -- uses `SimplexService` foreground service (default) + +*See:* `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +### Android: Notification Channels +Android notification channels registered by the app: +- **Messages:** `chat.simplex.app.MESSAGE_NOTIFICATION` -- high importance, for incoming messages +- **Calls:** `chat.simplex.app.CALL_NOTIFICATION_2` -- high importance, for incoming call alerts with custom sound +- **Service:** `chat.simplex.app.SIMPLEX_SERVICE_NOTIFICATION` -- low importance, persistent foreground service indicator +- **Call Service:** `chat.simplex.app.CALL_SERVICE_NOTIFICATION` -- default importance, ongoing call indicator + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt`, `SimplexService.kt`, `CallService.kt` + +### Android: NtfManager +The Android-specific notification manager. Handles creating notification channels, displaying message notifications (with grouping via `MessageGroup`), displaying incoming call notifications (with full-screen intent for lock-screen calls), and managing notification actions (accept/reject call, open chat). + +*See:* `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` + +### Desktop: System Notifications +On Desktop, notifications use the system notification mechanism (typically via the JVM's `SystemTray` or platform-specific notification APIs). The notification manager interface is shared (`ntfManager`) but the implementation is platform-specific. + +### NotificationPreviewMode +Controls what information appears in notifications: +- `HIDDEN` -- no message content +- `CONTACT` -- shows sender name only +- `MESSAGE` -- shows sender name and message preview (default) + +*See:* `ChatModel.kt:4823` -- `enum class NotificationPreviewMode` + +### Wake Lock Management +In `ChatController.startReceiver()`, each received message acquires a wake lock (via `getWakeLock(timeout=60000)`) that is released after 30 seconds. This ensures the device stays awake long enough to process incoming messages and display notifications, particularly for incoming calls. + +--- + +## 8. Application Architecture + +### ChatController +The singleton controller that bridges the Kotlin UI layer and the Haskell core library. Responsibilities: +- Manages the `chatCtrl` (FFI handle to the Haskell runtime) +- Sends commands via `sendCmd()` and receives events via the `startReceiver()` coroutine loop +- Processes received messages in `processReceivedMsg()` +- Holds a reference to `AppPreferences` and `ChatModel` +- Provides the `messagesChannel` (Kotlin coroutine `Channel`) for consumers to observe events +- Manages retry logic for transient network errors (`sendCmdWithRetry`) + +*See:* `SimpleXAPI.kt:493` -- `object ChatController` + +### ChatModel +The singleton reactive state container for the entire app. Uses Compose `mutableStateOf` and `mutableStateListOf` for reactive UI updates. Key state: +- `currentUser` -- the active user profile +- `users` -- list of all user profiles (`UserInfo`) +- `chatsContext` / `secondaryChatsContext` -- `ChatsContext` holding the chat list +- `chatId` -- currently open chat +- `groupMembers` -- members of the currently viewed group +- `callInvitations` -- pending incoming call invitations +- `activeCall` -- the currently active call +- `userAddress` -- the user's SimpleX address +- `chatItemTTL` -- global message TTL setting +- `userTags` -- chat tags +- `terminalItems` -- debug terminal log items +- Various UI state flags (`showCallView`, `switchingUsersAndHosts`, `clearOverlays`, etc.) + +*See:* `ChatModel.kt:86` -- `object ChatModel` + +### AppPreferences +A class wrapping platform-specific key-value storage (`Settings` from `com.russhwolf.settings`). On Android, backed by `SharedPreferences`. On Desktop, backed by Java `Properties` files. Provides type-safe accessors for all user preferences. + +*See:* `SimpleXAPI.kt:94` -- `class AppPreferences` + +### ComposeState +Data class holding the state of the message composition area. Fields: `message` (ComposeMessage), `parsedMessage` (formatted text), `liveMessage`, `preview` (ComposePreview), `contextItem` (ComposeContextItem -- reply/edit context), `inProgress`, `progressByTimeout`, `useLinkPreviews`, `mentions`. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:98` + +### ModalManager +Manages the modal/sheet presentation stack. Supports multiple placements (default, center, fullscreen, end). Holds an ordered list of `ModalViewHolder` items and exposes `showModal`, `showCustomModal`, `showModalCloseable`, `closeModal`. Uses Compose state (`modalCount`) to trigger recomposition. + +*See:* `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt:92` + +### AlertManager +Singleton for displaying alert dialogs. Provides `showAlertMsg`, `showAlertDialog`, `showAlertDialogButtons`, etc. Works with `AlertManager.shared` for the default instance. + +### ChatsContext +Holds the chat list state for a particular scope (main or secondary). Manages `chats` (State>), provides `updateChats()` to refresh, and supports filtering/keeping specific chats during updates. + +### ConnectProgressManager +Tracks and displays connection progress in the UI. Methods: `startConnectProgress(text, onCancel)`, `stopConnectProgress()`, `cancelConnectProgress()`. Exposes `showConnectProgress` (nullable string indicating active progress text). + +*See:* `ChatModel.kt:48` -- `object ConnectProgressManager` + +### withBGApi / withLongRunningApi +Utility functions for launching coroutines on background threads. Used throughout the codebase to perform API calls without blocking the UI thread. + +--- + +## 9. Configuration & Preferences + +### AppPreferences (Storage) +All preferences are accessed through `ChatController.appPrefs`, which is a lazy-initialized `AppPreferences` instance. The underlying storage is: +- **Android:** `SharedPreferences` with ID `chat.simplex.app.SIMPLEX_APP_PREFS` +- **Desktop:** Java `Properties` files via `com.russhwolf.settings` + +Theme overrides have separate storage (`SHARED_PREFS_THEMES_ID`). + +### SharedPreference +A generic wrapper providing `get()` and `set(value)` for a single preference. All `AppPreferences` fields are `SharedPreference` instances created by factory methods (`mkBoolPreference`, `mkStrPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`). + +### Key Preference Categories + +**Notifications:** +- `notificationsMode` -- OFF / PERIODIC / SERVICE +- `notificationPreviewMode` -- HIDDEN / CONTACT / MESSAGE +- `canAskToEnableNotifications` -- gate for the notification prompt + +**Privacy:** +- `privacyProtectScreen` -- prevents screenshots (Android FLAG_SECURE) +- `privacyAcceptImages` -- auto-accept inline images +- `privacyLinkPreviews` -- generate URL previews +- `privacySanitizeLinks` -- strip tracking parameters from URLs +- `privacyShowChatPreviews` -- show message preview in chat list +- `privacySaveLastDraft` -- persist draft messages +- `privacyEncryptLocalFiles` -- encrypt files at rest +- `privacyAskToApproveRelays` -- prompt before using relays suggested by contacts +- `privacyMediaBlurRadius` -- blur radius for media in notifications/previews + +**Security:** +- `performLA` -- require local authentication (biometric/PIN) +- `laMode` -- local authentication mode +- `laLockDelay` -- seconds before re-locking +- `storeDBPassphrase` -- whether to persist the DB passphrase +- `initialRandomDBPassphrase` -- indicates the DB uses a random (non-user-chosen) passphrase +- `selfDestruct` -- enable self-destruct profile +- `selfDestructDisplayName` -- display name for the self-destruct profile + +**Network:** +- `networkUseSocksProxy` -- route traffic through SOCKS proxy +- `networkProxy` -- SOCKS proxy host/port configuration +- `networkSessionMode` -- transport session multiplexing mode +- `networkSMPProxyMode` -- SMP proxy / private routing mode +- `networkSMPProxyFallback` -- fallback behavior when proxy fails +- `networkHostMode` -- onion/public host preference +- `networkRequiredHostMode` -- enforce host mode strictly +- Various TCP timeout settings (background, interactive, per-KB) +- Keep-alive settings (idle, interval, count) + +**Calls:** +- `webrtcPolicyRelay` -- force TURN relay usage +- `callOnLockScreen` -- DISABLE / SHOW / ACCEPT calls on lock screen +- `webrtcIceServers` -- custom ICE server configuration +- `experimentalCalls` -- enable experimental call features + +**Appearance:** +- `currentTheme` -- active theme name +- `systemDarkTheme` -- theme for system dark mode +- `themeOverrides` -- per-theme customizations +- `profileImageCornerRadius` -- avatar rounding +- `chatItemRoundness` -- message bubble rounding +- `chatItemTail` -- show/hide message bubble tail +- `fontScale` -- text size scaling +- `densityScale` -- UI density scaling +- `inAppBarsAlpha` -- toolbar transparency +- `appearanceBarsBlurRadius` -- toolbar blur effect + +**UI:** +- `oneHandUI` -- one-handed UI mode (bottom-aligned navigation) +- `chatBottomBar` -- show bottom bar in chat view +- `simplexLinkMode` -- how SimpleX links are displayed (DESCRIPTION / FULL / BROWSER) +- `showUnreadAndFavorites` -- filter chat list to unread/favorites +- `developerTools` -- enable developer tools (terminal, etc.) + +**Database:** +- `encryptedDBPassphrase` -- encrypted form of the DB passphrase +- `initializationVectorDBPassphrase` -- IV for DB passphrase encryption +- `encryptionStartedAt` -- timestamp of encryption operation start (for crash recovery) +- `confirmDBUpgrades` -- prompt before database migrations +- `newDatabaseInitialized` -- flag for incomplete initialization recovery + +**Remote Access:** +- `deviceNameForRemoteAccess` -- device display name for remote control +- `confirmRemoteSessions` -- require confirmation for remote sessions +- `connectRemoteViaMulticast` -- use multicast discovery +- `connectRemoteViaMulticastAuto` -- auto-connect via multicast +- `desktopWindowState` -- persisted window position/size (Desktop only) + +**Migration:** +- `migrationToStage` / `migrationFromStage` -- track migration progress +- `onboardingStage` -- current onboarding step +- `lastMigratedVersionCode` -- last app version that ran migrations + +*See:* `SimpleXAPI.kt:94-489` -- `class AppPreferences` with all `SHARED_PREFS_*` constants diff --git a/apps/multiplatform/product/rules.md b/apps/multiplatform/product/rules.md new file mode 100644 index 0000000000..90a2dadada --- /dev/null +++ b/apps/multiplatform/product/rules.md @@ -0,0 +1,253 @@ +# Business Rules -- SimpleX Chat (Android & Desktop, Kotlin Multiplatform) + +This document specifies invariants enforced by the Android and Desktop (Kotlin/Compose Multiplatform) clients. + +--- + +## Table of Contents + +1. [Security (RULE-01 through RULE-05)](#1-security) +2. [Message Integrity (RULE-06 through RULE-09)](#2-message-integrity) +3. [Group Integrity (RULE-10 through RULE-13)](#3-group-integrity) +4. [File Transfer (RULE-14 through RULE-15)](#4-file-transfer) +5. [Notification Delivery (RULE-16 through RULE-17)](#5-notification-delivery) +6. [Call Integrity (RULE-18)](#6-call-integrity) + +--- + +## 1. Security + +### RULE-01: End-to-End Encryption is Mandatory + +**Invariant:** Every message, file chunk, and call signaling payload MUST be encrypted end-to-end before transmission. The app MUST NOT transmit plaintext content to any relay server. + +**Enforcement:** The Haskell core library handles all encryption. The Kotlin layer never constructs raw SMP messages. All communication flows through `ChatController.sendCmd()` which delegates to the FFI, ensuring the encryption layer cannot be bypassed. + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` -- `ChatController.sendCmd()`, `chatSendCmd()` FFI call + +--- + +### RULE-02: Database Encryption at Rest + +**Invariant:** The local SQLite database MUST be encrypted. A passphrase (either user-chosen or randomly generated) MUST be set before the database is operational. + +**Enforcement:** On first launch, a random passphrase is generated and stored encrypted via the platform keystore (`CryptorInterface.encryptText`). The `initialRandomDBPassphrase` preference tracks whether the user has set a custom passphrase. Database encryption state is tracked in `ChatModel.chatDbEncrypted`. Encryption/re-encryption is performed via `CC.ApiStorageEncryption(config: DBEncryptionConfig)`. + +**Caveat:** The user is not forced to set a custom passphrase -- the random passphrase is stored in app-accessible encrypted preferences. See GAP: "Database passphrase not enforced." + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` -- `CryptorInterface` +- Android: `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` -- Android Keystore +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` -- **placeholder, not implemented** + +--- + +### RULE-03: Local Authentication Gating + +**Invariant:** When local authentication is enabled (`AppPreferences.performLA == true`), the app MUST require biometric/PIN authentication before displaying any chat content. The lock engages after `laLockDelay` seconds of inactivity. + +**Enforcement:** `AppLock.setPerformLA` controls the lock state. The lock delay is configurable via `AppPreferences.laLockDelay` (default 30 seconds). Authentication mode is set via `AppPreferences.laMode` (system biometric or passcode). + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt` +- `SimpleXAPI.kt` -- `AppPreferences.performLA`, `AppPreferences.laMode`, `AppPreferences.laLockDelay` + +--- + +### RULE-04: Self-Destruct Profile + +**Invariant:** When self-destruct is enabled (`AppPreferences.selfDestruct == true`), entering the self-destruct passphrase instead of the real passphrase MUST wipe the database and present a clean profile with `selfDestructDisplayName`. + +**Enforcement:** The self-destruct passphrase is stored separately (`encryptedSelfDestructPassphrase` / `initializationVectorSelfDestructPassphrase`). On Android, `SimplexService` checks for self-destruct on initialization. The comparison happens during the local authentication flow. + +**Location:** +- `SimpleXAPI.kt` -- `AppPreferences.selfDestruct`, `AppPreferences.selfDestructDisplayName` +- `android/src/main/java/chat/simplex/app/SimplexService.kt` -- initialization check + +--- + +### RULE-05: Screen Protection + +**Invariant:** When `AppPreferences.privacyProtectScreen == true` (default), the app MUST prevent screenshots and screen recording. On Android this uses `FLAG_SECURE`; on Desktop this is advisory only. + +**Enforcement:** The preference defaults to `true`. The Android activity applies `FLAG_SECURE` to its window based on this preference. The Desktop app cannot enforce this at the OS level. + +**Location:** `SimpleXAPI.kt` -- `AppPreferences.privacyProtectScreen` + +--- + +## 2. Message Integrity + +### RULE-06: Message Ordering Verification + +**Invariant:** The app MUST detect and surface message integrity violations (gaps, duplicates, out-of-order delivery) to the user. + +**Enforcement:** The Haskell core tracks message sequence numbers per connection. When a gap or integrity error is detected, a `CIContent.RcvIntegrityError(msgError: MsgErrorType)` chat item is inserted into the conversation. The UI renders these as system messages indicating the integrity issue. + +**Location:** `ChatModel.kt:3565` -- `CIContent.RcvIntegrityError` + +--- + +### RULE-07: Decryption Error Surfacing + +**Invariant:** When a message cannot be decrypted, the app MUST display a `RcvDecryptionError` item showing the error type and count of affected messages. The app MUST NOT silently drop undecryptable messages. + +**Enforcement:** The Haskell core emits `CIContent.RcvDecryptionError(msgDecryptError, msgCount)` which the UI renders with an explanation and count. Ratchet re-synchronization can be triggered via `APISyncContactRatchet` / `APISyncGroupMemberRatchet`. + +**Location:** `ChatModel.kt:3566` -- `CIContent.RcvDecryptionError` + +--- + +### RULE-08: Delivery Receipt Consistency + +**Invariant:** Delivery receipt settings MUST be consistent: when a user enables/disables receipts globally, the change MUST propagate to all contacts/groups (optionally clearing per-chat overrides via `clearOverrides`). + +**Enforcement:** Global receipt toggle triggers `CC.SetAllContactReceipts(enable)`. Per-type settings use `CC.ApiSetUserContactReceipts` / `CC.ApiSetUserGroupReceipts` with `UserMsgReceiptSettings(enable, clearOverrides)`. The `privacyDeliveryReceiptsSet` preference gates the initial setup prompt shown during onboarding. + +**Location:** +- `SimpleXAPI.kt` -- `CC.SetAllContactReceipts`, `CC.ApiSetUserContactReceipts`, `CC.ApiSetUserGroupReceipts` +- `SimpleXAPI.kt` -- `ChatController.startChat()` -- triggers `setDeliveryReceipts` prompt + +--- + +### RULE-09: Chat Item TTL Enforcement + +**Invariant:** When a chat item TTL (time-to-live) is set globally or per-chat, expired messages MUST be deleted by the core. The app MUST NOT display expired items. + +**Enforcement:** Global TTL set via `CC.APISetChatItemTTL(userId, seconds)`. Per-chat TTL set via `CC.APISetChatTTL(userId, chatType, id, seconds)`. The Haskell core performs periodic cleanup. The current global TTL is stored in `ChatModel.chatItemTTL`. + +**Location:** `SimpleXAPI.kt` -- `CC.APISetChatItemTTL`, `CC.APISetChatTTL` + +--- + +## 3. Group Integrity + +### RULE-10: Role-Based Access Control + +**Invariant:** Group operations MUST respect the member's role. Only members with sufficient role level can perform privileged operations: +- **Owner:** can delete group, change any member's role, transfer ownership +- **Admin:** can add/remove members, change roles (up to Admin), create/delete group links +- **Moderator:** can delete other members' messages, block members +- **Member / Author / Observer:** cannot perform administrative actions + +**Enforcement:** The Haskell core validates role permissions server-side. The Kotlin UI layer uses `GroupMemberRole` comparisons (the enum is ordered: Observer < Author < Member < Moderator < Admin < Owner) to show/hide action buttons. + +**Location:** `ChatModel.kt:2369` -- `enum class GroupMemberRole`; various group management views + +--- + +### RULE-11: Group Member Removal Atomicity + +**Invariant:** When removing members from a group, the removal command MUST specify all member IDs atomically. Partial removal MUST NOT leave the group in an inconsistent state. + +**Enforcement:** `CC.ApiRemoveMembers(groupId, memberIds: List, withMessages: Boolean)` sends all member IDs in a single command. The `withMessages` flag controls whether the removed members' messages are also deleted. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiRemoveMembers` + +--- + +### RULE-12: Group Link Role Default + +**Invariant:** When creating a group link, the default member role for joiners MUST be explicitly specified. The role can be updated after creation without regenerating the link. + +**Enforcement:** `CC.APICreateGroupLink(groupId, memberRole)` requires a role. `CC.APIGroupLinkMemberRole(groupId, memberRole)` updates it. The link itself remains stable. + +**Location:** `SimpleXAPI.kt` -- `CC.APICreateGroupLink`, `CC.APIGroupLinkMemberRole` + +--- + +### RULE-13: Member Blocking Scope + +**Invariant:** Blocking a member (`ApiBlockMembersForAll`) MUST apply the block for all group members (not just the requester). The `blocked` flag is visible to all members. Only roles >= Moderator can block. + +**Enforcement:** `CC.ApiBlockMembersForAll(groupId, memberIds, blocked)` sends the block/unblock to the core, which propagates it to all group members. + +**Location:** `SimpleXAPI.kt` -- `CC.ApiBlockMembersForAll`; `ChatModel.kt` -- `GroupMember.blockedByAdmin` + +--- + +## 4. File Transfer + +### RULE-14: File Encryption in Transit and at Rest + +**Invariant:** Files sent via XFTP MUST be encrypted before upload. Files received MUST be decrypted only after download. When `privacyEncryptLocalFiles` is enabled (default `true`), files stored locally MUST be encrypted with per-file keys (`CryptoFile.cryptoArgs`). + +**Enforcement:** The Haskell core handles XFTP encryption. Local file encryption is toggled via `CC.ApiSetEncryptLocalFiles(enable)`. The `CryptoFile` type carries optional `CryptoFileArgs` (key + nonce) for local decryption. Files are decrypted on-demand for display via `decryptCryptoFile()`. + +**Location:** +- `SimpleXAPI.kt` -- `CC.ApiSetEncryptLocalFiles`, `AppPreferences.privacyEncryptLocalFiles` +- `ChatModel.kt` -- `CryptoFile`, `CryptoFileArgs` +- `RecAndPlay.desktop.kt` -- `decryptCryptoFile()` usage in audio playback + +--- + +### RULE-15: Relay Approval for File Transfer + +**Invariant:** When `privacyAskToApproveRelays` is enabled (default `true`), the app MUST prompt the user before using XFTP relay servers suggested by contacts (as opposed to the user's own configured servers). The `userApprovedRelays` flag on `CC.ReceiveFile` records the user's consent. + +**Enforcement:** `CC.ReceiveFile(fileId, userApprovedRelays, encrypt, inline)` passes the approval flag. The UI prompts the user when the file is from an unapproved relay. + +**Location:** `SimpleXAPI.kt` -- `CC.ReceiveFile`, `AppPreferences.privacyAskToApproveRelays` + +--- + +## 5. Notification Delivery + +### RULE-16: Background Message Delivery (Android) + +**Invariant:** On Android, when `NotificationsMode.SERVICE` is selected (default), the app MUST maintain a foreground service (`SimplexService`) to ensure continuous message delivery. The service MUST survive app backgrounding and device sleep. When `NotificationsMode.PERIODIC` is selected, `MessagesFetcherWorker` MUST periodically wake and fetch messages. When `NotificationsMode.OFF`, no background delivery occurs. + +**Enforcement:** +- `SimplexService` runs as a foreground service with `START_STICKY` and a `WakeLock`. It displays a persistent notification on the `SIMPLEX_SERVICE_NOTIFICATION` channel. +- `MessagesFetcherWorker` is a `PeriodicWorkRequest` scheduled via `WorkManager`. +- The mode is stored in `AppPreferences.notificationsMode` and checked at app startup. + +**Location:** +- `android/src/main/java/chat/simplex/app/SimplexService.kt` +- `android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt` +- `SimpleXAPI.kt:7739` -- `enum class NotificationsMode` + +--- + +### RULE-17: Notification Preview Privacy + +**Invariant:** Notification content MUST respect `notificationPreviewMode`: +- `HIDDEN` -- notification shows no sender or message content +- `CONTACT` -- notification shows sender name only +- `MESSAGE` -- notification shows sender name and message preview + +**Enforcement:** `NtfManager` (Android) reads the preview mode from `AppPreferences.notificationPreviewMode` and constructs notifications accordingly. The `CallService` also respects this mode for call notifications (showing or hiding caller identity). + +**Location:** +- `android/src/main/java/chat/simplex/app/model/NtfManager.android.kt` -- `displayNotification()`, `notifyCallInvitation()` +- `android/src/main/java/chat/simplex/app/CallService.kt` -- `updateNotification()` +- `SimpleXAPI.kt` -- `AppPreferences.notificationPreviewMode` + +--- + +## 6. Call Integrity + +### RULE-18: Call Lifecycle Management + +**Invariant:** An active call MUST be properly managed across the full lifecycle: +1. **Incoming calls** MUST be reported via `CallManager.reportNewIncomingCall()` which triggers a notification (and on Android, a full-screen intent for lock-screen display). +2. **Only one call** can be active at a time. Accepting a new call MUST end any existing call first (`CallManager.acceptIncomingCall` checks `activeCall` and calls `endCall` if needed, guarded by `switchingCall` flag). +3. **Call state** MUST progress through defined states: `WaitCapabilities` -> `InvitationSent`/`InvitationAccepted` -> `OfferSent`/`OfferReceived` -> `Negotiated` -> `Connected` -> `Ended`. +4. **Call end** MUST clean up all resources: send `WCallCommand.End`, call `apiEndCall`, clear `activeCall`, cancel call notifications, and release platform resources. + +**Android enforcement:** +- `CallService` (foreground service) keeps the call alive in background with a `WakeLock` and ongoing notification on `CALL_SERVICE_NOTIFICATION` channel. +- `CallActivity` hosts the WebRTC WebView. +- Lock-screen behavior controlled by `AppPreferences.callOnLockScreen` (DISABLE / SHOW / ACCEPT). + +**Desktop enforcement:** +- Calls run in the system browser via the NanoWSD WebSocket server on `localhost:50395`. +- The `WebRTCController` composable manages the WebSocket lifecycle. +- On dispose, `WCallCommand.End` is sent and the server is stopped. + +**Location:** +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt` +- `common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt` +- Android: `android/src/main/java/chat/simplex/app/CallService.kt`, `android/src/main/java/chat/simplex/app/views/call/CallActivity.kt` +- Desktop: `common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt` diff --git a/apps/multiplatform/product/views/call.md b/apps/multiplatform/product/views/call.md new file mode 100644 index 0000000000..51d323874c --- /dev/null +++ b/apps/multiplatform/product/views/call.md @@ -0,0 +1,115 @@ +# Audio / Video Call + +> **Related spec:** [spec/services/calls.md](../../spec/services/calls.md) + +## Purpose + +Make and receive end-to-end encrypted audio and video calls over WebRTC. The implementation differs significantly between Android (WebView-based with `CallActivity` and PiP support) and Desktop (browser-based WebRTC via NanoHTTPD server on localhost). + +## Route / Navigation + +- **Entry point (outgoing)**: Tap audio or video call button in `ChatInfoView` action buttons or `ChatView` toolbar +- **Entry point (incoming)**: `IncomingCallAlertView` banner appears at top of screen +- **Presented by**: `ActiveCallView()` (expect/actual composable) is shown when `chatModel.showCallView == true` +- **Dismiss**: Call ends when user taps end button or remote party disconnects; `callManager.endCall()` handles cleanup +- **Android PiP**: Call view supports picture-in-picture mode via `CallActivity` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| WebRTC host | `WebView` with `WebViewAssetLoader` serving local assets | NanoHTTPD server on `localhost:50395` opened in system browser | +| Call activity | `CallActivity` (separate Android Activity) with lifecycle management | Inline composable with `WebRTCController` | +| PiP support | Native Android PiP via `CallActivity` | Not supported | +| Audio management | `CallAudioDeviceManager` with Android `AudioManager`, proximity wake lock | System browser audio routing | +| WebSocket | N/A | `NanoWSD` WebSocket server for bidirectional WebRTC signaling | + +## Page Sections + +### Incoming Call Banner (`IncomingCallAlertView`) + +Displayed as an overlay banner when `chatModel.activeCallInvitation` is set: + +| Element | Description | +|---|---| +| User profile image | Shown when multiple profiles exist (32dp `ProfileImage`) | +| Call type icon | `ic_videocam_filled` (green) for video, `ic_call_filled` (green) for audio | +| Call type text | `invitation.callTypeText` with caller info | +| Caller profile | `ProfilePreview` showing caller name and avatar (64dp) | +| Reject button | Red `ic_call_end_filled` icon -- ends the invitation via `callManager.endCall(invitation)` | +| Ignore button | Blue `ic_close` icon -- dismisses banner, cancels notification | +| Accept button | Green `ic_check_filled` icon -- accepts via `callManager.acceptIncomingCall(invitation)` | + +Sound: `SoundPlayer.start()` plays ringtone while banner is visible (unless call view is already showing). + +### Active Call View + +#### Android (`CallView.android.kt`) + +| Element | Description | +|---|---| +| WebView | `AndroidView` wrapping a `WebView` that loads `call.html` via `WebViewAssetLoader`; handles WebRTC JS bridge | +| `ActiveCallState` | Manages proximity lock (`PROXIMITY_SCREEN_OFF_WAKE_LOCK`), audio device manager, call sounds | +| Call controls overlay | Mic toggle, speaker toggle, camera switch, video toggle, end call button | +| Audio device selection | `CallAudioDeviceManager` with device enumeration (earpiece, speaker, Bluetooth, wired headset) | +| Permissions | Runtime permission checks for `CAMERA` and `RECORD_AUDIO` via Accompanist permissions library | + +#### Desktop (`CallView.desktop.kt`) + +| Element | Description | +|---|---| +| NanoHTTPD server | HTTP server on `localhost:50395` serving `call.html` and assets | +| NanoWSD WebSocket | WebSocket endpoint for bidirectional signaling between Kotlin and browser JS | +| `WebRTCController` | Processes `WCallCommand`/`WCallResponse` messages via `chatModel.callCommand` channel | +| Browser launch | `LocalUriHandler.openUri("http://localhost:50395/call.html")` opens system browser | +| Connection list | `connections: ArrayList` tracks active WebSocket connections | + +### WebRTC Signaling Flow + +| Step | Command/Response | Description | +|---|---|---| +| 1. Capabilities | `WCallResponse.Capabilities` | Local capabilities reported; `apiSendCallInvitation()` called | +| 2. Offer | `WCallResponse.Offer` | SDP offer + ICE candidates sent via `apiSendCallOffer()` | +| 3. Answer | `WCallResponse.Answer` | SDP answer + ICE candidates sent via `apiSendCallAnswer()` | +| 4. ICE | `WCallResponse.Ice` | Additional ICE candidates exchanged via `apiSendCallExtraInfo()` | +| 5. Connection | `WCallResponse.Connection` | WebRTC connection state changes; `CallState.Connected` set on success | +| 6. Connected | `WCallResponse.Connected` | Connection info (relay/direct) stored in `call.connectionInfo` | +| 7. PeerMedia | `WCallResponse.PeerMedia` | Remote party media source changes (mic, camera, screen) | +| 8. Media control | `WCallCommand.Media` | Toggle local media sources (mic, camera, screen audio/video) | +| 9. Camera switch | `WCallCommand.Camera` | Switch between front/back camera | +| 10. End | `WCallResponse.End` / `WCallResponse.Ended` | Call termination; cleanup and UI dismissal | + +### Call States (`CallState`) + +| State | Description | +|---|---| +| `WaitCapabilities` | Waiting for WebRTC capabilities | +| `InvitationSent` | Call invitation sent to remote party | +| `InvitationAccepted` | Callee accepted, starting WebRTC | +| `OfferSent` | SDP offer sent | +| `OfferReceived` | Callee received SDP offer | +| `AnswerReceived` | Caller received SDP answer | +| `Negotiated` | ICE negotiation complete | +| `Connected` | WebRTC media flowing; `connectedAt` timestamp set | +| `Ended` | Call terminated | + +### Call Sounds + +| Sound | Trigger | +|---|---| +| Connecting sound | `CallSoundsPlayer.startConnectingCallSound()` after invitation sent | +| In-call sound | `CallSoundsPlayer.startInCallSound()` when delivery receipt received | +| Ringtone | `SoundPlayer.start()` for incoming calls | +| End vibration | `CallSoundsPlayer.vibrate()` on call end (if was connected) | + +## Source Files + +| File | Path | +|---|---| +| `CallView.kt` | `views/call/CallView.kt` (common expect declarations) | +| `CallView.android.kt` | `androidMain/.../views/call/CallView.android.kt` | +| `CallView.desktop.kt` | `desktopMain/.../views/call/CallView.desktop.kt` | +| `IncomingCallAlertView.kt` | `views/call/IncomingCallAlertView.kt` | +| `CallManager.kt` | `views/call/CallManager.kt` | +| `WebRTC.kt` | `views/call/WebRTC.kt` | +| `CallAudioDeviceManager.kt` | `androidMain/.../views/call/CallAudioDeviceManager.kt` | diff --git a/apps/multiplatform/product/views/chat-list.md b/apps/multiplatform/product/views/chat-list.md new file mode 100644 index 0000000000..daa7907c5d --- /dev/null +++ b/apps/multiplatform/product/views/chat-list.md @@ -0,0 +1,136 @@ +# Chat List (Home Screen) + +> **Related spec:** [spec/client/chat-list.md](../../spec/client/chat-list.md) + +## Purpose + +Main screen of the SimpleX Chat Android and Desktop apps. Displays all conversations sorted by last activity, serves as the navigation root, and provides access to user profiles, settings, and new chat creation. + +## Route / Navigation + +- **Entry point**: App launch (root view), or back-navigation from any chat +- **Presented by**: `ChatListView` composable as the default view when `chatModel.chatId == null` +- **Navigation**: `ChatListNavLinkView` handles click routing to `ChatView` for each chat type +- **UserPicker**: Triggered by tapping the user avatar in the toolbar; presents `UserPicker` as a custom sheet (Android: bottom sheet overlay; Desktop: sidebar panel) + +## Platform Layout + +| Platform | Layout | +|---|---| +| Android | Single-column list; toolbar at top or bottom (one-hand UI); FAB for new chat | +| Desktop | 3-column layout: chat list (left), chat view (center), info/detail panel (right via `ModalManager.end`) | + +## Page Sections + +### Toolbar (`ChatListToolbar`) + +| Element | Location | Behavior | +|---|---|---| +| User avatar button | Leading | Opens `UserPicker` sheet (profile switcher, address, settings, preferences, connect to desktop/mobile) | +| "Your chats" title | Center | Tappable to scroll list to top | +| Connection status indicator (`SubscriptionStatusIndicator`) | Adjacent to title | Shows SMP server subscription status; taps open `ServersSummaryView` | +| New chat button (pencil icon) | Trailing (one-hand UI) or FAB (standard) | Opens `NewChatSheet` modal via `showNewChatSheet()` | +| Active call indicator | Trailing (Desktop, one-hand UI) | `ActiveCallInteractiveArea` shown when a call is active | +| Updating progress | Trailing | Shows progress circle/indicator during database updates | +| Stopped indicator | Trailing | Red warning icon when chat engine is stopped | + +The toolbar supports two layout modes controlled by `appPrefs.oneHandUI`: +- **Standard (top)**: `DefaultAppBar` at top with `NavigationButtonMenu` leading, title center, buttons trailing. FAB at bottom-right for new chat. +- **One-hand UI (bottom)**: Toolbar at bottom of screen with `Column(Modifier.align(Alignment.BottomCenter))`; list rendered with `reverseLayout = true`; no FAB (new chat button is inline in toolbar). + +### Search Bar (`ChatListSearchBar`) + +| Element | Description | +|---|---| +| Search icon | Magnifying glass icon at leading edge | +| Text field | `SearchTextField` with placeholder "Search or paste SimpleX link" | +| Filter button | `ToggleFilterEnabledButton` (filter icon) toggles unread-only filter; shown when search text is empty | +| Clear button | Appears when text is entered; `BackHandler` clears search on back | + +Behavior: +- Filters chat list in real-time by contact/group name via `filteredChats()` +- Detects pasted SimpleX links (`strHasSingleSimplexLink`) and triggers `planAndConnect()` connection dialogue +- In one-hand UI mode, search bar appears below tag filters with IME spacer; in standard mode, above tag filters + +### Chat Filter Tags (`TagsView`) + +Managed by `chatModel.userTags`, `chatModel.presetTags`, and `chatModel.activeChatTagFilter`: + +| Filter | `PresetTagKind` | Icon | Description | +|---|---|---|---| +| Group Reports | `GROUP_REPORTS` | Flag | Chats with moderation reports (non-collapsible) | +| Favorites | `FAVORITES` | Star | User-favorited chats | +| Contacts | `CONTACTS` | Person | Direct contacts and contact requests | +| Groups | `GROUPS` | Group | Group conversations (non-business) | +| Business | `BUSINESS` | Work | Business chat conversations | +| Notes | `NOTES` | Folder | Notes to self | +| Custom tags | `UserTag(ChatTag)` | Label/emoji | User-created tags with custom emoji and name | +| Unread | `ActiveFilter.Unread` | Filter list icon | Chats with unread messages (toggle via filter button) | + +Display logic: +- When collapsible preset tags exceed 3 total (with user tags), they collapse into a `CollapsedTagsFilterView` dropdown menu +- Non-collapsible tags (`GROUP_REPORTS`) always show expanded +- User tags show with emoji or label icon; long-press opens `TagsDropdownMenu` (edit, delete, change order) +- "+" button at end opens `TagListEditor` for creating new tags + +### Chat Preview Rows (`ChatPreviewView`) + +Each row rendered by `ChatPreviewView` inside `ChatListNavLinkView`: + +| Element | Description | +|---|---| +| Avatar | `ProfileImage` with overlay icons (inactive contact, left/removed group member) | +| Chat name | Display name with verified icon for verified contacts; colored for pending/connecting states | +| Last message preview | Truncated text of most recent message; draft indicator with edit icon; attachment icons | +| Timestamp | Relative time of last activity | +| Unread badge | Numeric count badge; distinct styling for mentions | +| Muted indicator | Bell-off icon when notifications are muted | +| Favorite indicator | Star icon for favorited chats | +| Incognito indicator | Shows when connected via incognito profile | +| Connection status | Shows connecting/pending state for incomplete connections | + +Chat types handled by `ChatListNavLinkView`: +- `ChatInfo.Direct` -- direct contact chat +- `ChatInfo.Group` -- group chat (with in-progress indicator for joining) +- `ChatInfo.Local` -- note-to-self folder +- `ChatInfo.ContactRequest` -- incoming contact request (tap shows accept/reject alert) +- `ChatInfo.ContactConnection` -- pending connection (tap opens `ContactConnectionView`) + +### Context Menu (Long Press / Right Click) + +Each chat type provides specific dropdown menu items: + +| Chat Type | Menu Items | +|---|---| +| Direct contact | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, delete contact | +| Group | Mark read/unread, toggle favorite, toggle notify, tag list, clear chat, archive all reports (moderator, when reports exist), leave group, delete group | +| Note folder | Mark read/unread, clear notes | +| Contact request | Accept, reject | +| Contact connection | Set name/alias, delete | + +### Floating Elements + +| Element | Condition | Description | +|---|---|---| +| One-hand UI card (`ToggleChatListCard`) | `oneHandUICardShown == false` | Dismissible card introducing bottom toolbar mode with toggle switch | +| Address creation card (`AddressCreationCard`) | `addressCreationCardShown == false` | Prompts user to create a SimpleX address; tappable card opens `UserAddressLearnMore` | +| FAB (new chat button) | Standard mode, search empty, chat running | `FloatingActionButton` at bottom-right, pencil icon, opens `NewChatSheet` | + +### Empty States + +| State | Display | +|---|---| +| Loading | "Loading chats..." centered text | +| No chats | "You have no chats" centered text | +| No filtered chats | "No chats in list [tag name]" or "No unread chats" with clickable filter reset | +| No search results | "No chats found" centered text | + +## Source Files + +| File | Path | +|---|---| +| `ChatListView.kt` | `views/chatlist/ChatListView.kt` | +| `ChatListNavLinkView.kt` | `views/chatlist/ChatListNavLinkView.kt` | +| `ChatPreviewView.kt` | `views/chatlist/ChatPreviewView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | +| `TagListView.kt` | `views/chatlist/TagListView.kt` | diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md new file mode 100644 index 0000000000..64abda7ee6 --- /dev/null +++ b/apps/multiplatform/product/views/chat.md @@ -0,0 +1,135 @@ +# Chat View (Conversation) + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +Full conversation view for displaying and interacting with messages in a direct contact chat, group chat, or note-to-self. Supports text messaging with markdown, media attachments, voice messages, E2E encrypted calls, message reactions, replies, forwarding, reporting, and content search/filtering. + +## Route / Navigation + +- **Entry point**: Tap a chat row in `ChatListView` (routed by `ChatListNavLinkView`) +- **Presented by**: `ChatView` composable bound to `chatModel.chatId`; on Desktop, shown in the center column +- **Back navigation**: Sets `chatModel.chatId = null`, stops `AudioPlayer`, clears group members, returns to chat list +- **Sub-navigation**: + - Info button opens `ChatInfoView` (contact) or `GroupChatInfoView` (group) via `ModalManager.end` + - Member avatars in group chats navigate to `GroupMemberInfoView` + - Reports button opens `GroupReportsView` for groups with moderation reports + - Support chats button opens `MemberSupportView` (moderators) or member support chat (regular members) + +## Page Sections + +### Navigation Bar (`ChatLayout`) + +Custom toolbar with themed background: + +| Element | Description | +|---|---| +| Back button | Returns to chat list; stops audio/video playback | +| Contact/Group avatar | Small profile image in toolbar | +| Chat name | Display name; tappable to open info view | +| Verified shield | Shows verified contact checkmark (direct chats with verified contacts only) | +| More menu button | Opens overflow menu containing search and audio/video call buttons (call buttons shown in direct chats only) | +| Info button | Opens `ChatInfoView` (direct) or `GroupChatInfoView` (group) | +| Reports count | Badge for group reports count; taps open reports view | +| Support chats | Badge for member support; taps open support chat view | + +### Message List + +Rendered by `LazyColumnWithScrollBar` with pagination: + +| Feature | Description | +|---|---| +| Scroll direction | Bottom-to-top (newest messages at bottom) | +| Pagination | `apiLoadMessages` called on scroll to load more; supports `.before`, `.after`, `.around`, `.initial` | +| Merged items | Adjacent messages grouped with `ItemSeparation` (timestamp, large gap, date separators) | +| Floating buttons | Scroll-to-bottom button with unread count | +| Date separators | Date headers between messages from different days | +| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes` | +| Content filter | Filter messages by type via `ContentFilter` (images, files, links, etc.) | + +### Message Types + +Each type has a dedicated composable in `views/chat/item/`: + +| Type | Composable | Description | +|---|---|---| +| Text | `FramedItemView` | Rendered with markdown (bold, italic, code, links, `@mentions`) via `CIMarkdownText` | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView` | +| Video | `CIVideoView` | Video thumbnail with play button; inline playback via `VideoPlayerHolder` | +| Voice | `CIVoiceView` | Waveform visualization with playback controls and duration | +| File | `CIFileView` | File icon, name, size; download/open actions with progress indicator | +| Link preview | `ChatItemLinkView` | URL preview card with title, description, image (defined in `LinkPreviews.kt`) | +| Emoji-only | `EmojiItemView` | Large emoji rendering without message bubble | +| Call event | `CICallItemView` | Call status (missed, ended, duration) | +| Group event | `CIEventView` | Member joined/left, role changes, group updates | +| E2EE info | `CIChatFeatureView` | Encryption status and feature change notifications | +| Group invitation | `CIGroupInvitationView` | Inline group join invitation card | +| Deleted | `DeletedItemView` / `MarkedDeletedItemView` | Placeholder for deleted messages | +| Decryption error | `CIRcvDecryptionError` | Error with ratchet sync suggestion | +| Invalid JSON | `CIInvalidJSONView` | Developer fallback for malformed items | +| Integrity error | `IntegrityErrorItemView` | Message integrity/gap warnings | + +### Message Interactions + +Long-press context menu on any message: + +| Action | Description | +|---|---| +| Reply | Sets compose bar to reply mode with quoted message (`ComposeContextItem.QuotedItem`) | +| Forward | Opens destination picker; uses `apiPlanForwardChatItems` with confirmation for partial forwards | +| Copy | Copies message text to clipboard | +| Edit | Enters edit mode (`ComposeContextItem.EditingItem`); own messages within edit window | +| Delete | Delete for self or delete for everyone (with confirmation via `deleteMessagesAlertDialog`) | +| Moderate | Group moderators can delete messages for all members (`moderateMessagesAlertDialog`) | +| React | Emoji reaction picker | +| Report | Report message to group moderators (`ComposeContextItem.ReportedItem` with `ReportReason`) | +| Select multiple | Enters multi-select mode (`selectedChatItems`) with bulk delete/forward/archive toolbar | +| Archive | Archive selected reports (moderators) | + +### Compose Bar (`ComposeView` + `SendMsgView`) + +Bottom input area for composing messages: + +| Element | Description | +|---|---| +| Text field | `PlatformTextField` with markdown support, `@mention` autocomplete, file paste support | +| Attachment button | Opens `ModalBottomSheetLayout` with options: camera, gallery (image/video), file | +| Send button | Sends message; changes to checkmark for reports; animated size/alpha | +| Voice record button | Shown when text is empty and voice allowed; hold to record, release to preview | +| Live message button | Start/update live typing message (if `liveMessageAlertShown`) | +| Context preview | Shows quoted message, editing indicator, or forwarding source above text field | +| Media preview | Thumbnail row of selected images/videos before sending | +| Link preview | Auto-generated link preview card (`ComposePreview.CLinkPreview`) | +| Connecting status | "Connecting..." text shown when contact is not yet ready | +| Commands menu | Developer commands (`showCommandsMenu`) | + +Compose states (`ComposeState`): +- `NoContextItem` -- normal new message +- `QuotedItem` -- replying to a message +- `EditingItem` -- editing own message +- `ForwardingItems` -- forwarding from another chat +- `ReportedItem` -- reporting a message with reason + +### Multi-Select Toolbar (`SelectedItemsButtonsToolbar`) + +Shown when `selectedChatItems != null`: + +| Button | Description | +|---|---| +| Delete / Archive | Delete selected messages (for self, or for everyone if allowed by `fullDeleteAllowed`); shown as Archive for report items (group moderators only) | +| Forward | Forward selected messages to another chat | +| Moderate | Delete selected messages for all members (group moderators only) | + +### Timed/Disappearing Messages + +When `timedMessageAllowed` is true, compose bar includes a timer icon for setting message disappear time via `customDisappearingMessageTimePref`. + +## Source Files + +| File | Path | +|---|---| +| `ChatView.kt` | `views/chat/ChatView.kt` | +| `ComposeView.kt` | `views/chat/ComposeView.kt` | +| `SendMsgView.kt` | `views/chat/SendMsgView.kt` | +| Chat item views | `views/chat/item/*.kt` | diff --git a/apps/multiplatform/product/views/contact-info.md b/apps/multiplatform/product/views/contact-info.md new file mode 100644 index 0000000000..32793a3b70 --- /dev/null +++ b/apps/multiplatform/product/views/contact-info.md @@ -0,0 +1,104 @@ +# Contact Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View contact details, manage per-contact preferences, verify security codes for E2E encryption, manage connection settings (switch address, sync ratchet), and perform destructive actions like clearing or deleting a contact. + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a direct contact chat) +- **Presented by**: `ChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Contact preferences -> `ContactPreferencesView` (via `ModalManager.end`) + - Security code verification -> `VerifyCodeView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Group profile view (for group-direct contacts) + +## Page Sections + +### Contact Info Header + +| Element | Description | +|---|---| +| Profile image | Large circular avatar (tappable) | +| Display name | Contact's display name | +| Full name | Optional full name below display name | +| Connection status | Shows if contact is ready, connecting, or has issues | + +### Local Alias + +Editable text field for setting a local-only name visible only on this device. Not shared with the contact. Changes saved via `setContactAlias()`. + +### Action Buttons + +Horizontal row of quick-action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` to search messages in chat | +| Audio call | Initiate audio call | +| Video call | Initiate video call | +| Mute/Unmute | Toggle notification mode | + +### Incognito Section + +Shown only when `customUserProfile` is set (connected via incognito profile): + +| Element | Description | +|---|---| +| Incognito icon | Indicates incognito connection | +| Profile name | The random profile name used for this connection | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-contact delivery receipt setting (`SendReceipts` tristate: default/on/off) | +| Chat item TTL | Per-contact message retention setting (`ChatItemTTL` with alert confirmation) | +| Contact preferences | Opens `ContactPreferencesView` for feature toggles (timed messages, full delete, reactions, voice, calls) | + +### Connection Details + +Shown when `connectionStats` is available: + +| Element | Description | +|---|---| +| Connection stats | Server information, agent connection ID | +| Switch address | Initiates SMP server address switch (`apiSwitchContact`) with confirmation alert | +| Abort switch | Cancels an in-progress address switch (`apiAbortSwitchContact`) | +| Sync connection | Fixes encryption ratchet synchronization (`apiSyncContactRatchet`) | +| Force sync | Force ratchet re-synchronization with confirmation alert | + +### Security Code Verification + +| Element | Description | +|---|---| +| Verify button | Opens `VerifyCodeView` showing the connection security code | +| Verified badge | Shows checkmark when contact is verified | +| Code comparison | Side-by-side code display for out-of-band verification via `apiVerifyContact` | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Contact's internal database identifier | +| Agent connection ID | Underlying SMP agent connection ID | + +### Destructive Actions + +| Action | Description | +|---|---| +| Clear chat | Deletes all messages in chat (with confirmation via `clearChatDialog`) | +| Delete contact | Removes the contact and all associated data (with confirmation via `deleteContactDialog`) | + +## Source Files + +| File | Path | +|---|---| +| `ChatInfoView.kt` | `views/chat/ChatInfoView.kt` | +| `ContactPreferences.kt` | `views/chat/ContactPreferences.kt` | +| `VerifyCodeView.kt` | `views/chat/VerifyCodeView.kt` | diff --git a/apps/multiplatform/product/views/group-info.md b/apps/multiplatform/product/views/group-info.md new file mode 100644 index 0000000000..65b068adc8 --- /dev/null +++ b/apps/multiplatform/product/views/group-info.md @@ -0,0 +1,145 @@ +# Group Chat Info + +> **Related spec:** [spec/client/chat-view.md](../../spec/client/chat-view.md) + +## Purpose + +View and manage group settings, member list, group preferences, group links, member admission, welcome messages, and moderation features. The scope of available actions depends on the user's role within the group (member, moderator, admin, owner). + +## Route / Navigation + +- **Entry point**: Tap the info button in `ChatView` navigation bar (when viewing a group chat) +- **Presented by**: `GroupChatInfoView` composable shown via `ModalManager.end` from `ChatView` +- **Sub-navigation**: + - Edit group profile -> `GroupProfileView` (via `ModalManager.end`) + - Add members -> `AddGroupMembersView` (via `ModalManager.end`) + - Group link -> `GroupLinkView` (via `ModalManager.end`) + - Group preferences -> `GroupPreferencesView` (via `ModalManager.end`) + - Welcome message -> `GroupWelcomeView` (via `ModalManager.end`) + - Member info -> `GroupMemberInfoView` (via `ModalManager.end`) + - Chat wallpaper -> wallpaper editor + - Member support -> `MemberSupportView` (via `ModalManager.end`) + +## Page Sections + +### Group Info Header + +| Element | Description | +|---|---| +| Group image | Large circular profile image | +| Group name | Display name (editable by owners via `GroupProfileView`) | +| Member count | "N members" label from `activeSortedMembers` | +| Full name | Optional secondary name | +| Description | Group description text (if set) | + +### Local Alias + +Editable text field for a local-only alias (not shared with other members). Changes saved via `setGroupAlias()`. + +### Action Buttons + +Horizontal row of action buttons: + +| Button | Description | +|---|---| +| Search | Triggers `onSearchClicked` callback to search messages in chat | +| Mute/Unmute | Toggle notification mode | +| Add members | Opens `AddGroupMembersView` (shown when user has admin+ role and `groupInfo.canAddMembers`) | + +### Group Management Section + +Available actions depend on role (`GroupMemberRole`): + +| Action | Minimum Role | Description | +|---|---|---| +| Edit group profile | Owner | Opens `GroupProfileView` to edit name, image, description | +| Add members | Admin | Opens `AddGroupMembersView` to invite contacts | +| Manage group link | Admin | Opens `GroupLinkView` to create/share/delete group link | +| Member support | Moderator | Opens `MemberSupportView` to manage member support chats | +| Edit welcome message | Owner | Opens `GroupWelcomeView` to set the auto-sent welcome text | +| Group preferences | Any | Opens `GroupPreferencesView` (read-only; only owners can change settings) | + +### Chat Preferences + +| Setting | Description | +|---|---| +| Send receipts | Per-group delivery receipt setting (`SendReceipts`); limited to groups under `SMALL_GROUPS_RCPS_MEM_LIMIT` (20 members) | +| Chat item TTL | Per-group message retention setting with confirmation alert via `setChatTTLAlert` | + +### Member List + +Displays `activeSortedMembers` (excluding left/removed members, sorted by role descending): + +| Element | Description | +|---|---| +| Member avatar | `MEMBER_ROW_AVATAR_SIZE` (42dp) profile image | +| Member name | Display name with role badge | +| Member role | Owner, Admin, Moderator, Member, Observer | +| Member status | Active, connecting, pending, left, removed | +| Tap action | Opens `GroupMemberInfoView` with connection stats and verification code | + +### Group Link (`GroupLinkView`) + +| Element | Description | +|---|---| +| Create link button | `apiCreateGroupLink` generates a shareable group invitation link | +| QR code display | QR code rendering of the group link | +| Short link toggle | Switch between short and full link display | +| Share button | System share for the link | +| Copy button | Copy link to clipboard | +| Member role selector | Set the default role for members joining via link (`acceptMemberRole`) | +| Add short link | `apiAddGroupShortLink` creates a short link that includes group profile | +| Delete link | Remove the group link with confirmation | + +### Add Members (`AddGroupMembersView`) + +| Element | Description | +|---|---| +| Contact list | Filterable list of contacts to invite | +| Role selector | Set the role for invited members | +| Invite button | Sends group invitations to selected contacts | +| Group link option | Alternative to direct invitation | + +### Group Member Info (`GroupMemberInfoView`) + +| Element | Description | +|---|---| +| Member profile | Avatar, name, role | +| Connection stats | Server information, connection status | +| Security code | Verification code for the member connection | +| Role change | Change member role (admin+ only) | +| Remove member | Remove from group (admin+ only) | +| Block member | Block member for self | +| Direct message | Open direct chat with member | + +### Developer Tools Section + +Shown when `developerTools` preference is enabled: + +| Element | Description | +|---|---| +| Database ID | Group's internal database identifier | + +### Destructive Actions + +| Action | Condition | Description | +|---|---|---| +| Clear chat | Any member | Deletes all messages locally (`clearChatDialog`) | +| Leave group | Non-owner | Leave the group (`leaveGroupDialog`) | +| Delete group | Owner or non-current member | Delete group for all (owner) or for self (`deleteGroupDialog`) | + +Business chats use alternative labels: "Delete chat" instead of "Delete group". + +## Source Files + +| File | Path | +|---|---| +| `GroupChatInfoView.kt` | `views/chat/group/GroupChatInfoView.kt` | +| `GroupMemberInfoView.kt` | `views/chat/group/GroupMemberInfoView.kt` | +| `AddGroupMembersView.kt` | `views/chat/group/AddGroupMembersView.kt` | +| `GroupLinkView.kt` | `views/chat/group/GroupLinkView.kt` | +| `GroupProfileView.kt` | `views/chat/group/GroupProfileView.kt` | +| `GroupPreferences.kt` | `views/chat/group/GroupPreferences.kt` | +| `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` | +| `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` | +| `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` | diff --git a/apps/multiplatform/product/views/new-chat.md b/apps/multiplatform/product/views/new-chat.md new file mode 100644 index 0000000000..b664fda67f --- /dev/null +++ b/apps/multiplatform/product/views/new-chat.md @@ -0,0 +1,96 @@ +# New Chat / Connection + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Create new contacts, groups, or connect with others via one-time invitation links or by scanning/pasting SimpleX links. This is the primary entry point for establishing new E2E encrypted connections. + +## Route / Navigation + +- **Entry point**: Tap the new chat button (pencil icon) in `ChatListView` toolbar or FAB +- **Presented by**: `NewChatSheet` modal from `ChatListView` via `showNewChatSheet()`; wraps `NewChatView` and group creation in `ModalManager.start` +- **Internal navigation**: `NewChatSheet` provides 3 action buttons: + - "Create 1-time link" -- opens `NewChatView` with `INVITE` tab (generate and share a one-time invitation link) + - "Scan / paste link" -- opens `NewChatView` with `CONNECT` tab (scan QR code or paste a received link) + - "Create group" -- opens `AddGroupView` +- **Tabs within NewChatView**: `HorizontalPager` with `TabRow` toggles between `NewChatOption.INVITE` (1-time link) and `NewChatOption.CONNECT` (connect via link) +- **Swipe gesture**: Left/right swipe switches between tabs (Android only; `userScrollEnabled = appPlatform.isAndroid`) +- **Dismiss behavior**: On dispose, a `DisposableEffect` shows an alert dialog (via `AlertManager.shared.showAlertDialog`) asking whether to keep an unused invitation link or delete it via `controller.deleteChat()` + +## Page Sections + +### Tab Selector + +| Tab | Icon | Label | Description | +|---|---|---|---| +| 1-time link | `ic_repeat_one` | "1-time link" | Generate and share a one-time invitation link | +| Connect via link | `ic_qr_code` | "Connect via link" | Scan QR code or paste a received link | + +### Invite Tab (1-time Link) -- `PrepareAndInviteView` + +Displayed when `selection == INVITE`: + +| Element | Description | +|---|---| +| QR code display | Generated QR code for the invitation link (`SimpleXLinkQRCode`) | +| Short/full link toggle | Switch between short and full link display | +| Share button | System share for the invitation link | +| Copy button | Copy link to clipboard | +| Incognito toggle | Option to connect with a random profile | +| Loading state | `CreatingLinkProgressView` with "Creating link" text while `creatingConnReq` is true | +| Retry button | `RetryButton` shown if link creation fails; calls `createInvitation()` | + +Link creation calls `apiAddContact` which returns a `CreatedConnLink` with both `connFullLink` and optional `connShortLink`. The invitation is tracked via `chatModel.showingInvitation`. + +### Connect Tab -- `ConnectView` + +Displayed when `selection == CONNECT`: + +| Element | Description | +|---|---| +| QR code scanner | Camera-based QR code scanner (`showQRCodeScanner` state) | +| Paste link field | Text field for pasting a SimpleX link (`pastedLink`) | +| Connect button | Initiates connection via `planAndConnect()` | + +When a valid SimpleX link is detected: +1. `planAndConnect()` is called with the link URI +2. If the link matches a known contact, filters to that chat +3. If the link matches a known group, filters to that group +4. Otherwise, creates a new connection + +### Create Group (`AddGroupView`) + +| Element | Description | +|---|---| +| Group name field | Required display name input with `FocusRequester` | +| Profile image picker | `GetImageBottomSheet` for selecting/cropping a group avatar | +| Incognito toggle | Option to create group with random profile (`incognitoPref`) | +| Create button | Calls `apiNewGroup()`, then opens `AddGroupMembersView` (normal) or `GroupLinkView` (incognito) | + +Group creation flow: +1. User enters group name and optionally selects an image +2. `apiNewGroup()` creates the group and returns `GroupInfo` +3. `openGroupChat()` navigates to the new group chat +4. `setGroupMembers()` preloads member data +5. `AddGroupMembersView` opens for inviting contacts (or `GroupLinkView` for incognito groups) + +### QR Code Components (`QRCode.kt`) + +| Component | Description | +|---|---| +| `SimpleXLinkQRCode` | Renders a QR code for a SimpleX connection link | +| QR scanner | Platform camera scanner for reading QR codes | +| Short link display | Compact link text with copy/share actions | + +## Source Files + +| File | Path | +|---|---| +| `NewChatView.kt` | `views/newchat/NewChatView.kt` | +| `AddGroupView.kt` | `views/newchat/AddGroupView.kt` | +| `QRCode.kt` | `views/newchat/QRCode.kt` | +| `NewChatSheet.kt` | `views/newchat/NewChatSheet.kt` | +| `ConnectPlan.kt` | `views/newchat/ConnectPlan.kt` | +| `QRCodeScanner.kt` | `views/newchat/QRCodeScanner.kt` (expect/actual) | +| `ContactConnectionInfoView.kt` | `views/newchat/ContactConnectionInfoView.kt` | diff --git a/apps/multiplatform/product/views/onboarding.md b/apps/multiplatform/product/views/onboarding.md new file mode 100644 index 0000000000..4127ac65f7 --- /dev/null +++ b/apps/multiplatform/product/views/onboarding.md @@ -0,0 +1,139 @@ +# Onboarding + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +First-time setup flow for new users. Guides through app introduction, profile creation, database passphrase setup (Desktop), server operator conditions acceptance, SimpleX address creation, and notification configuration (Android). Also provides an entry point for device migration. + +## Route / Navigation + +- **Entry point**: App launch when `onboardingStage` is not `OnboardingComplete` +- **Presented by**: `OnboardingView` renders the appropriate step based on `OnboardingStage` enum +- **Flow direction**: Linear progression controlled by `appPrefs.onboardingStage` +- **Completion**: Sets `onboardingStage` to `OnboardingComplete` + +## Onboarding Stages + +The `OnboardingStage` enum defines the flow: + +| Stage | Description | +|---|---| +| `Step1_SimpleXInfo` | Welcome screen with app introduction | +| `Step2_CreateProfile` | Create first user profile | +| `LinkAMobile` | Desktop-only: link a mobile device | +| `Step2_5_SetupDatabasePassphrase` | Desktop-only: set database encryption passphrase | +| `Step3_ChooseServerOperators` | Accept server operator conditions | +| `Step3_CreateSimpleXAddress` | Create a SimpleX contact address | +| `Step4_SetNotificationsMode` | Android-only: configure notification mode | +| `OnboardingComplete` | Onboarding finished | + +## Page Sections + +### Step 1: Welcome / SimpleX Info (`SimpleXInfo`) + +**Stage**: `Step1_SimpleXInfo` + +| Element | Description | +|---|---| +| Logo | `SimpleXLogo` -- SimpleX Chat logo (light/dark variant based on `isInDarkTheme()`) | +| Info button | `OnboardingInformationButton` -- "The next generation of private messaging"; taps open `HowItWorks` fullscreen modal | +| Privacy redefined | `InfoRow` with privacy icon: "No user identifiers" | +| Immune to spam | `InfoRow` with shield icon: "You decide who can connect" | +| Decentralized | `InfoRow` with decentralized icon: "Anybody can host servers" | +| **Create your profile** button | `OnboardingActionButton` -- primary action; advances to profile creation | +| **Migrate from another device** button | `TextButtonBelowOnboardingButton` -- opens `MigrateToDeviceView` fullscreen modal | + +Layout: `ColumnWithScrollBar` with `DEFAULT_ONBOARDING_HORIZONTAL_PADDING`, max width constrained (250dp Android, 500dp Desktop). + +### Step 2: Create Profile + +**Stage**: `Step2_CreateProfile` + +| Element | Description | +|---|---| +| Display name field | Required text input; auto-focused | +| Validation | Name validation with `mkValidName` check | +| Create button | Creates profile via API; advances to next step | + +Profile is stored locally and only shared with contacts. + +### Step 2.5: Setup Database Passphrase (Desktop only) + +**Stage**: `Step2_5_SetupDatabasePassphrase` + +| Element | Description | +|---|---| +| Passphrase field | Secure text input for database encryption key | +| Confirm field | Passphrase confirmation | +| Set button | Encrypts database with passphrase | + +### Link a Mobile (Desktop only) + +**Stage**: `LinkAMobile` + +| Element | Description | +|---|---| +| Instructions | How to connect mobile device to desktop | +| QR code | Connection QR code for mobile scanning | +| Skip button | Skip this step | + +### Step 3: Choose Server Operators + +**Stage**: `Step3_ChooseServerOperators` + +| Element | Description | +|---|---| +| Operator list | Available server operators with conditions | +| Conditions text | Terms of service for selected operators | +| Accept button | Accept conditions and continue | + +Managed by `ChooseServerOperators.kt`. + +### Step 3b: Create SimpleX Address + +**Stage**: `Step3_CreateSimpleXAddress` + +| Element | Description | +|---|---| +| Address creation | Auto-creates a SimpleX contact address | +| QR code | Displays the created address as QR code | +| Share button | Share address link | +| Skip button | Skip address creation | + +### Step 4: Set Notifications Mode (Android only) + +**Stage**: `Step4_SetNotificationsMode` + +| Element | Description | +|---|---| +| Notification options | Instant (background service) / Periodic (every 10 min) / Off | +| Description | Explains battery impact and notification behavior for each mode | +| Continue button | Saves selection and completes onboarding | + +Managed by `SetNotificationsMode.kt`. + +### What's New (`WhatsNewView`) + +Shown after onboarding or when triggered from Settings: + +| Element | Description | +|---|---| +| Version highlights | New features and changes in the current version | +| Updated conditions | Notice about updated server operator conditions (if applicable) | +| Close button | Dismisses the view | + +Triggered in `ChatListView` via `shouldShowWhatsNew()` with a 1-second delay. + +## Source Files + +| File | Path | +|---|---| +| `OnboardingView.kt` | `views/onboarding/OnboardingView.kt` | +| `SimpleXInfo.kt` | `views/onboarding/SimpleXInfo.kt` | +| `HowItWorks.kt` | `views/onboarding/HowItWorks.kt` | +| `SetupDatabasePassphrase.kt` | `views/onboarding/SetupDatabasePassphrase.kt` | +| `SetNotificationsMode.kt` | `views/onboarding/SetNotificationsMode.kt` | +| `ChooseServerOperators.kt` | `views/onboarding/ChooseServerOperators.kt` | +| `WhatsNewView.kt` | `views/onboarding/WhatsNewView.kt` | +| `LinkAMobileView.kt` | `views/onboarding/LinkAMobileView.kt` | diff --git a/apps/multiplatform/product/views/settings.md b/apps/multiplatform/product/views/settings.md new file mode 100644 index 0000000000..e668bf2d04 --- /dev/null +++ b/apps/multiplatform/product/views/settings.md @@ -0,0 +1,159 @@ +# Settings + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) | [spec/services/theme.md](../../spec/services/theme.md) | [spec/services/notifications.md](../../spec/services/notifications.md) + +## Purpose + +Configure all aspects of app behavior including notifications, network/servers, privacy, appearance, database management, call settings, and developer tools. Accessed from the UserPicker or directly from the chat list toolbar. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> Settings option; or directly via `NavigationButtonMenu` when no users exist +- **Presented by**: `SettingsView` composable via `ModalManager.start.showModalCloseable` +- **Navigation title**: "Your settings" (`AppBarTitle`) +- **Sub-navigation**: Each settings row opens a dedicated view via `showSettingsModal` or `showCustomModal` + +## Platform Differences + +| Aspect | Android | Desktop | +|---|---|---| +| App section | Device settings, app version | App updates (`AppUpdater`), device settings, app version | +| Notifications | Full notification mode selection (instant/periodic/off) | Notification settings | +| Use from desktop/mobile | "Use from desktop" option in UserPicker | "Link a mobile" / "Linked mobiles" option in UserPicker | +| Database migration | "Migrate to another device" with auth | Same | + +## Page Sections + +### Settings Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Notifications | `ic_bolt` / `ic_bolt_off` | `NotificationsSettingsView` | Push notification mode and preview settings | +| Network & servers | `ic_wifi_tethering` | `NetworkAndServersView` | SMP/XFTP servers, proxy, .onion hosts, advanced network | +| Audio & video calls | `ic_videocam` | `CallSettingsView` | WebRTC relay policy, ICE servers | +| Privacy & security | `ic_lock` | `PrivacySettingsView` | SimpleX Lock, delivery receipts, link previews, auto-accept | +| Appearance | `ic_light_mode` | `AppearanceView` | Theme, language, profile images, chat bubbles | + +All rows disabled when `chatModel.chatRunning != true` (except Appearance). + +#### Notifications (`NotificationsSettingsView`) + +| Setting | Options | +|---|---| +| Notification mode | Instant (background service) / Periodic (every 10 min) / Off | +| Notification preview | Configuration for notification content visibility | + +#### Network & Servers (`NetworkAndServersView`) + +| Setting | Description | +|---|---| +| SMP servers | Messaging relay servers; per-operator configuration | +| XFTP servers | File transfer servers; per-operator configuration | +| Server operators | `OperatorView` for each configured operator | +| Advanced network | `AdvancedNetworkSettings` -- timeouts, TCP keep-alive, reconnect intervals | +| Proxy configuration | SOCKS proxy, .onion host settings | + +Sub-files: `NetworkAndServers.kt`, `ProtocolServersView.kt`, `ProtocolServerView.kt`, `NewServerView.kt`, `ScanProtocolServer.kt`, `AdvancedNetworkSettings.kt`, `OperatorView.kt` + +#### Audio & Video Calls (`CallSettingsView`) + +| Setting | Description | +|---|---| +| WebRTC relay policy | Always relay / relay when needed / never relay | +| ICE servers | Custom STUN/TURN server configuration | + +#### Privacy & Security (`PrivacySettingsView`) + +Organized in sections: + +**Device Section** (`PrivacyDeviceSection`): + +| Setting | Description | +|---|---| +| SimpleX Lock | `SimplexLockView` -- app lock with system auth or passcode (`LAMode.SYSTEM` / `LAMode.PASSCODE`) | + +**Chats Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Send link previews | `privacyLinkPreviews` | Auto-generate link preview cards | +| Sanitize links | `privacySanitizeLinks` | Strip tracking parameters from URLs | +| Show last messages | `privacyShowChatPreviews` | Show message previews in chat list | +| Message draft | `privacySaveLastDraft` | Save unsent message draft for each chat | + +**Files Section**: + +| Setting | Preference Key | Description | +|---|---|---| +| Encrypt local files | `privacyEncryptLocalFiles` | Encrypt files stored on device | +| Auto-accept images | `privacyAcceptImages` | Automatically download received images | +| Blur media radius | `privacyMediaBlurRadius` | Blur radius for media previews | +| Protect IP address | `privacyAskToApproveRelays` | Prompt before connecting to unknown file relays to protect IP address | + +#### Appearance (`AppearanceView`) + +Platform-specific composable (`expect fun AppearanceView`): + +| Setting | Description | +|---|---| +| Profile images | `ProfileImageSection` -- slider for profile image corner radius | +| Theme selection | Color scheme / theme picker | +| Language | App language selection | +| Chat wallpaper | Background image settings | +| Chat bubbles | Message bubble appearance configuration | +| Toolbar opacity | App bar transparency settings (`inAppBarsAlpha`) | +| Color picker | `ClassicColorPicker` for custom theme colors | + +### Chat Database Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| Database passphrase & export | `ic_database` | `DatabaseView` | Manage encryption, export/import database | +| Migrate to another device | `ic_ios_share` | `MigrateFromDeviceView` | Device migration (requires auth) | + +Database icon shows warning color (`WarningOrange`) when database is not encrypted or passphrase is not saved. + +### Help Section + +| Row | Icon | Destination | Description | +|---|---|---|---| +| How to use SimpleX Chat | `ic_help` | `HelpView` | Usage guide | +| What's new | `ic_add` | `WhatsNewView` | Version changelog | +| About SimpleX Chat | `ic_info` | `SimpleXInfo` (non-onboarding mode) | App information | +| Chat with the founder | `ic_tag` | Opens SimpleX link | Direct chat with SimpleX team | +| Send us an email | `ic_mail` | Opens mailto: | Email support | + +### Support Section + +| Row | Icon | Description | +|---|---|---| +| Contribute | `ic_keyboard` | Opens GitHub contribution page (hidden for Android Bundle) | +| Rate the app | `ic_star` | Opens Google Play / app store listing | +| Star on GitHub | `ic_github` | Opens GitHub repository | + +### App Section (`SettingsSectionApp`) + +Platform-specific section (expect/actual composable): + +| Row | Description | +|---|---| +| App updates (Desktop) | App update checker and installer | +| Developer tools | Toggle developer mode | +| Chat console | Opens `ChatConsoleView` terminal | +| Terminal always visible (Desktop) | Keep terminal window open | +| Install terminal app | Link to CLI app on GitHub | +| Reset all hints | Reset dismissed hint/card preferences | +| App version | Version string with build info; taps open `VersionInfoView` | + +## Source Files + +| File | Path | +|---|---| +| `SettingsView.kt` | `views/usersettings/SettingsView.kt` | +| `Appearance.kt` | `views/usersettings/Appearance.kt` | +| `PrivacySettings.kt` | `views/usersettings/PrivacySettings.kt` | +| `NetworkAndServers.kt` | `views/usersettings/networkAndServers/NetworkAndServers.kt` | +| `AdvancedNetworkSettings.kt` | `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | +| `OperatorView.kt` | `views/usersettings/networkAndServers/OperatorView.kt` | +| `ProtocolServersView.kt` | `views/usersettings/networkAndServers/ProtocolServersView.kt` | +| `NewServerView.kt` | `views/usersettings/networkAndServers/NewServerView.kt` | diff --git a/apps/multiplatform/product/views/user-profiles.md b/apps/multiplatform/product/views/user-profiles.md new file mode 100644 index 0000000000..dfc37a5e8d --- /dev/null +++ b/apps/multiplatform/product/views/user-profiles.md @@ -0,0 +1,122 @@ +# User Profiles + +> **Related spec:** [spec/client/navigation.md](../../spec/client/navigation.md) + +## Purpose + +Manage multiple chat profiles within a single app instance. Users can create, switch between, hide, mute, and delete profiles. Hidden profiles are protected by password. The UserPicker provides quick profile switching from the chat list, while UserProfilesView offers full profile management. + +## Route / Navigation + +- **Entry point**: Tap user avatar in `ChatListView` toolbar -> `UserPicker` -> "Your chat profiles" +- **Presented by**: `UserProfilesView` composable via `ModalManager.start.showCustomModal` with search bar +- **Navigation title**: "Your chat profiles" (`AppBarTitle`) +- **Sub-navigation**: + - Create profile -> `CreateProfile` (via `ModalManager.center`) + - Edit active profile -> `UserProfileView` (via UserPicker tap on active user) + - User address -> `UserAddressView` (via UserPicker) + - Chat preferences -> `PreferencesView` (via UserPicker) + +## Page Sections + +### UserPicker (`UserPicker.kt`) + +Overlay panel triggered from `ChatListView` toolbar: + +| Section | Description | +|---|---| +| Device picker row | `DevicePickerRow` showing local device and connected remote hosts (Desktop only); pill-shaped buttons with connect/disconnect actions | +| Active user profile | `ProfilePreview` of current user (Desktop: single row; Android: full user list) | +| User list | `UserPickerUsersSection` with all visible non-hidden profiles; tap to switch, long-press disabled | +| SimpleX address | Row to open `UserAddressView` (create or view address) | +| Chat preferences | Row to open `PreferencesView` | +| Chat profiles | Row to open `UserProfilesView` (or `CreateProfile` when no users exist on Desktop) | +| Use from desktop/mobile | Android: "Use from desktop" (`ConnectDesktopView`); Desktop: "Link a mobile" / "Linked mobiles" (`ConnectMobileView`) | +| Settings | Row to open `SettingsView` with `ColorModeSwitcher` trailing | + +Platform behavior: +- **Android**: `PlatformUserPicker` renders as bottom sheet with `AnimatedViewState` transitions; shows all users inline +- **Desktop**: Sidebar panel; shows only active user in header, inactive users in separate section below divider + +### UserProfilesView + +Full profile management screen with search/password field: + +#### Search / Password Field + +Combined text field at the top (`searchTextOrPassword`): +- In normal mode: Filters visible profiles by name +- For hidden profiles: Acts as password entry to reveal hidden profiles +- Trimmed search text compared against `user.anyNameContains()` and `correctPassword()` + +#### Profile List + +Each row rendered by `UserView` -> `UserProfilePickerItem`: + +| Element | Description | +|---|---| +| Active indicator | Checkmark icon (`ic_done_filled`) for the current active profile | +| Profile image | 54dp avatar with `fontSizeSqrtMultiplier` scaling | +| Display name | Profile's display name; bold for active, normal for inactive | +| Unread count | Badge showing unread message count (`unreadCountStr`) with primary/secondary color based on mute state | +| Muted indicator | `ic_notifications_off` icon when profile notifications are muted | +| Hidden indicator | `ic_lock` icon for hidden profiles (only shown when revealed via password) | + +#### Profile Row Tap Action + +| Action | Description | +|---|---| +| Switch active | Tapping a profile row calls `changeActiveUser()` to activate the selected profile; all chats switch context | + +#### Profile Actions (Context Menu) + +Available via long-press / right-click on a profile row (`DefaultDropdownMenu`): + +| Action | Condition | Description | +|---|---|---| +| Mute | Visible, notifications on | `apiMuteUser()` mutes notifications; shows `showMuteProfileAlert` on first use | +| Unmute | Visible, notifications off | `apiUnmuteUser()` restores notifications | +| Hide | Visible, multiple visible users | Opens `HiddenProfileView` to set password | +| Unhide | Hidden profile | `apiUnhideUser()` with password entry (`ProfileActionView` with `UserProfileAction.UNHIDE`) | +| Delete | Any non-sole profile | Delete with confirmation dialog; options: "Delete with connections" (removes SMP queues) or "Delete data only" | + +#### Add Profile + +| Element | Description | +|---|---| +| Add button | "+" icon with "Add profile" text at bottom of list (hidden when searching) | +| Auth required | Profile creation requires authentication via `withAuth` | +| Create view | Opens `CreateProfile` in `ModalManager.center` | + +#### Profile Deletion (`removeUser`) + +Deletion flow: +1. If hidden profile requiring password: opens `ProfileActionView` with `UserProfileAction.DELETE` +2. If active profile: switches to another visible user first via `changeActiveUser_`, then deletes +3. If last visible profile with hidden profiles: deletes user, then changes active to null; on Android, stops chat and resets to onboarding +4. Cleans up wallpaper files and cancels notifications for the deleted user + +#### Hidden Profile Notice + +Shown once via `showHiddenProfilesNotice` preference: + +| Element | Description | +|---|---| +| Alert title | "Make profile private" | +| Alert text | "You can hide or mute user profile" | +| "Don't show again" | Disables the notice permanently | + +### Profile Password Validation + +| Function | Description | +|---|---| +| `correctPassword()` | Validates password against `user.viewPwdHash` using `chatPasswordHash(pwd, salt)` | +| `passwordEntryRequired()` | Returns true if user is hidden, active, and password does not match current search text | +| `userViewPassword()` | Extracts view password from search text for hidden user operations | + +## Source Files + +| File | Path | +|---|---| +| `UserProfilesView.kt` | `views/usersettings/UserProfilesView.kt` | +| `UserPicker.kt` | `views/chatlist/UserPicker.kt` | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md new file mode 100644 index 0000000000..c5d9a3b4f7 --- /dev/null +++ b/apps/multiplatform/spec/README.md @@ -0,0 +1,137 @@ +# SimpleX Chat -- Kotlin Multiplatform Specification + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Dependency Graph](#dependency-graph) +3. [Specification Documents](#specification-documents) +4. [Product Documents](#product-documents) +5. [Source Entry Points](#source-entry-points) + +--- + +## Executive Summary + +SimpleX Chat is a Kotlin Multiplatform application targeting **Android** and **Desktop** (JVM) platforms. The UI layer is built entirely with Jetpack Compose. The application communicates with a Haskell-based cryptographic core (`simplex-chat`) through a **JNI bridge** -- native functions declared in Kotlin and linked at runtime to a shared library (`libapp-lib`). Platform-specific behavior (notifications, file system paths, services, audio/video) is abstracted using the `expect`/`actual` pattern and a runtime-assignable `PlatformInterface` callback object. + +The Gradle project is structured as three modules: + +| Module | Purpose | +|---|---| +| `:common` | Shared Compose UI, models, platform abstractions (`commonMain`, `androidMain`, `desktopMain`) | +| `:android` | Android application entry point (`SimplexApp`, `MainActivity`) | +| `:desktop` | Desktop application entry point (`Main.kt`, `showApp()`) | + +All meaningful application logic resides in `:common/commonMain`. Platform source sets (`androidMain`, `desktopMain`) provide `actual` implementations for `expect` declarations and host platform-specific integration code. + +--- + +## Dependency Graph + +``` +App Entry Points ++-- Android: SimplexApp.onCreate -> initHaskell -> initMultiplatform -> initChatControllerOnStart +| MainActivity.onCreate -> setContent { AppScreen() } ++-- Desktop: main() -> initHaskell -> runMigrations -> initApp -> showApp -> AppWindow -> AppScreen() + | + v +Common Module (commonMain) ++-- ChatModel (Compose state singleton) <-> ChatController/SimpleXAPI (JNI bridge) <-> Haskell Core (chat_ctrl) ++-- Views (Compose) +| +-- App.kt: AppScreen -> MainScreen +| +-- ChatListView -> ChatView -> ComposeView -> SendMsgView +| +-- ChatItemView (message rendering: text, image, video, voice, file, call, events) +| +-- Settings: SettingsView, UserProfileView, UserProfilesView +| +-- Onboarding: OnboardingView, WhatsNewView, CreateFirstProfile +| +-- Call: CallView, IncomingCallAlertView +| +-- Database: DatabaseView, DatabaseEncryptionView, DatabaseErrorView +| +-- Groups: GroupChatInfoView, AddGroupMembersView, GroupMemberInfoView +| +-- Contacts: ContactListNavView +| +-- Remote: ConnectDesktopView, ConnectMobileView +| +-- Terminal: TerminalView ++-- Models +| +-- ChatModel -- global app state (Compose MutableState singleton) +| +-- ChatsContext -- per-context chat list state (primary + optional secondary) +| +-- Chat -- per-conversation state (chatInfo, chatItems, chatStats) +| +-- ChatController -- API command dispatch, event receiver, preferences +| +-- AppPreferences -- 150+ SharedPreferences keys ++-- Services +| +-- NtfManager -- abstract notification coordinator (Android/Desktop implementations) +| +-- SimplexService -- Android foreground service for background messaging +| +-- ThemeManager -- theme resolution (system/light/dark/simplex/black + per-user overrides) +| +-- CallManager -- WebRTC call lifecycle ++-- Platform (expect/actual) + +-- Core.kt -- JNI declarations (external fun), initChatController, chatInitTemporaryDatabase + +-- AppCommon.kt -- runMigrations, AppPlatform enum + +-- Files.kt -- dataDir, tmpDir, filesDir, dbAbsolutePrefixPath (expect) + +-- Share.kt -- shareText, shareFile, openFile (expect) + +-- VideoPlayer.kt -- VideoPlayerInterface, VideoPlayer (expect class) + +-- RecAndPlay.kt -- RecorderInterface, AudioPlayerInterface (expect) + +-- UI.kt -- showToast, hideKeyboard, getKeyboardState (expect) + +-- Notifications.kt -- allowedToShowNotification (expect) + +-- NtfManager.kt -- abstract NtfManager class + +-- Platform.kt -- PlatformInterface (runtime callback object) + +-- Cryptor.kt -- CryptorInterface (expect) + +-- Images.kt -- bitmap utilities (expect) + +-- SimplexService.kt-- getWakeLock (expect) + +-- Log.kt, Modifier.kt, Back.kt, ScrollableColumn.kt, PlatformTextField.kt, Resources.kt +``` + +--- + +## Specification Documents + +| Document | Path | Description | +|---|---|---| +| Architecture | [spec/architecture.md](architecture.md) | System layers, module structure, JNI bridge, app lifecycle, event streaming, platform abstraction | +| State Management | [spec/state.md](state.md) | ChatModel singleton, ChatsContext, Chat data class, AppPreferences, ActiveChatState | +| API | [spec/api.md](api.md) | ChatController command dispatch, ~150 API functions in 11 categories, CC/CR/API types | +| Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore | +| Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis | +| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions | +| Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags | +| Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions | +| Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows | +| Calls | [spec/services/calls.md](services/calls.md) | WebRTC call lifecycle, signaling, platform-specific call views | +| Files | [spec/services/files.md](services/files.md) | File transfer (SMP inline / XFTP), CryptoFile encryption, platform file paths | +| Notifications | [spec/services/notifications.md](services/notifications.md) | NtfManager, SimplexService, notification channels, background delivery | +| Theme | [spec/services/theme.md](services/theme.md) | ThemeManager, color system, wallpapers, per-user overrides | + +--- + +## Product Documents + +| Category | Path | Topic | +|---|---|---| +| Overview | [product/README.md](../product/README.md) | Product overview, capability map, navigation map | +| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | +| Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | +| Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | +| Gaps | [product/gaps.md](../product/gaps.md) | 7 known gaps with recommendations | +| Flows | [product/flows/](../product/flows/) | onboarding, messaging, connection, calling, file-transfer, group-lifecycle | +| Views | [product/views/](../product/views/) | chat-list, chat, settings, onboarding, call, new-chat, contact-info, group-info, user-profiles | + +--- + +## Source Entry Points + +| Component | File | Key Symbol | Line | +|---|---|---|---| +| Android Application | [`SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L41) | `class SimplexApp` | 41 | +| Android Activity | [`MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L27) | `class MainActivity` | 27 | +| Desktop Entry | [`Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) | `fun main()` | 21 | +| Desktop App Window | [`DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) | `fun showApp()` | 33 | +| Desktop Init | [`AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) | `fun initApp()` | 21 | +| Common App Screen | [`App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) | `fun AppScreen()` | 47 | +| JNI Bridge | [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | +| Chat Controller | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | `object ChatController` | 493 | +| Chat Model | [`ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) | `object ChatModel` | 86 | +| App Preferences | [`SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) | `class AppPreferences` | 94 | +| Platform Interface | [`Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) | `interface PlatformInterface` | 15 | +| Notification Manager | [`NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L19) | `abstract class NtfManager` | 19 | +| Theme Manager | [`ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L18) | `object ThemeManager` | 18 | +| Android Haskell Init | [`AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt#L33) | `fun initHaskell(packageName: String)` | 33 | +| Common Migrations | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L41) | `fun runMigrations()` | 41 | +| Android Service | [`SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt#L41) | `class SimplexService` | 41 | +| Gradle Root | [`settings.gradle.kts`](../settings.gradle.kts#L22) | `include(":android", ":desktop", ":common")` | 22 | +| Common Build | [`build.gradle.kts`](../common/build.gradle.kts#L14) | `kotlin { androidTarget(); jvm("desktop") }` | 14 | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md new file mode 100644 index 0000000000..15d5e141a0 --- /dev/null +++ b/apps/multiplatform/spec/api.md @@ -0,0 +1,435 @@ +# Chat API Reference + +## Table of Contents + +1. [Overview](#1-overview) +2. [Command Categories](#2-command-categories) + - 2.1 [User Management](#21-user-management) + - 2.2 [Chat Lifecycle](#22-chat-lifecycle) + - 2.3 [Message Operations](#23-message-operations) + - 2.4 [Group Operations](#24-group-operations) + - 2.5 [Contact Operations](#25-contact-operations) + - 2.6 [File Operations](#26-file-operations) + - 2.7 [Call Operations](#27-call-operations) + - 2.8 [Settings & Network](#28-settings--network) + - 2.9 [Chat Tags](#29-chat-tags) + - 2.10 [Server Operators](#210-server-operators) + - 2.11 [Archive](#211-archive) +3. [Response Types](#3-response-types) +4. [Event Types](#4-event-types) +5. [Error Types](#5-error-types) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +The SimpleX Chat API bridge connects Kotlin/Compose UI code to the Haskell core via JNI. All communication follows a **command/response JSON protocol**: + +``` +Kotlin suspend fun api*() + -> ChatController.sendCmd(rhId, CC.*, ctrl) + -> serialize CC to cmdString (JSON) + -> chatSendCmdRetry(ctrl, cmdString, retryNum) [JNI / external fun] + -> Haskell core processes command + -> returns JSON response string + -> json.decodeFromString(responseString) + -> API.Result(rhId, CR.*) or API.Error(rhId, ChatError) + -> pattern-match on CR subclass -> update ChatModel / return data to UI +``` + +**Key types in the pipeline:** + +| Type | Role | Location | +|------|------|----------| +| `CC` (sealed class) | Command definitions (~165 subclasses) | [SimpleXAPI.kt#L3529](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L3529) | +| `API` (sealed class) | Top-level response wrapper (`Result` / `Error`) | [SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975) | +| `CR` (sealed class) | Chat response variants (~180 subclasses) | [SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114) | +| `ChatError` (sealed class) | Error hierarchy | [SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974) | +| `ChatController` (object) | Singleton hosting all `api*` functions | [SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493) | + +**JNI bridge functions** (declared in [Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +external fun chatCloseStore(ctrl: ChatCtrl): String +external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String +external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String +external fun chatRecvMsg(ctrl: ChatCtrl): String +external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String +``` + + + +**`sendCmd` flow** ([SimpleXAPI.kt#L804](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804)): + +1. Obtains the `ChatCtrl` handle (or uses the provided `otherCtrl`). +2. Serializes the `CC` command to its `cmdString`. +3. Dispatches to `Dispatchers.IO`; calls `chatSendCmdRetry` (local) or `chatSendRemoteCmdRetry` (remote host). +4. Decodes the returned JSON string into `API`. +5. Logs the result to the terminal item list. + + + + + +**Asynchronous event receiver** (`startReceiver`, [SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)): + +A long-running coroutine on `Dispatchers.IO` repeatedly calls `chatRecvMsgWait` (blocking JNI). Each received `API` message is dispatched to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)), which pattern-matches on `CR` subclasses to update `ChatModel` state and trigger notifications. + +--- + + + +## 2. Command Categories + +All functions below are `suspend fun` members of `ChatController` ([SimpleXAPI.kt#L493](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L493)). The `rh` / `rhId` parameter is `Long?` identifying a remote host (`null` = local device). + +### 2.1 User Management + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetActiveUser` | `rh: Long?, ctrl: ChatCtrl?` | Fetch the currently active user profile | [L841](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L841) | +| `apiCreateActiveUser` | `rh: Long?, p: Profile?, pastTimestamp: Boolean, ctrl: ChatCtrl?` | Create a new user profile and set it as active | [L851](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L851) | +| `listUsers` | `rh: Long?` | List all user profiles sorted by display name | [L871](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L871) | +| `apiSetActiveUser` | `rh: Long?, userId: Long, viewPwd: String?` | Switch the active user to a different profile | [L881](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L881) | +| `apiSetAllContactReceipts` | `rh: Long?, enable: Boolean` | Enable/disable delivery receipts for all contacts globally | [L888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L888) | +| `apiSetUserContactReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user contacts | [L894](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L894) | +| `apiSetUserGroupReceipts` | `u: User, userMsgReceiptSettings: UserMsgReceiptSettings` | Set delivery receipt settings for user groups | [L900](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L900) | +| `apiSetUserAutoAcceptMemberContacts` | `u: User, enable: Boolean` | Toggle auto-accept for member contact requests | [L906](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L906) | +| `apiHideUser` | `u: User, viewPwd: String` | Hide a user profile behind a password | [L912](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L912) | +| `apiUnhideUser` | `u: User, viewPwd: String` | Unhide a previously hidden user profile | [L915](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L915) | +| `apiMuteUser` | `u: User` | Mute all notifications for a user profile | [L918](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L918) | +| `apiUnmuteUser` | `u: User` | Unmute notifications for a user profile | [L921](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L921) | +| `apiDeleteUser` | `u: User, delSMPQueues: Boolean, viewPwd: String?` | Delete a user profile and optionally its SMP queues | [L930](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L930) | +| `apiUpdateProfile` | `rh: Long?, profile: Profile` | Update the active user's display profile | [L1682](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1682) | +| `apiSetProfileAddress` | `rh: Long?, on: Boolean` | Enable/disable including address in user profile | [L1694](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1694) | +| `apiSetUserUIThemes` | `rh: Long?, userId: Long, themes: ThemeModeOverrides?` | Set UI theme overrides for a user | [L1732](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1732) | + +### 2.2 Chat Lifecycle + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiStartChat` | `ctrl: ChatCtrl?` | Start the chat engine (returns `true` if newly started) | [L937](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L937) | +| `apiStopChat` | _(none)_ | Stop the chat engine | [L955](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L955) | +| `apiSetAppFilePaths` | `filesFolder, tempFolder, assetsFolder, remoteHostsFolder: String, ctrl: ChatCtrl?` | Configure file-system paths for the Haskell core | [L961](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L961) | +| `apiSetEncryptLocalFiles` | `enable: Boolean` | Enable/disable encryption of locally stored files | [L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967) | +| `apiSaveAppSettings` | `settings: AppSettings` | Persist application settings to the core | [L969](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L969) | +| `apiGetAppSettings` | `settings: AppSettings` | Retrieve application settings from the core | [L975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L975) | +| `apiGetChats` | `rh: Long?` | Fetch the list of all chats for the active user | [L1013](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1013) | +| `apiGetChat` | `rh, type, id, scope, contentTag, pagination, search` | Fetch a single chat with paginated messages | [L1031](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1031) | +| `apiGetChatContentTypes` | `rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?` | Get available content type filters for a chat | [L1044](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1044) | +| `apiClearChat` | `rh: Long?, type: ChatType, id: Long` | Delete all messages in a chat | [L1675](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1675) | +| `apiDeleteChat` | `rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a chat (contact, group, connection, etc.) | [L1620](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1620) | +| `apiChatRead` | `rh: Long?, type: ChatType, id: Long` | Mark a chat as read | [L1888](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1888) | +| `apiChatItemsRead` | `rh, type, id, scope, itemIds` | Mark specific chat items as read | [L1902](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1902) | +| `apiChatUnread` | `rh: Long?, type: ChatType, id: Long, unreadChat: Boolean` | Toggle a chat's unread flag | [L1909](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1909) | +| `getChatItemTTL` | `rh: Long?` | Get the auto-delete TTL for chat items | [L1286](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1286) | +| `setChatItemTTL` | `rh: Long?, chatItemTTL: ChatItemTTL` | Set the auto-delete TTL for chat items | [L1299](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1299) | +| `setChatTTL` | `rh: Long?, chatType, id, chatItemTTL` | Set TTL for a specific chat | [L1306](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1306) | + +### 2.3 Message Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSendMessages` | `rh, type, id, scope, live, ttl, composedMessages` | Send one or more messages to a chat | [L1074](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1074) | +| `apiCreateChatItems` | `rh: Long?, noteFolderId: Long, composedMessages: List` | Create items in a private notes folder | [L1111](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1111) | +| `apiReportMessage` | `rh, groupId, chatItemId, reportReason, reportText` | Report a message in a group | [L1119](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1119) | +| `apiGetChatItemInfo` | `rh, type, id, scope, itemId` | Get delivery info for a specific chat item | [L1126](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1126) | +| `apiForwardChatItems` | `rh, toChatType, toChatId, toScope, fromChatType, fromChatId, fromScope, itemIds, ttl` | Forward messages between chats | [L1133](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1133) | +| `apiPlanForwardChatItems` | `rh, fromChatType, fromChatId, fromScope, chatItemIds` | Check forward feasibility before forwarding | [L1138](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1138) | +| `apiUpdateChatItem` | `rh, type, id, scope, itemId, updatedMessage, live` | Edit an existing message | [L1145](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1145) | +| `apiChatItemReaction` | `rh, type, id, scope, itemId, add, reaction` | Add or remove a reaction to a message | [L1168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1168) | +| `apiGetReactionMembers` | `rh: Long?, groupId: Long, itemId: Long, reaction: MsgReaction` | List members who reacted with a specific emoji | [L1175](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1175) | +| `apiDeleteChatItems` | `rh, type, id, scope, itemIds, mode` | Delete messages (for self or for everyone) | [L1183](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1183) | +| `apiDeleteMemberChatItems` | `rh: Long?, groupId: Long, itemIds: List` | Moderate: delete another member's messages | [L1190](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1190) | +| `apiArchiveReceivedReports` | `rh: Long?, groupId: Long` | Archive all received reports in a group | [L1197](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1197) | +| `apiDeleteReceivedReports` | `rh: Long?, groupId: Long, itemIds: List, mode: CIDeleteMode` | Delete specific received reports | [L1204](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1204) | + +### 2.4 Group Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiNewGroup` | `rh: Long?, incognito: Boolean, groupProfile: GroupProfile` | Create a new group | [L2092](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2092) | +| `apiAddMember` | `rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole` | Invite a contact to a group | [L2100](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2100) | +| `apiJoinGroup` | `rh: Long?, groupId: Long` | Accept a group invitation | [L2109](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2109) | +| `apiAcceptMember` | `rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole` | Accept a member joining via group link | [L2135](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2135) | +| `apiDeleteMemberSupportChat` | `rh: Long?, groupId: Long, groupMemberId: Long` | Delete a member's support chat | [L2144](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2144) | +| `apiRemoveMembers` | `rh: Long?, groupId: Long, memberIds: List, withMessages: Boolean` | Remove members from a group | [L2151](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2151) | +| `apiMembersRole` | `rh: Long?, groupId: Long, memberIds: List, memberRole: GroupMemberRole` | Change the role of group members | [L2160](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2160) | +| `apiBlockMembersForAll` | `rh: Long?, groupId: Long, memberIds: List, blocked: Boolean` | Block/unblock members for all group participants | [L2169](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2169) | +| `apiLeaveGroup` | `rh: Long?, groupId: Long` | Leave a group | [L2178](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2178) | +| `apiListMembers` | `rh: Long?, groupId: Long` | List all members of a group | [L2185](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2185) | +| `apiUpdateGroup` | `rh: Long?, groupId: Long, groupProfile: GroupProfile` | Update group profile (name, image, etc.) | [L2192](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2192) | +| `apiCreateGroupLink` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Create a group invitation link | [L2211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2211) | +| `apiGroupLinkMemberRole` | `rh: Long?, groupId: Long, memberRole: GroupMemberRole` | Update the default role for group link joins | [L2226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2226) | +| `apiDeleteGroupLink` | `rh: Long?, groupId: Long` | Delete the group invitation link | [L2235](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2235) | +| `apiGetGroupLink` | `rh: Long?, groupId: Long` | Retrieve the current group invitation link | [L2245](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2245) | +| `apiAddGroupShortLink` | `rh: Long?, groupId: Long` | Create a short link for the group | [L2252](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2252) | +| `apiCreateMemberContact` | `rh: Long?, groupId: Long, groupMemberId: Long` | Create a direct contact from a group member | [L2262](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2262) | +| `apiSendMemberContactInvitation` | `rh: Long?, contactId: Long, mc: MsgContent` | Send a direct message invitation to a group member | [L2271](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2271) | +| `apiAcceptMemberContact` | `rh: Long?, contactId: Long` | Accept a member's direct contact invitation | [L2280](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2280) | +| `apiSetMemberSettings` | `rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings` | Configure per-member settings (e.g., mentions) | [L1343](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1343) | +| `apiGroupMemberInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get a group member's info and connection stats | [L1353](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1353) | +| `apiSetGroupAlias` | `rh: Long?, groupId: Long, localAlias: String` | Set a local alias for a group | [L1718](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1718) | + +### 2.5 Contact Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiAddContact` | `rh: Long?, incognito: Boolean` | Create a one-time invitation link for a new contact | [L1444](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1444) | +| `apiSetConnectionIncognito` | `rh: Long?, connId: Long, incognito: Boolean` | Toggle incognito on a pending connection | [L1455](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1455) | +| `apiChangeConnectionUser` | `rh: Long?, connId: Long, userId: Long` | Change the user profile on a pending connection | [L1464](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1464) | +| `apiConnectPlan` | `rh: Long?, connLink: String, inProgress: MutableState` | Analyze a connection link before connecting | [L1474](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1474) | +| `apiConnect` | `rh: Long?, incognito: Boolean, connLink: CreatedConnLink` | Connect via an invitation or address link | [L1482](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1482) | +| `apiPrepareContact` | `rh, connLink, contactShortLinkData` | Prepare a contact chat from a short link (before connecting) | [L1546](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1546) | +| `apiPrepareGroup` | `rh, connLink, groupShortLinkData` | Prepare a group chat from a short link (before connecting) | [L1555](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1555) | +| `apiConnectPreparedContact` | `rh, contactId, incognito, msg` | Connect to a previously prepared contact | [L1580](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1580) | +| `apiConnectPreparedGroup` | `rh, groupId, incognito, msg` | Join a previously prepared group | [L1590](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1590) | +| `apiConnectContactViaAddress` | `rh: Long?, incognito: Boolean, contactId: Long` | Connect to a contact using their public address | [L1600](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1600) | +| `apiDeleteContact` | `rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode` | Delete a contact and return the deleted Contact | [L1644](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1644) | +| `apiContactInfo` | `rh: Long?, contactId: Long` | Get a contact's connection stats and custom profile | [L1346](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1346) | +| `apiSetContactAlias` | `rh: Long?, contactId: Long, localAlias: String` | Set a local display alias for a contact | [L1711](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1711) | +| `apiSetConnectionAlias` | `rh: Long?, connId: Long, localAlias: String` | Set a local display alias for a pending connection | [L1725](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1725) | +| `apiSetContactPrefs` | `rh: Long?, contactId: Long, prefs: ChatPreferences` | Update feature preferences for a contact | [L1704](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1704) | +| `apiCreateUserAddress` | `rh: Long?` | Create a long-term public contact address | [L1746](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1746) | +| `apiDeleteUserAddress` | `rh: Long?` | Delete the user's public contact address | [L1762](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1762) | +| `apiAddMyAddressShortLink` | `rh: Long?` | Create a short link for the user's address | [L1784](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1784) | +| `apiSetUserAddressSettings` | `rh: Long?, settings: AddressSettings` | Configure auto-accept for incoming contact requests | [L1795](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1795) | +| `apiAcceptContactRequest` | `rh: Long?, incognito: Boolean, contactReqId: Long` | Accept an incoming contact request | [L1809](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1809) | +| `apiRejectContactRequest` | `rh: Long?, contactReqId: Long` | Reject an incoming contact request | [L1832](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1832) | +| `apiSwitchContact` | `rh: Long?, contactId: Long` | Initiate SMP server switch for a contact | [L1374](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1374) | +| `apiAbortSwitchContact` | `rh: Long?, contactId: Long` | Abort an in-progress server switch | [L1388](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1388) | +| `apiSyncContactRatchet` | `rh: Long?, contactId: Long, force: Boolean` | Force ratchet synchronization with a contact | [L1402](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1402) | +| `apiGetContactCode` | `rh: Long?, contactId: Long` | Get the security verification code for a contact | [L1416](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1416) | +| `apiVerifyContact` | `rh: Long?, contactId: Long, connectionCode: String?` | Verify a contact's security code | [L1430](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1430) | + +### 2.6 File Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `receiveFiles` | `rhId, user, fileIds, userApprovedRelays, auto` | Accept and download one or more files (handles relay approval) | [L1946](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | +| `receiveFile` | `rhId, user, fileId, userApprovedRelays, auto` | Accept and download a single file (convenience wrapper) | [L2062](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | +| `cancelFile` | `rh: Long?, user: User, fileId: Long` | Cancel an in-progress file transfer and clean up | [L2072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | +| `apiCancelFile` | `rh: Long?, fileId: Long, ctrl: ChatCtrl?` | Cancel a file transfer (low-level, returns updated chat item) | [L2080](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | +| `uploadStandaloneFile` | `user: UserLike, file: CryptoFile, ctrl: ChatCtrl?` | Upload a standalone file (used for migration) | [L1916](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | +| `downloadStandaloneFile` | `user: UserLike, url: String, file: CryptoFile, ctrl: ChatCtrl?` | Download a standalone file by URL | [L1926](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | +| `standaloneFileInfo` | `url: String, ctrl: ChatCtrl?` | Retrieve metadata for a standalone file link | [L1936](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1936) | + +### 2.7 Call Operations + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiGetCallInvitations` | `rh: Long?` | Retrieve pending call invitations | [L1842](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | +| `apiSendCallInvitation` | `rh: Long?, contact: Contact, callType: CallType` | Initiate a call by sending an invitation | [L1849](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | +| `apiRejectCall` | `rh: Long?, contact: Contact` | Reject an incoming call | [L1854](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | +| `apiSendCallOffer` | `rh, contact, rtcSession, rtcIceCandidates, media, capabilities` | Send a WebRTC call offer | [L1859](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | +| `apiSendCallAnswer` | `rh: Long?, contact: Contact, rtcSession: String, rtcIceCandidates: String` | Send a WebRTC call answer | [L1866](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | +| `apiSendCallExtraInfo` | `rh: Long?, contact: Contact, rtcIceCandidates: String` | Send additional ICE candidates during a call | [L1872](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | +| `apiEndCall` | `rh: Long?, contact: Contact` | End an active call | [L1878](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | +| `apiCallStatus` | `rh: Long?, contact: Contact, status: WebRTCCallStatus` | Report call status updates to the core | [L1883](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | + +### 2.8 Settings & Network + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiSetNetworkConfig` | `cfg: NetCfg, showAlertOnError: Boolean, ctrl: ChatCtrl?` | Apply network configuration (SOCKS proxy, timeouts, etc.) | [L1313](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1313) | +| `apiSetNetworkInfo` | `networkInfo: UserNetworkInfo` | Update network reachability information | [L1340](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1340) | +| `apiSetSettings` | `rh: Long?, type: ChatType, id: Long, settings: ChatSettings` | Update per-chat settings (notifications, favorites) | [L1333](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1333) | +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Verify a database encryption key is correct | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | +| `testProtoServer` | `rh: Long?, server: String` | Test connectivity to a protocol server | [L1211](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1211) | +| `reconnectServer` | `rh: Long?, server: String` | Reconnect to a specific server | [L1326](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1326) | +| `reconnectAllServers` | `rh: Long?` | Reconnect to all servers | [L1331](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1331) | +| `apiSetChatUIThemes` | `rh: Long?, chatId: ChatId, themes: ThemeModeOverrides?` | Set per-chat UI theme overrides | [L1739](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1739) | +| `apiContactQueueInfo` | `rh: Long?, contactId: Long` | Get server queue diagnostics for a contact | [L1360](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1360) | +| `apiGroupMemberQueueInfo` | `rh: Long?, groupId: Long, groupMemberId: Long` | Get server queue diagnostics for a group member | [L1367](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1367) | + +### 2.9 Chat Tags + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiCreateChatTag` | `rh: Long?, tag: ChatTagData` | Create a new chat tag (folder/label) | [L1052](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1052) | +| `apiSetChatTags` | `rh: Long?, type: ChatType, id: Long, tagIds: List` | Assign tags to a chat | [L1060](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1060) | +| `apiDeleteChatTag` | `rh: Long?, tagId: Long` | Delete a chat tag | [L1068](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1068) | +| `apiUpdateChatTag` | `rh: Long?, tagId: Long, tag: ChatTagData` | Update a chat tag's name or emoji | [L1070](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1070) | +| `apiReorderChatTags` | `rh: Long?, tagIds: List` | Set the display order of chat tags | [L1072](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1072) | + +### 2.10 Server Operators + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `getServerOperators` | `rh: Long?` | Get server operator conditions detail | [L1219](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1219) | +| `setServerOperators` | `rh: Long?, operators: List` | Update the list of server operators | [L1226](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1226) | +| `getUserServers` | `rh: Long?` | Get the user's configured servers per operator | [L1233](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1233) | +| `setUserServers` | `rh: Long?, userServers: List` | Save user's configured servers per operator | [L1241](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1241) | +| `validateServers` | `rh: Long?, userServers: List` | Validate server configuration for errors | [L1253](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1253) | +| `getUsageConditions` | `rh: Long?` | Get current and accepted usage conditions | [L1261](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1261) | +| `setConditionsNotified` | `rh: Long?, conditionsId: Long` | Mark conditions as shown to user | [L1268](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1268) | +| `acceptConditions` | `rh: Long?, conditionsId: Long, operatorIds: List` | Accept usage conditions for operators | [L1275](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1275) | + +### 2.11 Archive + +| Command | Parameters | Description | Line | +|---------|-----------|-------------|------| +| `apiExportArchive` | `config: ArchiveConfig` | Export chat database to a ZIP archive | [L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive` | `config: ArchiveConfig` | Import chat database from a ZIP archive | [L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage` | _(none)_ | Delete all chat database storage | [L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + + + +`ArchiveConfig` ([SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162)): + +```kotlin +class ArchiveConfig( + val archivePath: String, + val disableCompression: Boolean? = null, + val parentTempDirectory: String? = null +) +``` + +--- + + + +## 3. Response Types + +All command responses are deserialized into the `API` sealed class ([SimpleXAPI.kt#L5975](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L5975)): + +```kotlin +sealed class API { + class Result(val remoteHostId: Long?, val res: CR) : API() + class Error(val remoteHostId: Long?, val err: ChatError) : API() +} +``` + + + +The `CR` sealed class ([SimpleXAPI.kt#L6114](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6114)) contains approximately 180 response variants. Key categories: + +| Category | Examples | Lines | +|----------|---------|-------| +| User | `ActiveUser`, `UsersList`, `UserPrivacy`, `UserProfileUpdated` | L6104-L6157 | +| Chat state | `ChatStarted`, `ChatRunning`, `ChatStopped`, `ApiChats`, `ApiChat` | L6106-L6110 | +| Tags | `ChatTags`, `TagsUpdated` | L6112, L6137 | +| Contacts | `Invitation`, `SentConfirmation`, `SentInvitation`, `ContactConnected`, `ContactDeleted` | L6138-L6165 | +| Messages | `NewChatItems`, `ChatItemUpdated`, `ChatItemsDeleted`, `ChatItemReaction`, `ForwardPlan` | L6176-L6184 | +| Groups | `GroupCreated`, `SentGroupInvitation`, `UserAcceptedGroupSent`, `GroupUpdated`, `GroupMembers` | L6186-L6219 | +| Files (receive) | `RcvFileAccepted`, `RcvFileStart`, `RcvFileComplete`, `RcvFileCancelled`, `RcvFileError` | L6221-L6232 | +| Files (send) | `SndFileStart`, `SndFileComplete`, `SndFileCancelled`, `SndFileCompleteXFTP` | L6234-L6244 | +| Calls | `CallInvitation`, `CallOffer`, `CallAnswer`, `CallExtraInfo`, `CallEnded` | L6246-L6251 | +| Remote host | `RemoteHostList`, `RemoteHostStarted`, `RemoteHostConnected`, `RemoteHostStopped` | L6255-L6262 | +| Remote ctrl | `RemoteCtrlList`, `RemoteCtrlFound`, `RemoteCtrlConnected`, `RemoteCtrlStopped` | L6264-L6269 | +| Encryption | `ContactPQAllowed`, `ContactPQEnabled` | L6271-L6272 | +| Misc | `CmdOk`, `ArchiveExported`, `ArchiveImported`, `AppSettingsR`, `VersionInfo` | L6274-L6283 | +| Fallback | `Response` (unknown type + raw JSON), `Invalid` (unparseable) | L6282-L6283 | + +Each `CR` subclass is annotated with `@Serializable @SerialName("jsonTag")` for polymorphic JSON deserialization. + +--- + +## 4. Event Types + +The chat core pushes asynchronous events through the same `CR` type hierarchy. The `startReceiver` coroutine ([SimpleXAPI.kt#L660](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660)) continuously calls `chatRecvMsgWait` (blocking JNI), then dispatches each message to `processReceivedMsg` ([SimpleXAPI.kt#L2568](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568)). + +Events handled in `processReceivedMsg` include: + +| Event | Description | +|-------|-------------| +| `ContactConnected` | A contact has completed the connection handshake | +| `ContactConnecting` | A contact connection is in progress | +| `ContactSndReady` | Contact's sending channel is ready | +| `ContactDeletedByContact` | A contact deleted their side of the conversation | +| `ReceivedContactRequest` | An incoming contact request arrived | +| `NewChatItems` | New messages received | +| `ChatItemUpdated` | A message was edited | +| `ChatItemsDeleted` | Messages were deleted | +| `ChatItemReaction` | A reaction was added/removed | +| `ChatItemsStatusesUpdated` | Delivery statuses updated | +| `GroupCreated` | A new group was created | +| `ReceivedGroupInvitation` | An invitation to join a group | +| `JoinedGroupMember` | A new member joined | +| `DeletedMember` / `DeletedMemberUser` | A member was removed | +| `LeftMember` | A member left voluntarily | +| `GroupUpdated` | Group profile changed | +| `MemberRole` | A member's role changed | +| `MemberBlockedForAll` | A member was blocked for all | +| `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress | +| `SndFileStart` / `SndFileComplete` / `SndFileError` | File send progress | +| `CallInvitation` / `CallOffer` / `CallAnswer` / `CallEnded` | Call signaling events | +| `ContactPQEnabled` | Post-quantum encryption status changed | +| `RemoteHostStopped` / `RemoteCtrlStopped` | Remote access session ended | +| `SubscriptionStatusEvt` | Connection subscription status changed | + +Each event triggers updates to `ChatModel` (reactive Compose state) and optionally fires platform notifications via `ntfManager`. + +--- + + + +## 5. Error Types + +### ChatError ([SimpleXAPI.kt#L6974](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L6974)) + +```kotlin +sealed class ChatError { + class ChatErrorChat(val errorType: ChatErrorType) // Application-level errors + class ChatErrorAgent(val agentError: AgentErrorType) // SMP/XFTP agent errors + class ChatErrorStore(val storeError: StoreError) // Database store errors + class ChatErrorDatabase(val databaseError: DatabaseError)// Database engine errors + class ChatErrorRemoteHost(val remoteHostError: ...) // Remote host errors + class ChatErrorRemoteCtrl(val remoteCtrlError: ...) // Remote controller errors + class ChatErrorInvalidJSON(val json: String) // JSON parsing failure +} +``` + +### ChatErrorType ([SimpleXAPI.kt#L7004](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7004)) + +Common application error codes (~70 variants): + +| Error | Meaning | +|-------|---------| +| `NoActiveUser` | No user profile is set as active | +| `UserExists` | Attempted to create a duplicate user | +| `InvalidDisplayName` | Display name contains invalid characters | +| `ChatNotStarted` / `ChatNotStopped` | Chat engine in wrong state | +| `InvalidConnReq` / `UnsupportedConnReq` | Bad or incompatible connection link | +| `ContactNotReady` / `ContactDisabled` | Contact in unusable state | +| `GroupUserRole` | Insufficient group permissions | +| `GroupNotJoined` | User has not joined the group | +| `FileNotFound` / `FileCancelled` / `FileAlreadyReceiving` | File transfer errors | +| `FileNotApproved` | File from unapproved relay server | +| `HasCurrentCall` / `NoCurrentCall` | Call state conflicts | +| `CommandError` / `InternalError` / `CEException` | Generic/internal errors | + +### StoreError ([SimpleXAPI.kt#L7168](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7168)) + +Database-level errors: `DuplicateName`, `UserNotFound`, `GroupNotFound`, `ChatItemNotFound`, `LargeMsg`, `UserContactLinkNotFound`, etc. + +### ArchiveError ([SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658)) + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) + class ArchiveErrorFile(val file: String, val fileError: String) +} +``` + +--- + +## 6. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API bridge: all `api*` functions, `CC`, `CR`, `ChatError` | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals, `initChatController`, `chatMigrateInit` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| ChatModel.kt | Reactive UI state (`ChatModel` object) | `common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, DB password helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Files.kt | Platform-expect file path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual file paths | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual file paths | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AndroidKeyStore AES-GCM encryption | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) encryption | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | + +All paths are relative to `apps/multiplatform/`. diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md new file mode 100644 index 0000000000..cfef4d06c2 --- /dev/null +++ b/apps/multiplatform/spec/architecture.md @@ -0,0 +1,423 @@ +# System Architecture + +## Table of Contents + +1. [Overview](#1-overview) +2. [Module Structure](#2-module-structure) +3. [JNI Bridge](#3-jni-bridge) +4. [App Lifecycle](#4-app-lifecycle) +5. [Event Streaming](#5-event-streaming) +6. [Platform Abstraction](#6-platform-abstraction) +7. [Source Files](#7-source-files) + +--- + +## 1. Overview + +The application is a three-layer system: + +``` ++------------------------------------------------------------------+ +| Compose UI (Views) | +| ChatListView, ChatView, ComposeView, SettingsView, CallView | ++------------------------------------------------------------------+ + | ^ + | user actions | Compose MutableState recomposition + v | ++------------------------------------------------------------------+ +| Application Logic Layer | +| ChatModel (state) ChatController (command dispatch) | +| AppPreferences NtfManager ThemeManager | ++------------------------------------------------------------------+ + | ^ + | sendCmd() | recvMsg() / processReceivedMsg() + v | ++------------------------------------------------------------------+ +| JNI Bridge (Core.kt) | +| external fun chatSendCmdRetry() external fun chatRecvMsgWait()| ++------------------------------------------------------------------+ + | ^ + | C FFI | C FFI + v | ++------------------------------------------------------------------+ +| Haskell Core (libsimplex / libapp-lib) | +| chat_ctrl handle SMP/XFTP protocols SQLite/PostgreSQL | ++------------------------------------------------------------------+ +``` + +**Data flow summary:** +1. User interacts with Compose UI. +2. View calls a `suspend fun api*()` method on `ChatController`. +3. `ChatController.sendCmd()` serializes the command to a JSON string and calls `chatSendCmdRetry()` (JNI). +4. The Haskell core processes the command and returns a JSON response string. +5. The response is deserialized to an `API` sealed class and returned to the caller. +6. Asynchronous events from the core (incoming messages, connection updates, call invitations) are delivered via a receiver coroutine that calls `chatRecvMsgWait()` in a loop and dispatches each event through `processReceivedMsg()`. + +--- + +## 2. Module Structure + +### Gradle Configuration + +Root: [`settings.gradle.kts`](../settings.gradle.kts#L22) +``` +include(":android", ":desktop", ":common") +``` + +### `:common` Module + +Build file: [`common/build.gradle.kts`](../common/build.gradle.kts#L14) + +``` +kotlin { + androidTarget() + jvm("desktop") +} +``` + +Source sets: + +| Source Set | Path | Purpose | +|---|---|---| +| `commonMain` | `common/src/commonMain/kotlin/` | All shared UI, models, platform abstractions | +| `androidMain` | `common/src/androidMain/kotlin/` | Android `actual` implementations | +| `desktopMain` | `common/src/desktopMain/kotlin/` | Desktop `actual` implementations | + +Key dependencies (from `commonMain`): +- `kotlinx-serialization-json` -- JSON codec for Haskell core communication +- `kotlinx-datetime` -- cross-platform date/time +- `multiplatform-settings` (russhwolf) -- `SharedPreferences` abstraction +- `kaml` -- YAML parsing (theme import/export) +- `boofcv-core` -- QR code scanning +- `jsoup` -- HTML parsing for link previews +- `moko-resources` -- cross-platform string/image resources +- `multiplatform-markdown-renderer` -- Markdown rendering in chat + +### `:android` Module + +Build file: [`android/build.gradle.kts`](../android/build.gradle.kts) + +Contains: +- `SimplexApp` (Application subclass) +- `MainActivity` (FragmentActivity) +- `SimplexService` (foreground Service) +- `NtfManager` (Android NotificationManager wrapper) +- `CallActivity` (dedicated activity for calls) + +### `:desktop` Module + +Build file: [`desktop/build.gradle.kts`](../desktop/build.gradle.kts) + +Contains: +- `main()` entry point +- `initHaskell()` -- loads native library and calls `initHS()` +- Window management (VLC library loading on Windows) + +--- + +## 3. JNI Bridge + +All JNI declarations reside in [`Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt). + + + + +### External Native Functions + +| # | Function | Signature | Line | Purpose | +|---|---|---|---|---| +| 1 | [`initHS()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L18) | `external fun initHS()` | 18 | Initialize GHC runtime system | +| 2 | [`pipeStdOutToSocket()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L20) | `external fun pipeStdOutToSocket(socketName: String): Int` | 20 | Redirect Haskell stdout to Android local socket for logging | +| 3 | [`chatMigrateInit()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25) | `external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array` | 25 | Initialize database with migration; returns `[jsonResult, chatCtrl]` | +| 4 | [`chatCloseStore()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L26) | `external fun chatCloseStore(ctrl: ChatCtrl): String` | 26 | Close database store | +| 5 | [`chatSendCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L27) | `external fun chatSendCmdRetry(ctrl: ChatCtrl, msg: String, retryNum: Int): String` | 27 | Send command to core with retry count | +| 6 | [`chatSendRemoteCmdRetry()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L28) | `external fun chatSendRemoteCmdRetry(ctrl: ChatCtrl, rhId: Int, msg: String, retryNum: Int): String` | 28 | Send command to remote host | +| 7 | [`chatRecvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L29) | `external fun chatRecvMsg(ctrl: ChatCtrl): String` | 29 | Receive message (non-blocking) | +| 8 | [`chatRecvMsgWait()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L30) | `external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String` | 30 | Receive message with timeout (blocking up to `timeout` microseconds) | +| 9 | [`chatParseMarkdown()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L31) | `external fun chatParseMarkdown(str: String): String` | 31 | Parse markdown formatting | +| 10 | [`chatParseServer()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L32) | `external fun chatParseServer(str: String): String` | 32 | Parse SMP/XFTP server address | +| 11 | [`chatParseUri()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L33) | `external fun chatParseUri(str: String, safe: Int): String` | 33 | Parse SimpleX connection URI | +| 12 | [`chatPasswordHash()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L34) | `external fun chatPasswordHash(pwd: String, salt: String): String` | 34 | Hash password with salt | +| 13 | [`chatValidName()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L35) | `external fun chatValidName(name: String): String` | 35 | Validate/sanitize display name | +| 14 | [`chatJsonLength()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L36) | `external fun chatJsonLength(str: String): Int` | 36 | Get JSON-encoded string length | +| 15 | [`chatWriteFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L37) | `external fun chatWriteFile(ctrl: ChatCtrl, path: String, buffer: ByteBuffer): String` | 37 | Write encrypted file via core | +| 16 | [`chatReadFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L38) | `external fun chatReadFile(path: String, key: String, nonce: String): Array` | 38 | Read and decrypt file | +| 17 | [`chatEncryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39) | `external fun chatEncryptFile(ctrl: ChatCtrl, fromPath: String, toPath: String): String` | 39 | Encrypt file on disk | +| 18 | [`chatDecryptFile()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L40) | `external fun chatDecryptFile(fromPath: String, key: String, nonce: String, toPath: String): String` | 40 | Decrypt file on disk | + +**Total: 18 external native functions** (the `ChatCtrl` type alias at [line 23](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L23) is `Long`, representing the Haskell-side controller pointer). + + + + + +### Key Kotlin Functions in Core.kt + +| Function | Line | Purpose | +|---|---|---| +| [`initChatControllerOnStart()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L51) | 51 | Entry point called during app startup; launches `initChatController` in a long-running coroutine | +| [`initChatController()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62) | 62 | Main initialization: DB migration via `chatMigrateInit`, error recovery (incomplete DB removal), sets file paths, loads active user, starts chat | +| [`chatInitTemporaryDatabase()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L190) | 190 | Creates a temporary database for migration scenarios | +| [`chatInitControllerRemovingDatabases()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L202) | 202 | Removes existing DBs and creates fresh controller (used during re-initialization) | +| [`showStartChatAfterRestartAlert()`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L222) | 222 | Shows confirmation dialog when chat was stopped and DB passphrase is stored | + + + +### initChatController Flow + +``` +initChatController(useKey, confirmMigrations, startChat) + | + +-- chatMigrateInit(dbPath, dbKey, confirm) // JNI -> Haskell + | returns [jsonResult, chatCtrl] + | + +-- if migration error and rerunnable: + | chatMigrateInit(dbPath, dbKey, confirm) // retry with user confirmation + | + +-- setChatCtrl(ctrl) // store controller handle + +-- apiSetAppFilePaths(...) // tell core about file dirs + +-- apiSetEncryptLocalFiles(...) + +-- apiGetActiveUser() -> currentUser + +-- getServerOperators() -> conditions + +-- if shouldImportAppSettings: apiGetAppSettings + importIntoApp + +-- if user exists and startChat confirmed: + | startChat(user) // starts receiver, API commands + +-- else if no user: + set onboarding stage, optionally startChatWithoutUser() +``` + +--- + +## 4. App Lifecycle + +### Android + +Entry: [`SimplexApp.onCreate()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L47) + +``` +SimplexApp.onCreate() + +-- initHaskell(packageName) // Load native lib, pipe stdout, call initHS() + | +-- System.loadLibrary("app-lib") + | +-- pipeStdOutToSocket(packageName) + | +-- initHS() + +-- initMultiplatform() // Set up ntfManager, platform callbacks + +-- reconfigureBroadcastReceivers() + +-- runMigrations() // Theme migration, version code tracking + +-- initChatControllerOnStart() // -> initChatController() -> chatMigrateInit -> startChat +``` + +Activity: [`MainActivity.onCreate()`](../android/src/main/java/chat/simplex/app/MainActivity.kt#L32) + +``` +MainActivity.onCreate() + +-- processNotificationIntent(intent) // Handle OpenChat/AcceptCall from notifications + +-- processIntent(intent) // Handle VIEW intents (deep links) + +-- processExternalIntent(intent) // Handle SEND/SEND_MULTIPLE (share sheet) + +-- setContent { AppScreen() } // Compose UI entry point +``` + +Lifecycle callbacks in `SimplexApp` (implements `LifecycleEventObserver`): +- `ON_START`: refresh chat list from API if chat is running +- `ON_RESUME`: show background service notice, start `SimplexService` if configured + +### Desktop + +Entry: [`main()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L21) + +``` +main() + +-- initHaskell() // Load native lib from resources dir, call initHS() + | +-- System.load(libapp-lib.so/dll/dylib) + | +-- initHS() + +-- runMigrations() + +-- setupUpdateChecker() + +-- initApp() // Set ntfManager, applyAppLocale, initChatControllerOnStart + +-- showApp() // Compose window with AppScreen() +``` + +[`showApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt#L33) creates a Compose `Window` with error recovery -- if a crash occurs, it closes the offending modal/view and re-opens the window. + +[`initApp()`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt#L21) sets the `ntfManager` implementation (desktop notifications via `NtfManager` in `common/model/`) and calls `initChatControllerOnStart()`. + +--- + +## 5. Event Streaming + +### Receiver Coroutine + +[`ChatController.startReceiver()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L660) launches a coroutine on `Dispatchers.IO` that continuously polls for events from the Haskell core: + +```kotlin +// SimpleXAPI.kt line 660 +private fun startReceiver() { + if (receiverJob != null || chatCtrl == null) return // guard against double-start + receiverJob = CoroutineScope(Dispatchers.IO).launch { + var releaseLock: (() -> Unit) = {} + while (isActive) { + val ctrl = chatCtrl + if (ctrl == null) { stopReceiver(); break } // chatCtrl became null + try { + val release = releaseLock + launch { delay(30000); release() } // release previous wake lock after 30s + val msg = recvMsg(ctrl) // calls chatRecvMsgWait with 300s timeout + releaseLock = getWakeLock(timeout = 60000) // acquire wake lock (60s timeout) + if (msg != null) { + val finished = withTimeoutOrNull(60_000L) { + processReceivedMsg(msg) + messagesChannel.trySend(msg) + } + if (finished == null) { + Log.e(TAG, "Timeout processing: " + msg.responseType) + } + } + } catch (e: Exception) { + Log.e(TAG, "recvMsg/processReceivedMsg exception: " + e.stackTraceToString()) + } catch (e: Throwable) { + Log.e(TAG, "recvMsg/processReceivedMsg throwable: " + e.stackTraceToString()) + } + } + } +} +``` + +### Message Reception + +[`recvMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L829) calls `chatRecvMsgWait(ctrl, MESSAGE_TIMEOUT)` where `MESSAGE_TIMEOUT = 300_000_000` microseconds (300 seconds). Returns `null` on timeout (empty string from Haskell), otherwise deserializes the JSON response to an `API` instance. + +### Command Sending + +[`sendCmd()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L804) runs on `Dispatchers.IO`, serializes the command via `CC.cmdString`, calls `chatSendCmdRetry()` (or `chatSendRemoteCmdRetry()` for remote hosts), deserializes the response, and logs terminal items. + +### Event Processing + +[`processReceivedMsg()`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2568) is a large `when` block that dispatches on the `CR` (ChatResponse) type: + +- `CR.ContactConnected` -- update contact in `ChatModel` +- `CR.NewChatItems` -- add items to chat, trigger notifications +- `CR.RcvCallInvitation` -- add to `callInvitations`, trigger call UI +- `CR.ChatStopped` -- set `chatRunning = false` +- `CR.GroupMemberConnected`, `CR.GroupMemberUpdated`, etc. -- update group state +- Many more event types for connection status, file transfers, SMP relay events, etc. + +### Wake Lock + +On Android, the receiver acquires a wake lock via [`getWakeLock(timeout)`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) (expect function) after each received message with a 60-second timeout. The previous iteration's wake lock is released after a 30-second delay, ensuring overlap so the CPU does not sleep between messages. + +--- + +## 6. Platform Abstraction + +### expect/actual Pattern + +The `commonMain` source set declares `expect` functions and classes. Each platform source set provides `actual` implementations. + +Examples from platform files: + +| expect Declaration | File | Line | +|---|---|---| +| `expect val appPlatform: AppPlatform` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L20) | 20 | +| `expect val deviceName: String` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L22) | 22 | +| `expect fun isAppVisibleAndFocused(): Boolean` | [`AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt#L24) | 24 | +| `expect val dataDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | 18 | +| `expect val tmpDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | 19 | +| `expect val filesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | 20 | +| `expect val appFilesDir: File` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | 21 | +| `expect val dbAbsolutePrefixPath: String` | [`Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | 24 | +| `expect fun showToast(text: String, timeout: Long)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L6) | 6 | +| `expect fun hideKeyboard(view: Any?, clearFocus: Boolean)` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L16) | 16 | +| `expect fun getKeyboardState(): State` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L15) | 15 | +| `expect fun allowedToShowNotification(): Boolean` | [`Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt#L3) | 3 | +| `expect class VideoPlayer` | [`VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt#L25) | 25 | +| `expect class RecorderNative` | [`RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt#L17) | 17 | +| `expect val cryptor: CryptorInterface` | [`Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt#L9) | 9 | +| `expect fun base64ToBitmap(base64ImageString: String): ImageBitmap` | [`Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt#L17) | 17 | +| `expect fun getWakeLock(timeout: Long): (() -> Unit)` | [`SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt#L3) | 3 | +| `expect class GlobalExceptionsHandler` | [`UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt#L24) | 24 | +| `expect fun UriHandler.sendEmail(subject: String, body: CharSequence)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L7) | 7 | +| `expect fun ClipboardManager.shareText(text: String)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L9) | 9 | +| `expect fun shareFile(text: String, fileSource: CryptoFile)` | [`Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt#L10) | 10 | + +### PlatformInterface Callback Object + +[`PlatformInterface`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L15) is an interface with default no-op implementations. It is assigned at runtime by each platform entry point: + +- **Android**: assigned in [`SimplexApp.initMultiplatform()`](../android/src/main/java/chat/simplex/app/SimplexApp.kt#L187) (line 187) +- **Desktop**: assigned in [`Main.kt initHaskell()`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt#L50) (line 50) + +The global variable is declared at [`Platform.kt line 50`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt#L50): +```kotlin +var platform: PlatformInterface = object : PlatformInterface {} +``` + +#### PlatformInterface Callbacks + +| Callback | Default | Android Implementation | +|---|---|---| +| `androidServiceStart()` | no-op | Start `SimplexService` foreground service | +| `androidServiceSafeStop()` | no-op | Stop `SimplexService` | +| `androidCallServiceSafeStop()` | no-op | Stop `CallService` | +| `androidNotificationsModeChanged(mode)` | no-op | Toggle receivers, start/stop service | +| `androidChatStartedAfterBeingOff()` | no-op | Start service or schedule periodic worker | +| `androidChatStopped()` | no-op | Cancel workers, stop service | +| `androidChatInitializedAndStarted()` | no-op | Show background service notice, start service | +| `androidIsBackgroundCallAllowed()` | `true` | Check battery restriction | +| `androidSetNightModeIfSupported()` | no-op | Set `UiModeManager` night mode | +| `androidSetStatusAndNavigationBarAppearance(...)` | no-op | Configure system bar colors/appearance | +| `androidStartCallActivity(acceptCall, rhId, chatId)` | no-op | Launch `CallActivity` | +| `androidPictureInPictureAllowed()` | `true` | Check PiP permission via AppOps | +| `androidCallEnded()` | no-op | Destroy call WebView | +| `androidRestartNetworkObserver()` | no-op | Restart `NetworkObserver` | +| `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | +| `androidIsXiaomiDevice()` | `false` | Check device brand | +| `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | +| `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | +| `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | + +--- + +## 7. Source Files + +### Core Infrastructure + +| File | Path | Key Contents | +|---|---|---| +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | JNI externals, `initChatController`, `chatInitTemporaryDatabase` | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `ChatController`, `AppPreferences`, `startReceiver`, `sendCmd`, `recvMsg`, `processReceivedMsg`, all `api*` functions | +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton, `ChatsContext`, `Chat`, `ChatInfo`, `ChatItem` and all domain types | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen()`, `MainScreen()` | + +### Platform Layer + +| File | Path | Key Contents | +|---|---|---| +| Platform.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt) | `PlatformInterface`, global `platform` var | +| AppCommon.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt) | `AppPlatform`, `runMigrations()` | +| AppCommon.android.kt | [`common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt`](../common/src/androidMain/kotlin/chat/simplex/common/platform/AppCommon.android.kt) | `initHaskell()`, `androidAppContext` | +| AppCommon.desktop.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/platform/AppCommon.desktop.kt) | `initApp()`, desktop NtfManager setup | +| Files.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | `expect val dataDir/tmpDir/filesDir/dbAbsolutePrefixPath` | +| NtfManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | `abstract class NtfManager` | +| Notifications.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Notifications.kt) | `expect fun allowedToShowNotification()` | +| UI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/UI.kt) | `showToast`, `hideKeyboard`, `GlobalExceptionsHandler` | +| Share.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Share.kt) | `shareText`, `shareFile`, `openFile` | +| VideoPlayer.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/VideoPlayer.kt) | `VideoPlayerInterface`, `expect class VideoPlayer` | +| RecAndPlay.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt) | `RecorderInterface`, `AudioPlayerInterface` | +| Cryptor.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt) | `CryptorInterface` | +| Images.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Images.kt) | `base64ToBitmap`, `resizeImageToStrSize` | +| SimplexService.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/SimplexService.kt) | `expect fun getWakeLock()` | + +### Entry Points + +| File | Path | Key Contents | +|---|---|---| +| SimplexApp.kt | [`android/src/main/java/chat/simplex/app/SimplexApp.kt`](../android/src/main/java/chat/simplex/app/SimplexApp.kt) | Android Application class, lifecycle observer | +| MainActivity.kt | [`android/src/main/java/chat/simplex/app/MainActivity.kt`](../android/src/main/java/chat/simplex/app/MainActivity.kt) | Android main activity | +| SimplexService.kt | [`android/src/main/java/chat/simplex/app/SimplexService.kt`](../android/src/main/java/chat/simplex/app/SimplexService.kt) | Android foreground service | +| Main.kt | [`desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt`](../desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt) | Desktop `main()` | +| DesktopApp.kt | [`common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt`](../common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt) | `showApp()`, `SimplexWindowState` | + +### Theme + +| File | Path | Key Contents | +|---|---|---| +| ThemeManager.kt | [`common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt`](../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | Theme resolution, system/light/dark/custom, per-user overrides | diff --git a/apps/multiplatform/spec/client/chat-list.md b/apps/multiplatform/spec/client/chat-list.md new file mode 100644 index 0000000000..b0f3750659 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-list.md @@ -0,0 +1,314 @@ +# Chat List Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatListView Composable](#2-chatlistview-composable) +3. [Data Sources](#3-data-sources) +4. [Filter System](#4-filter-system) +5. [Chat Preview](#5-chat-preview) +6. [ChatListNavLinkView](#6-chatlistnavlinkview) +7. [Tag System](#7-tag-system) +8. [UserPicker](#8-userpicker) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat List is the landing screen of SimpleX Chat, rendering all conversations for the active user. Built around `ChatListView` (line 126 in `ChatListView.kt`), it provides a searchable, filterable `LazyColumn` of chat previews with a toolbar, tag-based filtering, and a user-switching side panel. The view adapts between one-hand UI mode (toolbar at bottom, reversed list) and standard mode (toolbar at top). Search also accepts SimpleX links for direct connection. + +--- + +## 1. Overview + +``` +ChatListView +|-- ChatListToolbar (top or bottom app bar) +| |-- UserProfileButton (opens UserPicker) +| |-- Title ("Your chats") +| |-- SubscriptionStatusIndicator +| +-- NewChatButton / StoppedIndicator +|-- ChatListWithLoadingScreen +| |-- ChatList (LazyColumnWithScrollBar) +| | |-- Spacer (top/bottom padding) +| | |-- stickyHeader +| | | |-- ChatListSearchBar (search input + filter toggle) +| | | +-- TagsView (preset + custom tag chips) +| | |-- ChatListNavLinkView[] (per-chat row items) +| | +-- ChatListFeatureCards (one-hand UI card, address card) +| +-- EmptyState text +|-- NewChatSheetFloatingButton (FAB, standard mode only) +|-- UserPicker (slide-in panel, Android) ++-- ActiveCallInteractiveArea (desktop, in-call banner) +``` + +--- + + + +## 2. ChatListView Composable + +**Location:** [`ChatListView.kt#L127`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L127) + +```kotlin +fun ChatListView( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit, + stopped: Boolean +) +``` + +### Initialization + +- Shows "What's New" modal on first launch after update (line ~130), with a 1-second delay. +- On desktop, closing a chat resets audio/video players (line ~138). + +### Layout Modes + +The `oneHandUI` preference (`appPrefs.oneHandUI.state`) controls the layout: + +| Mode | Toolbar Position | List Direction | FAB | Search/Tags Position | +|---|---|---|---|---| +| **Standard** (`oneHandUI = false`) | Top | Top-to-bottom | Bottom-right FAB | Below toolbar | +| **One-hand** (`oneHandUI = true`) | Bottom | Bottom-to-top (reversed) | Integrated in toolbar | Above toolbar | + +### State + +| State | Type | Purpose | +|---|---|---| +| `searchText` | `MutableState` | Search query (saved across recomposition) | +| `listState` | `LazyListState` | Scroll position (persisted in `lazyListState` var) | +| `oneHandUI` | `State` | One-hand UI mode toggle | + +### Android-specific + +- `SetNotificationsModeAdditions`: Notification permission setup (line ~184). +- `UserPicker`: Overlay side panel for user switching (line ~192). + +--- + +## 3. Data Sources + +| Source | Location | Description | +|---|---|---| +| `chatModel.chats` | `ChatModel.chatsContext.chats` | Full list of `Chat` objects for the active user | +| `chatModel.activeChatTagFilter` | `ChatModel.activeChatTagFilter` | Currently active filter (`PresetTag`, `UserTag`, or `Unread`) | +| `chatModel.userTags` | `ChatModel.userTags` | User-created custom tags | +| `chatModel.presetTags` | `ChatModel.presetTags` | Map of `PresetTagKind` to count | +| `chatModel.unreadTags` | `ChatModel.unreadTags` | Map of tag ID to unread count | +| `chatModel.chatId` | `ChatModel.chatId` | Currently selected chat ID (highlights row) | +| `chatModel.currentUser` | `ChatModel.currentUser` | Active user profile | +| `chatModel.users` | `ChatModel.users` | All user profiles (for UserPicker) | +| `chatModel.showChatPreviews` | `ChatModel.showChatPreviews` | Privacy toggle for message previews | + +--- + +## 4. Filter System + +### Active Filter Types + +Defined as sealed class `ActiveFilter` (line ~51): + +```kotlin +sealed class ActiveFilter { + data class PresetTag(val tag: PresetTagKind) : ActiveFilter() + data class UserTag(val tag: ChatTag) : ActiveFilter() + data object Unread : ActiveFilter() +} +``` + +### PresetTagKind Enum + +| Value | Description | +|---|---| +| `GROUP_REPORTS` | Groups with active reports (moderator-visible) | +| `FAVORITES` | Chats marked as favorite | +| `CONTACTS` | Direct (1:1) chats | +| `GROUPS` | Group chats | +| `BUSINESS` | Business-type chats | +| `NOTES` | Local note folders | + +### Search Filtering + +The `filteredChats` function (line ~1188) applies filters in this order: + +1. **SimpleX link match:** If a pasted link resolved to a known contact/group, show only that chat. +2. **Text search:** Case-insensitive match against `chat.chatInfo.chatViewName`, `chat.chatInfo.fullName`, and `chat.chatInfo.localAlias`. +3. **Active filter:** + - `PresetTag`: Matches chat type and characteristics (e.g., `CONTACTS` filters `ChatInfo.Direct`, `GROUPS` filters `ChatInfo.Group`). + - `UserTag`: Matches chats whose `chatTags` contain the tag ID. + - `Unread`: Matches chats with `unreadCount > 0` or `unreadChat == true`. + +### Search Bar + +`ChatListSearchBar` (line ~611) provides: +- Text input with search icon. +- SimpleX link detection: When a pasted string contains a single SimpleX link, it triggers `planAndConnect` for connection, suppressing normal search. +- Unread filter toggle button (right side, when search is empty). + +--- + + + +## 5. Chat Preview + +**Location:** [`ChatPreviewView.kt#L40`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt#L40) + +```kotlin +fun ChatPreviewView( + chat: Chat, + showChatPreviews: Boolean, + chatModelDraft: ComposeState?, + chatModelDraftChatId: ChatId?, + currentUserProfileDisplayName: String?, + disabled: Boolean, + linkMode: SimplexLinkMode, + inProgress: Boolean, + progressByTimeout: Boolean, + defaultClickAction: () -> Unit +) +``` + +### Layout + +Each chat preview row contains: + +| Element | Position | Content | +|---|---|---| +| Profile image | Left | `ChatInfoImage` with overlay icons for inactive contacts/groups | +| Title row | Top-right of image | Chat name (bold), verified shield (direct), timestamp | +| Preview row | Below title | Last message preview or draft indicator, unread badge | +| Unread badge | Right | Circular badge with count, or dot for muted chats | + +### Draft Display + +When `chatModelDraftChatId` matches the chat ID, the preview shows a draft indicator (pencil icon) with the draft message text instead of the last chat item. + +### Inactive Indicators + +- Inactive contacts: cancel icon overlay on profile image. +- Left/removed/deleted groups: cancel icon overlay. + +--- + + + +## 6. ChatListNavLinkView + +**Location:** [`ChatListNavLinkView.kt#L37`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt#L37) + +Routes each chat to the appropriate click action and context menu based on `chat.chatInfo`: + +| ChatInfo Type | Click Action | Context Menu | +|---|---|---| +| `ChatInfo.Direct` | `directChatAction` (opens chat) | `ContactMenuItems`: mark read/unread, mute, favorite, tag, clear, delete | +| `ChatInfo.Group` | `groupChatAction` (opens chat or joins) | `GroupMenuItems`: mark read/unread, mute, favorite, tag, clear, leave, delete | +| `ChatInfo.Local` | `noteFolderChatAction` (opens notes) | `NoteFolderMenuItems`: mark read, clear, delete | +| `ChatInfo.ContactRequest` | `contactRequestAlertDialog` (accept/reject) | `ContactRequestMenuItems`: reject | +| `ChatInfo.ContactConnection` | Sets `chatModel.chatId` (opens connection info) | `ContactConnectionMenuItems`: delete | +| `ChatInfo.InvalidJSON` | Sets `chatModel.chatId` | No menu | + +### Selection Highlight + +On desktop, the currently selected chat (`chatModel.chatId.value == chat.id`) receives a highlight background. `nextChatSelected` state is used to suppress the bottom divider when the next chat in the list is selected. + +--- + +## 7. Tag System + +### TagsView + +**Location:** [`ChatListView.kt#L929`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListView.kt#L929) + +Renders a horizontally scrollable row of tag chips (via `TagsRow`, which is a platform-specific `expect` composable). + +Layout logic: +- If there are more than 1 collapsible preset tags and the total tag count exceeds 3, preset tags collapse into a `CollapsedTagsFilterView` dropdown. +- Otherwise, each preset tag renders as an `ExpandedTagFilterView` chip. +- User tags render as individual chips with emoji or label icon, bold when active. +- A "+" button at the end opens `TagListEditor` for creating new tags. + +### Tag Interactions + +- **Single tap:** Toggles the tag filter on `chatModel.activeChatTagFilter`. +- **Long press / right-click (user tags):** Opens dropdown menu with edit/delete/reorder options. +- **Unread dot:** Shown on tags that have chats with unread messages. + + + +### TagListView + +**Location:** [`TagListView.kt#L48`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/TagListView.kt#L48) + +Full-screen tag management view opened from the "+" button or long-press menu. + +```kotlin +fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) +``` + +- Displays all user tags in a `LazyColumnWithScrollBar`. +- Supports drag-and-drop reordering via `rememberDragDropState` (calls `apiReorderChatTags`). +- Each tag row shows emoji/icon, name, chat count, and a checkbox if opened for a specific chat (to assign/unassign tags). +- "Create list" button opens `TagListEditor` modal. + +--- + + + +## 8. UserPicker + +**Location:** [`UserPicker.kt#L46`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/UserPicker.kt#L46) + +```kotlin +fun UserPicker( + chatModel: ChatModel, + userPickerState: MutableStateFlow, + setPerformLA: (Boolean) -> Unit +) +``` + +### Behavior + +- **Android:** Renders as a slide-up overlay panel on the chat list, triggered by tapping the user profile button in the toolbar. +- **Desktop:** Rendered inline in the left column of `DesktopScreen`, always accessible. +- Closes automatically when any `ModalManager.start` modal opens. + +### Content + +| Section | Content | +|---|---| +| **Active user** | Profile image, display name, "active" indicator | +| **Other users** | List of non-hidden user profiles sorted by `activeOrder`; tapping switches user | +| **Remote hosts** | Connected remote devices (desktop linking) | +| **Settings** | Opens `SettingsView` modal | +| **Color mode** | `ColorModeSwitcher` for theme toggle | +| **Add profile** | Opens `CreateProfile` flow | +| **Lock** | Locks app (calls `AppLock.setPerformLA`) | + +### State Machine + +Uses `AnimatedViewState` (`GONE`, `VISIBLE`, `HIDING`) with a `MutableStateFlow` to coordinate animation between the parent screen and the picker overlay. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `ChatListView.kt` | Main chat list view, toolbar, search, tags, filtering | +| `ChatListNavLinkView.kt` | Per-chat row routing and context menus | +| `ChatPreviewView.kt` | Chat preview row layout (image, title, last message) | +| `ChatHelpView.kt` | Empty-state help content | +| `ContactConnectionView.kt` | Pending connection preview row | +| `ContactRequestView.kt` | Contact request preview row | +| `ServersSummaryView.kt` | Server connection status summary | +| `ShareListNavLinkView.kt` | Share target list row (forwarding) | +| `ShareListView.kt` | Share target list (forwarding flow) | +| `TagListView.kt` | Tag management and assignment view | +| `UserPicker.kt` | User switching side panel | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md new file mode 100644 index 0000000000..2819b1e751 --- /dev/null +++ b/apps/multiplatform/spec/client/chat-view.md @@ -0,0 +1,324 @@ +# Chat View Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatView Composable](#2-chatview-composable) +3. [Message List](#3-message-list) +4. [ChatItemView](#4-chatitemview) +5. [Message Types](#5-message-types) +6. [Context Menu Actions](#6-context-menu-actions) +7. [ChatInfoView](#7-chatinfoview) +8. [GroupChatInfoView](#8-groupchatinfoview) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the `ChatView` composable (line ~96 in `ChatView.kt`), which orchestrates a `ChatLayout` containing a reverse-scrolling `LazyColumn` of `ChatItemView` items and a `ComposeView` for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the `views/chat/item/` package. + +--- + +## 1. Overview + +``` +ChatView +|-- ChatLayout +| |-- ChatInfoToolbar (top/bottom app bar with back, title, call, search, menu) +| |-- SupportChatsCountToolbar (reports/support banner, group only) +| |-- ChatItemsList (LazyColumnWithScrollBar, reverse layout) +| | |-- ChatViewListItem +| | | |-- DateSeparator +| | | |-- MemberNameAndRole (group received messages) +| | | |-- MemberImage (group received messages) +| | | +-- ChatItemView (message type routing) +| | |-- ChatBannerView (first item: chat profile banner) +| | +-- FloatingButtons (scroll-to-bottom, unread counter) +| |-- ComposeView (message composition area) +| | |-- ContextItemView (reply/edit/forward/report indicator) +| | |-- previewView (link/media/voice/file preview) +| | +-- SendMsgView (text input + send/voice/timed buttons) +| |-- GroupMentions (mention autocomplete popup) +| |-- CommandsMenuView (bot commands popup) +| +-- ChooseAttachmentView (bottom sheet for attachment type) +|-- ChatInfoView (contact info, end modal) ++-- GroupChatInfoView (group management, end modal) +``` + +--- + + + +## 2. ChatView Composable + +**Location:** [`ChatView.kt#L97`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L97) + +```kotlin +fun ChatView( + chatsCtx: ChatModel.ChatsContext, + staleChatId: State, + scrollToItemId: MutableState, + onComposed: suspend (chatId: String) -> Unit +) +``` + +### State Management + +| State Variable | Type | Purpose | +|---|---|---| +| `showSearch` | `MutableState` | Controls search bar visibility | +| `searchText` | `MutableState` | Current search query text | +| `composeState` | `MutableState` | Full compose area state (message, preview, context, mentions) | +| `attachmentOption` | `MutableState` | Selected attachment type from bottom sheet | +| `selectedChatItems` | `MutableState?>` | Multi-select mode item IDs; `null` = selection off | +| `showCommandsMenu` | `MutableState` | Bot commands menu visibility | +| `contentFilter` | `MutableState` | Active content type filter (images, videos, etc.) | +| `availableContent` | `MutableState>` | Content types available in this chat | +| `activeChat` | `State` | Derived from `chatModel.chats` matching `staleChatId` | +| `unreadCount` | `State` | Unread message count derived from chat stats | + +### Chat Loading + +On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): + +1. Marks unread chat as read (`markUnreadChatAsRead`) +2. Clears group members state +3. Resets search, content filter, and selection +4. Fetches available content types (`updateAvailableContent`) +5. For direct chats, loads contact info and connection stats +6. For groups with pending membership, opens member support chat + +### Chat Type Routing + +The outer `when (chatInfo)` (line ~229) branches: + +| ChatInfo Type | Behavior | +|---|---| +| `ChatInfo.Direct`, `ChatInfo.Group`, `ChatInfo.Local` | Full `ChatLayout` with compose, search, reactions, per-chat theme | +| `ChatInfo.ContactConnection` | `ModalView` wrapping `ContactConnectionInfoView` | +| `ChatInfo.InvalidJSON` | `ModalView` with raw JSON display and share button | + +--- + +## 3. Message List + +**Location:** [`ChatView.kt#L1592`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1592) (`ChatItemsList` composable) + +The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, meaning index 0 is the newest message at the bottom of the screen. + +### Key Behaviors + +- **Merged Items:** Messages are grouped via `MergedItems.create()` (line ~1653), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`. +- **Pagination:** `PreloadItems` triggers `loadMessages` with `ChatPagination.Before` (older) or `ChatPagination.Last` (newer) when the user scrolls near list boundaries. +- **Scroll To Item:** `scrollToItem` lambda supports animated scrolling to a specific item ID, used by search result taps and quoted message navigation. +- **Unread Marking:** `MarkItemsReadAfterDelay` composable marks newly visible received items as read after a brief delay. +- **Date Separators:** `DateSeparator` composable renders between messages when the date changes (via `ItemSeparation.date`). +- **Swipe to Reply:** `SwipeToDismiss` modifier on each item (EndToStart direction, 30dp threshold) sets `ComposeContextItem.QuotedItem`. +- **Selection Mode:** When `selectedChatItems` is non-null, a checkbox overlay appears on each item; a full-width clickable overlay toggles selection. + +### Item Layout (ChatViewListItem) + +- **Group received messages** with `showAvatar = true`: Column layout with `MemberNameAndRole` header, `MemberImage` (clickable to `showMemberInfo`), and message bubble. +- **Group received without avatar:** Indented to align with avatar-bearing messages. +- **Sent messages (group or direct):** Right-aligned with larger start padding. +- **Direct messages:** Symmetric padding (76dp opposite side). + +--- + + + +## 4. ChatItemView + +**Location:** [`item/ChatItemView.kt#L66`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L66) + +```kotlin +fun ChatItemView( + chatsCtx, rhId, chat, cItem, composeState, imageProvider, + useLinkPreviews, linkMode, revealed, highlighted, hoveredItemId, + range, selectedChatItems, searchIsNotBlank, fillMaxWidth, + selectChatItem, deleteMessage, deleteMessages, archiveReports, + receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, + openDirectChat, forwardItem, scrollToItem, scrollToItemId, + scrollToQuotedItemFromItem, setReaction, showItemDetails, + reveal, showMemberInfo, showChatInfo, developerTools, showViaProxy, + showTimestamp, itemSeparation, ... +) +``` + +The composable routes based on `cItem.content` and `cItem.meta.itemDeleted`: + +- **Deleted items** -> `DeletedItemView` or `MarkedDeletedItemView` +- **Message content** (`SndMsgContent`, `RcvMsgContent`) -> `FramedItemView` or specialized views depending on `msgContent` type +- **Call items** -> `CICallItemView` +- **Integrity/decryption errors** -> `IntegrityErrorItemView`, `CIRcvDecryptionError` +- **Group invitations** -> `CIGroupInvitationView` +- **Events** (group/direct/connection events) -> `CIEventView` +- **Feature changes** -> `CIChatFeatureView`, `CIFeaturePreferenceView` +- **E2EE info** -> `CIEventView` +- **Chat banner** -> handled at list level, not in `ChatItemView` +- **Invalid JSON** -> `CIInvalidJSONView` + +### Reactions + +`ChatItemReactions` row renders below each message bubble, showing emoji reaction counts. Tapping own reactions removes them; tapping others' opens a member list dropdown. + +### Context Menu + +Long-press or right-click opens a dropdown menu with context-sensitive actions (see section 6). + +--- + +## 5. Message Types + +| CIContent Variant | MsgContent Type | View Composable | Source File | +|---|---|---|---| +| `SndMsgContent` / `RcvMsgContent` | `MCText` | `FramedItemView` -> `TextItemView` or `EmojiItemView` | `TextItemView.kt`, `EmojiItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCLink` | `FramedItemView` (with link preview) | `FramedItemView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCImage` | `CIImageView` (inside `FramedItemView`) | `CIImageView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVideo` | `CIVideoView` (inside `FramedItemView`) | `CIVideoView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCVoice` | `CIVoiceView` | `CIVoiceView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCFile` | `CIFileView` | `CIFileView.kt` | +| `SndMsgContent` / `RcvMsgContent` | `MCReport` | `FramedItemView` (with report styling) | `FramedItemView.kt` | +| `SndCall` / `RcvCall` | -- | `CICallItemView` | `CICallItemView.kt` | +| `RcvIntegrityError` | -- | `IntegrityErrorItemView` | `IntegrityErrorItemView.kt` | +| `RcvDecryptionError` | -- | `CIRcvDecryptionError` | `CIRcvDecryptionError.kt` | +| `RcvGroupInvitation` / `SndGroupInvitation` | -- | `CIGroupInvitationView` | `CIGroupInvitationView.kt` | +| `RcvDirectEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvGroupEventContent` / `SndGroupEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvConnEventContent` / `SndConnEventContent` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeature` / `SndChatFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `RcvChatPreference` / `SndChatPreference` | -- | `CIFeaturePreferenceView` | `CIFeaturePreferenceView.kt` | +| `RcvGroupFeature` / `SndGroupFeature` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `SndModerated` / `RcvModerated` / `RcvBlocked` | -- | `MarkedDeletedItemView` | `MarkedDeletedItemView.kt` | +| `SndDirectE2EEInfo` / `RcvDirectE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `SndGroupE2EEInfo` / `RcvGroupE2EEInfo` | -- | `CIEventView` | `CIEventView.kt` | +| `RcvChatFeatureRejected` / `RcvGroupFeatureRejected` | -- | `CIChatFeatureView` | `CIChatFeatureView.kt` | +| `ChatBanner` | -- | `ChatBannerView` (inline in `ChatItemsList`) | `ChatView.kt` | +| `InvalidJSON` | -- | `CIInvalidJSONView` | `CIInvalidJSONView.kt` | +| `CIMemberCreatedContact` | -- | `CIMemberCreatedContactView` | `CIMemberCreatedContactView.kt` | + +--- + +## 6. Context Menu Actions + +Context menu actions are built dynamically in `ChatItemView` based on message type, direction, chat type, and feature flags. + +| Action | Condition | Effect | +|---|---|---| +| **Reply** | Message content (not event/deleted), not local notes | Sets `ComposeContextItem.QuotedItem` | +| **Edit** | Sent message, editable (`meta.editable`), text/link content | Sets `ComposeContextItem.EditingItem` | +| **Delete for me** | Any deletable item | `apiDeleteChatItems` with `cidmInternal` mode | +| **Delete for everyone** | Sent + within time window, or moderator privilege | `apiDeleteChatItems` with `cidmBroadcast` mode | +| **Moderate** | Group moderator + received message | `apiDeleteMemberChatItems` | +| **Forward** | Message content, not live message | Opens share sheet via `SharedContent.Forward` | +| **Select** | Any selectable item | Enters multi-select mode (`selectedChatItems`) | +| **React** | Message content, reactions enabled | Opens emoji picker; calls `apiChatItemReaction` | +| **Report** | Received group message, reports enabled | Sets `ComposeContextItem.ReportedItem` with reason | +| **Info** | Any message | Opens `ChatItemInfoView` in end modal | +| **Copy** | Text content present | Copies text to clipboard | +| **Save** | Image/video/file with completed download | Saves media to device | +| **Open** | File with completed download | Opens file with system handler | +| **Reveal / Hide** | Part of a merged group; expanded or collapsed | Toggles `revealedItems` state | + +--- + +## 7. ChatInfoView + +**Location:** [`ChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt) + +Opened via the `info` callback when the user taps the toolbar title in a direct chat. Displayed in `ModalManager.end`. + +Preloads `apiContactInfo` (connection stats, server profile) and `apiGetContactCode` (verification code) before showing the modal. + +Key sections: contact profile, local alias, connection stats, shared media, disappearing messages preference, voice/call/file feature toggles, encryption verification, and contact deletion. + +--- + +## 8. GroupChatInfoView + +**Location:** [`group/GroupChatInfoView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt) + +Opened via the `info` callback for group chats. Displayed in `ModalManager.end`. + +Preloads group members (`setGroupMembers`) and group link (`apiGetGroupLink`). + +Key sections: group profile, group link, member list with roles, group preferences (disappearing messages, direct messages, full deletion, voice, files, SimpleX links, history), member admission, welcome message, reports view, and group deletion/leave. + +--- + +## 9. Source Files + +### `views/chat/` + +| File | Description | +|---|---| +| `ChatView.kt` | Main chat view, ChatLayout, ChatItemsList, ChatInfoToolbar | +| `ChatInfoView.kt` | Contact info modal | +| `ChatItemInfoView.kt` | Individual message delivery/read info | +| `ChatItemsLoader.kt` | Pagination and message loading logic | +| `ChatItemsMerger.kt` | MergedItems grouping of consecutive events | +| `CommandsMenuView.kt` | Bot `/command` menu popup | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `ComposeFileView.kt` | File attachment preview in compose | +| `ComposeImageView.kt` | Image/video attachment preview in compose | +| `ComposeView.kt` | Main compose area (ComposeState, send logic) | +| `ComposeVoiceView.kt` | Voice recording preview in compose | +| `ContactPreferences.kt` | Per-contact feature preferences | +| `ContextItemView.kt` | Reply/edit/forward context indicator | +| `ScanCodeView.kt` | QR code scanner | +| `SelectableChatItemToolbars.kt` | Multi-select toolbar (delete, forward, moderate) | +| `SendMsgView.kt` | Text input field, send button, voice record button | +| `VerifyCodeView.kt` | Contact/member encryption verification | + +### `views/chat/item/` + +| File | Description | +|---|---| +| `ChatItemView.kt` | Message type routing, context menu, reactions | +| `CIBrokenComposableView.kt` | Fallback for rendering errors | +| `CICallItemView.kt` | Call event display (incoming/outgoing/missed) | +| `CIChatFeatureView.kt` | Chat feature change event | +| `CIEventView.kt` | Generic event display (group/direct/connection) | +| `CIFeaturePreferenceView.kt` | Feature preference change event | +| `CIFileView.kt` | File message (download/upload progress) | +| `CIGroupInvitationView.kt` | Group invitation card | +| `CIImageView.kt` | Image message (thumbnail + fullscreen) | +| `CIInvalidJSONView.kt` | Invalid JSON fallback display | +| `CIMemberCreatedContactView.kt` | Member-created contact event | +| `CIMetaView.kt` | Message metadata (time, status indicators) | +| `CIRcvDecryptionError.kt` | Decryption error display | +| `CIVideoView.kt` | Video message (thumbnail + player) | +| `CIVoiceView.kt` | Voice message (waveform + player) | +| `DeletedItemView.kt` | Deleted message placeholder | +| `EmojiItemView.kt` | Large emoji-only message | +| `FramedItemView.kt` | Message bubble frame (quoted item, text, media) | +| `ImageFullScreenView.kt` | Fullscreen image gallery | +| `IntegrityErrorItemView.kt` | Message integrity error | +| `MarkedDeletedItemView.kt` | Marked-as-deleted / moderated message | +| `TextItemView.kt` | Plain text message with markdown | + +### `views/chat/group/` + +| File | Description | +|---|---| +| `AddGroupMembersView.kt` | Add members to group | +| `GroupChatInfoView.kt` | Group info and management | +| `GroupLinkView.kt` | Group link display and management | +| `GroupMemberInfoView.kt` | Individual member info | +| `GroupMembersToolbar.kt` | Members toolbar in group info | +| `GroupMentions.kt` | @mention autocomplete | +| `GroupPreferences.kt` | Group feature preferences | +| `GroupProfileView.kt` | Group profile editor | +| `GroupReportsView.kt` | Group reports list view | +| `MemberAdmission.kt` | Member admission settings | +| `MemberSupportChatView.kt` | Member support chat (scoped context) | +| `MemberSupportView.kt` | Support chat list for moderators | +| `WelcomeMessageView.kt` | Group welcome message editor | diff --git a/apps/multiplatform/spec/client/compose.md b/apps/multiplatform/spec/client/compose.md new file mode 100644 index 0000000000..241dcf667b --- /dev/null +++ b/apps/multiplatform/spec/client/compose.md @@ -0,0 +1,399 @@ +# Message Composition Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`, `SendMsgView.kt` + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [ComposeState Data Class](#2-composestate-data-class) +3. [ComposePreview Sealed Class](#3-composepreview-sealed-class) +4. [ComposeContextItem Sealed Class](#4-composecontextitem-sealed-class) +5. [SendMsgView](#5-sendmsgview) +6. [Attachment Handling](#6-attachment-handling) +7. [Draft Persistence](#7-draft-persistence) +8. [Source Files](#8-source-files) + +--- + +## Executive Summary + +Message composition in SimpleX Chat is managed by `ComposeView` (line ~345 in `ComposeView.kt`) backed by the serializable `ComposeState` data class. The compose area supports text input, link previews, media/file/voice attachments, reply/edit/forward contexts, live (streaming) messages, member @mentions, message reports, and timed (disappearing) messages. The `SendMsgView` composable (in `SendMsgView.kt`) provides the text field and action buttons. Draft state persists across chat switches when the privacy preference is enabled. + +--- + + + +## 1. Overview + +``` +ComposeView +|-- contextItemView() +| |-- ContextItemView (QuotedItem) [reply indicator] +| |-- ContextItemView (EditingItem) [edit indicator] +| |-- ContextItemView (ForwardingItems) [forward indicator] +| +-- ContextItemView (ReportedItem) [report indicator] +|-- ReportReasonView [report reason header] +|-- MsgNotAllowedView [disabled send reason] +|-- previewView() +| |-- ComposeLinkView [link preview card] +| |-- ComposeImageView [media thumbnails] +| |-- ComposeVoiceView [voice recording waveform] +| +-- ComposeFileView [file name display] +|-- AttachmentAndCommandsButtons +| |-- CommandsButton [bot commands "//"] +| +-- AttachmentButton [paperclip icon] ++-- SendMsgView + |-- PlatformTextField [multiline text input] + |-- DeleteTextButton [clear text, shown on long text] + |-- SendMsgButton [arrow/check icon] + |-- RecordVoiceView [microphone + hold-to-record] + |-- StartLiveMessageButton [bolt icon] + |-- CancelLiveMessageButton [cancel live] + +-- TimedMessageDropdown [disappearing message timer] +``` + +--- + + + +## 2. ComposeState Data Class + +**Location:** [`ComposeView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L98) + +```kotlin +@Serializable +data class ComposeState( + val message: ComposeMessage = ComposeMessage(), + val parsedMessage: List = emptyList(), + val liveMessage: LiveMessage? = null, + val preview: ComposePreview = ComposePreview.NoPreview, + val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, + val inProgress: Boolean = false, + val progressByTimeout: Boolean = false, + val useLinkPreviews: Boolean, + val mentions: MentionedMembers = emptyMap() +) +``` + +### Fields + +| Field | Type | Description | +|---|---|---| +| `message` | `ComposeMessage` | Current text and cursor selection (`TextRange`) | +| `parsedMessage` | `List` | Markdown-parsed representation of message text | +| `liveMessage` | `LiveMessage?` | Active live (streaming) message state | +| `preview` | `ComposePreview` | Attachment preview (link, media, voice, file) | +| `contextItem` | `ComposeContextItem` | Reply/edit/forward/report context | +| `inProgress` | `Boolean` | Send operation in flight | +| `progressByTimeout` | `Boolean` | Show spinner after 1-second send delay | +| `useLinkPreviews` | `Boolean` | Link preview feature flag | +| `mentions` | `MentionedMembers` | Map of mention display name to `CIMention` | + +### Computed Properties + +| Property | Type | Description | +|---|---|---| +| `editing` | `Boolean` | True when `contextItem` is `EditingItem` | +| `forwarding` | `Boolean` | True when `contextItem` is `ForwardingItems` | +| `reporting` | `Boolean` | True when `contextItem` is `ReportedItem` | +| `sendEnabled` | `() -> Boolean` | True when there is content to send and not in progress | +| `linkPreviewAllowed` | `Boolean` | True when no media/voice/file preview is active | +| `linkPreview` | `LinkPreview?` | Extracts link preview from `CLinkPreview` | +| `attachmentDisabled` | `Boolean` | True when editing, forwarding, live, in-progress, or reporting | +| `attachmentPreview` | `Boolean` | True when a file or media preview is showing | +| `empty` | `Boolean` | True when no text, no preview, and no context item | +| `whitespaceOnly` | `Boolean` | True when message text contains only whitespace | +| `placeholder` | `String` | Input placeholder text (report reason text or default) | +| `memberMentions` | `Map` | Extracted member ID map for API calls | + +### ComposeMessage + +```kotlin +@Serializable +data class ComposeMessage( + val text: String = "", + val selection: TextRange = TextRange.Zero +) +``` + +### LiveMessage + +```kotlin +@Serializable +data class LiveMessage( + val chatItem: ChatItem, + val typedMsg: String, + val sentMsg: String, + val sent: Boolean +) +``` + +Tracks a live (streaming) message: the associated `ChatItem`, the currently typed text, the last sent text, and whether the initial send has occurred. + +### Serialization + +`ComposeState` is fully `@Serializable` with a custom `Saver` (line ~214) that uses `json.encodeToString`/`decodeFromString` for `rememberSaveable` persistence across configuration changes. + +--- + + + +## 3. ComposePreview Sealed Class + +**Location:** [`ComposeView.kt#L52`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L52) + +```kotlin +sealed class ComposePreview { + object NoPreview : ComposePreview() + class CLinkPreview(val linkPreview: LinkPreview?) : ComposePreview() + class MediaPreview(val images: List, val content: List) : ComposePreview() + data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean) : ComposePreview() + class FilePreview(val fileName: String, val uri: URI) : ComposePreview() +} +``` + +| Variant | Fields | View | +|---|---|---| +| `NoPreview` | -- | Nothing shown | +| `CLinkPreview` | `linkPreview: LinkPreview?` (null = loading) | `ComposeLinkView`: title, description, image thumbnail, cancel button | +| `MediaPreview` | `images: List` (base64 thumbnails), `content: List` | `ComposeImageView`: horizontal thumbnail strip, cancel button | +| `VoicePreview` | `voice: String` (file path), `durationMs: Int`, `finished: Boolean` | `ComposeVoiceView`: waveform visualization, duration, play/pause | +| `FilePreview` | `fileName: String`, `uri: URI` | `ComposeFileView`: file icon, file name, cancel button | + +### UploadContent + +Used within `MediaPreview` to track the source type: + +- `SimpleImage(uri: URI)` -- still image +- `AnimatedImage(uri: URI)` -- GIF or animated WebP +- `Video(uri: URI, duration: Int)` -- video with duration in seconds + +--- + +## 4. ComposeContextItem Sealed Class + +**Location:** [`ComposeView.kt#L61`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L61) + +```kotlin +sealed class ComposeContextItem { + object NoContextItem : ComposeContextItem() + class QuotedItem(val chatItem: ChatItem) : ComposeContextItem() + class EditingItem(val chatItem: ChatItem) : ComposeContextItem() + class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo) : ComposeContextItem() + class ReportedItem(val chatItem: ChatItem, val reason: ReportReason) : ComposeContextItem() +} +``` + +| Variant | Trigger | Compose Behavior | +|---|---|---| +| `NoContextItem` | Default state | Normal message composition | +| `QuotedItem` | Swipe-to-reply or reply menu action | Shows quoted message indicator; sends with `quoted` parameter | +| `EditingItem` | Edit menu action | Populates text field with existing message; send button becomes checkmark; calls `apiUpdateChatItem` | +| `ForwardingItems` | Forward action from another chat | Shows forwarded items indicator; calls `apiForwardChatItems`; can include optional text message | +| `ReportedItem` | Report menu action | Shows report indicator with reason; placeholder changes to reason text; calls `apiReportMessage` | + +### Context Item View + +`contextItemView()` (line ~1098 in `ComposeView.kt`) renders the active context as a dismissible bar above the text input: + +- Icon: reply (ic_reply), edit (ic_edit_filled), forward (ic_forward), report (ic_flag) +- Content: quoted message preview text with sender name +- Close button: resets `contextItem` to `NoContextItem` (or `clearState()` for editing) + +--- + + + +## 5. SendMsgView + +**Location:** [`SendMsgView.kt#L36`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt#L36) + +```kotlin +fun SendMsgView( + composeState: MutableState, + showVoiceRecordIcon: Boolean, + recState: MutableState, + isDirectChat: Boolean, + liveMessageAlertShown: SharedPreference, + sendMsgEnabled: Boolean, + userCantSendReason: Pair?, + sendButtonEnabled: Boolean, + sendToConnect: (() -> Unit)?, + hideSendButton: Boolean, + nextConnect: Boolean, + needToAllowVoiceToContact: Boolean, + allowedVoiceByPrefs: Boolean, + sendButtonColor: Color, + allowVoiceToContact: () -> Unit, + timedMessageAllowed: Boolean, + customDisappearingMessageTimePref: SharedPreference?, + placeholder: String, + sendMessage: (Int?) -> Unit, + sendLiveMessage: (suspend () -> Unit)?, + updateLiveMessage: (suspend () -> Unit)?, + cancelLiveMessage: (() -> Unit)?, + editPrevMessage: () -> Unit, + onFilesPasted: (List) -> Unit, + onMessageChange: (ComposeMessage) -> Unit, + textStyle: MutableState, + focusRequester: FocusRequester? +) +``` + +### Layout + +The view is a `Box` containing: + +1. **PlatformTextField:** Multiline text input (platform-specific `expect`). Handles text changes via `onMessageChange`, up-arrow to `editPrevMessage`, file paste via `onFilesPasted`, and Enter to send. +2. **DeleteTextButton:** Shown when text is long; clears the field. +3. **Action area** (bottom-right, stacked): + - **Progress indicator:** Shown when `progressByTimeout` is true. + - **Report confirm button:** Checkmark icon when context is `ReportedItem`. + - **Voice record button:** Shown when message is empty, not editing/forwarding, no preview active. + - `RecordVoiceView`: Hold-to-record with waveform display. + - `DisallowedVoiceButton`: Shown when voice is disabled by preferences. + - `VoiceButtonWithoutPermissionByPlatform`: Shown when microphone permission is not granted. + - **Live message button:** Bolt icon, starts streaming message (calls `sendLiveMessage`). + - **Send button:** Arrow icon (new message) or checkmark (editing/live). Long-press opens dropdown: + - "Send live message" option + - Timed message options (1min, 5min, 1hr, 8hr, 1day, 1week, 1month, custom) + +### RecordingState + +```kotlin +sealed class RecordingState { + object NotStarted : RecordingState() + class Started(val filePath: String, val progressMs: Int) : RecordingState() + class Finished(val filePath: String, val durationMs: Int) : RecordingState() +} +``` + +Voice recording of 300ms or less is auto-cancelled. + +### Disabled State + +When `sendMsgEnabled` is false (e.g., contact not ready, group permissions), an overlay covers the text field. If `userCantSendReason` is provided, tapping the overlay shows an alert explaining why sending is disabled. + +--- + +## 6. Attachment Handling + + + +### Attachment Selection + +The `AttachmentSelection` composable (line ~263 in `ComposeView.kt`) is an `expect` function with platform-specific implementations: + +**Android:** +- Camera launcher (image capture) +- Gallery launcher (image/video picker, multi-select) +- File picker (any file type) + +**Desktop:** +- File chooser dialog (filters for images or all files) + +### ChooseAttachmentView + +Bottom sheet (`ModalBottomSheetLayout`) presenting attachment type options: + +| Option | Result | +|---|---| +| Camera (Android) | Launches camera intent; result processed as `SimpleImage` | +| Gallery | Launches media picker; results processed via `processPickedMedia` | +| File | Launches file picker; result processed via `processPickedFile` | + +### File Processing + +**`processPickedFile`** (line ~281): +1. Checks file size against `maxFileSize` (XFTP limit). +2. Extracts file name from URI. +3. Sets `ComposePreview.FilePreview` on compose state. + +**`processPickedMedia`** (line ~300): +1. For each URI, determines type (image, animated image, video). +2. Images: Gets bitmap, creates `SimpleImage` or `AnimatedImage` upload content. +3. Videos: Extracts thumbnail and duration, creates `Video` upload content. +4. Generates base64 preview thumbnails (max 14KB). +5. Sets `ComposePreview.MediaPreview` with thumbnails and content list. + +**`onFilesAttached`** (line ~270): +Groups dropped/pasted files into images and non-images; routes to `processPickedMedia` or `processPickedFile`. + +### Send Flow + +On send (line ~603, `sendMessageAsync`): + +1. **Forwarding:** Calls `apiForwardChatItems`, then optionally sends a text message quoting the last forwarded item. +2. **Editing:** Calls `apiUpdateChatItem` with updated `MsgContent`. +3. **Reporting:** Calls `apiReportMessage` with reason and text. +4. **New message:** Iterates over `msgs` (one per media item or single for text/file/voice): + - Saves file to app storage (or remote host). + - For voice: encrypts if `privacyEncryptLocalFiles` is enabled. + - Calls `apiSendMessages` or `apiCreateChatItems` (local notes). +5. On failure of the last message, restores compose state for retry. + +### Link Preview + +When `privacyLinkPreviews` is enabled and the message contains a URL: + +1. `showLinkPreview` extracts first non-SimpleX, non-cancelled link from parsed markdown. +2. Sets `ComposePreview.CLinkPreview(null)` (loading state). +3. After 1.5s debounce, calls `getLinkPreview(url)`. +4. On success, updates to `CLinkPreview(linkPreview)`. +5. Cancel button adds the URL to `cancelledLinks` set. + +--- + +## 7. Draft Persistence + +**Location:** [`ComposeView.kt#L1230`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1230) (`KeyChangeEffect(chatModel.chatId.value)`) + +Controlled by the `privacySaveLastDraft` preference. + +### Save Behavior + +When the user navigates away from a chat (`chatModel.chatId.value` changes): + +| Compose State | Action | +|---|---| +| Live message active (text present or already sent) | Sends the live message immediately, clears draft | +| In progress | Clears in-progress flag, clears previous draft | +| Non-empty (text, preview, or context) | If `saveLastDraft` is true: saves `composeState.value` to `chatModel.draft.value` and `chatModel.draftChatId.value` | +| Empty but draft exists for current chat | Restores draft from `chatModel.draft` | +| Empty, no draft | Clears previous draft, deletes unused files | + +### Restore Behavior + +When entering a chat (line ~132 in `ChatView.kt`): + +1. Checks if `chatModel.draftChatId.value` matches the chat ID. +2. If match and draft is not null (and not a cross-chat forward), initializes `composeState` from the draft. +3. Otherwise, creates a fresh `ComposeState`. + +### Desktop-specific + +On desktop, a `DisposableEffect` (line ~1256) saves the draft on dispose when forwarding content, since the `KeyChangeEffect` mechanism is Android-specific. + +### Draft Display in Chat List + +When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the draft text instead of the last message preview. + +--- + +## 8. Source Files + +| File | Description | +|---|---| +| `ComposeView.kt` | ComposeState, ComposePreview, ComposeContextItem, ComposeView composable, send logic, link preview, draft persistence | +| `SendMsgView.kt` | Text input field, send/voice/live/timed buttons, recording state | +| `ComposeFileView.kt` | File attachment preview (name, cancel) | +| `ComposeImageView.kt` | Media attachment preview (thumbnails, cancel) | +| `ComposeVoiceView.kt` | Voice recording preview (waveform, duration, play) | +| `ContextItemView.kt` | Reply/edit/forward/report context bar | +| `ComposeContextContactRequestActionsView.kt` | Contact request action buttons in compose area | +| `ComposeContextGroupDirectInvitationActionsView.kt` | Group direct invitation compose actions | +| `ComposeContextPendingMemberActionsView.kt` | Pending member compose actions | +| `ComposeContextProfilePickerView.kt` | Profile picker in compose context | +| `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | diff --git a/apps/multiplatform/spec/client/navigation.md b/apps/multiplatform/spec/client/navigation.md new file mode 100644 index 0000000000..c9939ea3c0 --- /dev/null +++ b/apps/multiplatform/spec/client/navigation.md @@ -0,0 +1,379 @@ +# Navigation Specification + +Source: `common/src/commonMain/kotlin/chat/simplex/common/App.kt` (470 lines) + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [AppScreen Composable](#2-appscreen-composable) +3. [MainScreen](#3-mainscreen) +4. [Android Layout](#4-android-layout) +5. [Desktop Layout](#5-desktop-layout) +6. [ModalManager](#6-modalmanager) +7. [Authentication Gate](#7-authentication-gate) +8. [Onboarding Flow](#8-onboarding-flow) +9. [Source Files](#9-source-files) + +--- + +## Executive Summary + +SimpleX Chat navigation is a platform-adaptive system implemented in `App.kt`. The root `AppScreen` composable applies theming and safe-area insets, delegating to `MainScreen` which acts as a state machine routing between onboarding, authentication, database error, and the main chat interface. Android uses a 2-column sliding layout (`AndroidScreen`), while desktop uses a fixed 3-column layout (`DesktopScreen`). Modal presentation is managed by `ModalManager`, which provides named zones (start, center, end, fullscreen) for layered content. Authentication is gated by `AppLock`, and onboarding follows a linear `OnboardingStage` enum. + +--- + +## 1. Overview + +``` +AppScreen (line 46) ++-- SimpleXTheme + +-- Surface + +-- MainScreen (line 82) + |-- [Migration in progress] -> DefaultProgressView + |-- [Database opening] -> DefaultProgressView + |-- [Database error] -> DatabaseErrorView + |-- [Encryption check pending] -> SplashView + |-- [Onboarding incomplete] -> AnimatedContent { OnboardingStage views } + |-- [Onboarding complete] + | |-- [Android] + | | +-- AndroidWrapInCallLayout + | | +-- AndroidScreen (line 293) + | | |-- StartPartOfScreen (ChatListView) + | | +-- ChatView (slide-in panel) + | +-- [Desktop] + | +-- DesktopScreen (line 406) + | |-- StartPartOfScreen + UserPicker (left column) + | |-- ModalManager.start (overlay on left) + | |-- CenterPartOfScreen / ChatView (center column) + | +-- ModalManager.end (right column) + |-- [Unauthorized] -> AuthView / SplashView / PasscodeView + |-- [Active call] -> ActiveCallView (desktop) / startCallActivity (Android) + +-- [Incoming call] -> IncomingCallAlertView +``` + +--- + + + +## 2. AppScreen Composable + +**Location:** [`App.kt#L47`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L47) + +```kotlin +@Composable +fun AppScreen() +``` + +### Responsibilities + +1. **Theme application:** Wraps content in `SimpleXTheme` with `Surface` using `MaterialTheme.colors.background`. +2. **Window insets:** Computes safe padding for landscape mode, accounting for display cutouts on both sides. Uses `WindowInsets.safeDrawing` and `WindowInsets.displayCutout` to calculate symmetric padding. +3. **Fullscreen gallery overlay:** When `chatModel.fullscreenGalleryVisible` is true, draws a black rectangle behind content extending into the cutout areas to provide an immersive gallery background. +4. **Delegates to `MainScreen()`.** + +--- + + + +## 3. MainScreen + +**Location:** [`App.kt#L84`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L84) + +```kotlin +@Composable +fun MainScreen() +``` + +### State Machine + +`MainScreen` evaluates a series of conditions in priority order: + +| Priority | Condition | View | +|---|---|---| +| 1 | `onboarding == Step1_SimpleXInfo && migrationState != null` | `SimpleXInfo` (migration in progress) | +| 2 | `dbMigrationInProgress` | `DefaultProgressView("Database migration...")` | +| 3 | `chatDbStatus == null && showInitializationView` | `DefaultProgressView("Opening database...")` | +| 4 | `showChatDatabaseError` | `DatabaseErrorView` | +| 5 | `chatDbEncrypted == null \|\| localUserCreated == null` | `SplashView` | +| 6 | `onboarding == OnboardingComplete` | Platform-specific main screen | +| 7 | Other onboarding stages | `AnimatedContent` with stage-specific views | + +### Onboarding Complete Branch (line ~156) + +When onboarding is complete: + +1. Shows "advertise lock" alert if conditions met (not shown before, LA not enabled, >3 chats, no active call). +2. Sets up clipboard listener. +3. Routes to `AndroidScreen` or `DesktopScreen` based on platform. + +### Overlay Layers (bottom of MainScreen) + +| Layer | Condition | Content | +|---|---|---| +| `ModalManager.fullscreen` | Android + migration/onboarding | Fullscreen modals | +| `SwitchingUsersView` | User switch in progress | Loading overlay | +| Auth gate | `userAuthorized != true` | `AuthView` or `SplashView` + passcode | +| Active call | `showCallView == true` | `ActiveCallView` (desktop) or call activity (Android) | +| One-time passcode | Always | `ModalManager.fullscreen.showOneTimePasscodeInView` | +| Privacy alerts | Always | `AlertManager.privacySensitive` | +| Incoming call | `activeCallInvitation != null` | `IncomingCallAlertView` | +| Shared alerts | Always | `AlertManager.shared` | + +--- + + + +## 4. Android Layout + +**Location:** [`App.kt#L296`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L296) + +```kotlin +@Composable +fun AndroidScreen(userPickerState: MutableStateFlow) +``` + +### 2-Column Slide Animation + +Uses `BoxWithConstraints` to get `maxWidth`, then two `Box` containers: + +1. **Left panel (StartPartOfScreen):** Chat list, positioned at `translationX = -offset`. +2. **Right panel (ChatView):** Chat view, positioned at `translationX = maxWidth - offset`. + +The `offset` is an `Animatable`: +- `0f` when no chat is selected (chat list visible). +- `maxWidth.value` when a chat is open (chat view visible). + +### Animation Flow + +1. `snapshotFlow { chatModel.chatId.value }` detects chat ID changes. +2. When `chatId` becomes null, `onComposed(null)` animates offset to 0. +3. When `ChatView` finishes composing (calls `onComposed(chatId)`), offset animates to `maxWidth`. +4. Animation uses `chatListAnimationSpec()` (standard spring or tween). + +### Display Cutout Handling + +If the device has a display cutout on horizontal sides (detected via `WindowInsets.displayCutout`), the panels are clipped with `RectangleShape` to prevent the chat list from showing through during transition. + +### Call Layout Wrapper + +`AndroidWrapInCallLayout` (line ~279) adds a 40dp top padding when an active call is in progress (not in `WaitCapabilities` or `InvitationAccepted` state), with an `ActiveCallInteractiveArea` banner above. + +--- + + + +## 5. Desktop Layout + +**Location:** [`App.kt#L410`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L410) + +```kotlin +@Composable +fun DesktopScreen(userPickerState: MutableStateFlow) +``` + +### 3-Column Layout + +| Column | Width | Content | +|---|---|---| +| **Left** | `DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier` (fixed) | `StartPartOfScreen` (ChatListView) + `UserPicker` overlay | +| **Left overlay** | Same as left column | `ModalManager.start` modals + `SwitchingUsersView` | +| **Center** | `min = DEFAULT_MIN_CENTER_MODAL_WIDTH`, `weight = 1f` (flexible) | `CenterPartOfScreen` (ChatView or "no selected chat" placeholder, or `ModalManager.center`) | +| **Right** | `max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier` (flexible, 0 when empty) | `ModalManager.end` (ChatInfoView, GroupChatInfoView, ChatItemInfoView, etc.) | + +### Column Separators + +- `VerticalDivider` between left and center columns (always visible). +- `VerticalDivider` between center and right columns (visible when `ModalManager.end.hasModalsOpen()`). + +### Click-to-Dismiss Overlay + +When the UserPicker is visible or a start modal is open (but no center modal), a full-size clickable overlay covers the center+right area (line ~428). Clicking it closes start modals and hides the UserPicker. + +### CenterPartOfScreen + +**Location:** [`App.kt#L373`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L373) + +- When `chatId` is null and no center modals: shows "No selected chat" placeholder. +- When `chatId` is null and center modals open: shows `ModalManager.center`. +- When `chatId` is set: shows `ChatView`. +- Automatically closes center modals when a chat is selected. + +### StartPartOfScreen + +**Location:** [`App.kt#L352`](../../common/src/commonMain/kotlin/chat/simplex/common/App.kt#L352) + +Routes between: +- `SetDeliveryReceiptsView` (if `chatModel.setDeliveryReceipts` is true) +- `ChatListView` (normal operation) +- `ShareListView` (when `chatModel.sharedContent` is non-null, i.e., forwarding) + +--- + +## 6. ModalManager + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt` (line 92) + +```kotlin +class ModalManager(private val placement: ModalPlacement?) +``` + +### Zones + +| Zone | Android Behavior | Desktop Behavior | +|---|---|---| +| `start` | Shared (same as all others) | Left column overlay, slides from start | +| `center` | Shared | Center column overlay, replaces ChatView | +| `end` | Shared | Right column, slides from end | +| `fullscreen` | Shared | Fullscreen overlay | + +On Android, all four zones point to the same `shared` instance, meaning modals stack in a single overlay. On desktop, each zone is independent with its own `ModalPlacement`. + +```kotlin +companion object { + val start = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.START) + val center = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.CENTER) + val end = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.END) + val fullscreen = if (appPlatform.isAndroid) shared else ModalManager(ModalPlacement.FULLSCREEN) +} +``` + +### Modal Stack + +Each `ModalManager` maintains a stack of `ModalViewHolder` objects with: +- `id: ModalViewId?` -- optional identifier for deduplication +- `animated: Boolean` -- whether to use enter/exit transitions +- `data: ModalData` -- scoped data for the modal +- `modal: @Composable ModalData.(close: () -> Unit) -> Unit` -- the modal content + +### Key Methods + +| Method | Description | +|---|---| +| `showModal` | Push a simple modal onto the stack | +| `showModalCloseable` | Push a modal with a close callback | +| `showCustomModal` | Push a modal with full control over `ModalView` wrapper | +| `closeModals` | Pop all modals from the stack | +| `closeModalsExceptFirst` | Pop all but the bottom modal | +| `hasModalsOpen()` | Check if any modals are on the stack | +| `showInView` | Render the current modal stack into the composable tree | + +### Usage Pattern + +| Action | Zone Used | +|---|---| +| Settings, New Chat, User Address | `ModalManager.start` | +| Onboarding conditions, What's New | `ModalManager.center` | +| ChatInfoView, GroupChatInfoView, ChatItemInfoView, GroupMemberInfoView | `ModalManager.end` | +| Passcode entry, Call view, Migration | `ModalManager.fullscreen` | + +--- + + + +## 7. Authentication Gate + +**Location:** [`AppLock.kt#L17`](../../common/src/commonMain/kotlin/chat/simplex/common/AppLock.kt#L17) + +```kotlin +object AppLock { + val userAuthorized = mutableStateOf(null) + val enteredBackground = mutableStateOf(null) + val laFailed = mutableStateOf(false) +} +``` + +### State + +| Field | Type | Description | +|---|---|---| +| `userAuthorized` | `MutableState` | `null` = not yet determined, `true` = authenticated, `false` = locked | +| `enteredBackground` | `MutableState` | Timestamp when app entered background (for lock delay) | +| `laFailed` | `MutableState` | True if last authentication attempt failed | + +### Authentication Flow + +1. **MainScreen** checks `unauthorized` (derived: `userAuthorized.value != true`) at line ~135. +2. If unauthorized and not in an active call: + - Launches `AppLock.runAuthenticate()` which triggers platform-specific biometric/passcode prompt. + - On Android with system auth finishing during activity destruction, authentication is skipped. +3. If `performLA` preference is set and `laFailed` is true: shows `AuthView` with "Unlock" button. +4. If `performLA` is set and `laFailed` is false: shows `SplashView` with passcode overlay. + +### Lock Delay + +The `laLockDelay` preference controls how long after backgrounding the app requires re-authentication. When `laLockDelay == 0`, screen rotation triggers a 3-second grace period (line ~270) to prevent unnecessary re-auth. + +### Lock Modes + +- `LAMode.SYSTEM`: Uses Android biometric/system lock screen. +- `LAMode.PASSCODE`: Uses in-app passcode (`SetAppPasscodeView`). + +### First-Time Lock Notice + +`showLANotice` (line ~33 in `AppLock.kt`) prompts users to enable SimpleX Lock when they have more than 3 chats, have not yet been shown the notice, and have not enabled lock. On Android, it offers a choice between system auth and passcode. + +--- + +## 8. Onboarding Flow + +**Location:** `common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/OnboardingView.kt` (line 3) + +```kotlin +enum class OnboardingStage { + Step1_SimpleXInfo, + Step2_CreateProfile, + LinkAMobile, + Step2_5_SetupDatabasePassphrase, + Step3_ChooseServerOperators, + Step3_CreateSimpleXAddress, + Step4_SetNotificationsMode, + OnboardingComplete +} +``` + +### Stage Progression + +| Stage | View | Next Stage | +|---|---|---| +| `Step1_SimpleXInfo` | `SimpleXInfo` -- app introduction, privacy features | `Step2_CreateProfile` or `LinkAMobile` (desktop) | +| `Step2_CreateProfile` | `CreateFirstProfile` -- display name, optional image | `Step2_5_SetupDatabasePassphrase` or `Step3_ChooseServerOperators` | +| `LinkAMobile` | `LinkAMobile` -- desktop linking to mobile device | `Step2_CreateProfile` | +| `Step2_5_SetupDatabasePassphrase` | `SetupDatabasePassphrase` -- optional DB encryption | `Step3_ChooseServerOperators` | +| `Step3_ChooseServerOperators` | `OnboardingConditionsView` -- server operator selection, T&C | `Step3_CreateSimpleXAddress` or `Step4_SetNotificationsMode` | +| `Step3_CreateSimpleXAddress` | `SetNotificationsMode` (legacy backcompat) | `Step4_SetNotificationsMode` | +| `Step4_SetNotificationsMode` | `SetNotificationsMode` -- notification permission setup | `OnboardingComplete` | +| `OnboardingComplete` | Main app screen | -- | + +### Animated Transitions + +Onboarding uses `AnimatedContent` with directional transitions: +- Forward: `fromEndToStartTransition` (slide left). +- Backward: `fromStartToEndTransition` (slide right). + +The stage value is stored in `appPrefs.onboardingStage` and persisted across app restarts. + +--- + +## 9. Source Files + +| File | Description | +|---|---| +| `App.kt` | AppScreen, MainScreen, AndroidScreen, DesktopScreen, StartPartOfScreen, CenterPartOfScreen, EndPartOfScreen | +| `AppLock.kt` | AppLock object, authentication state, lock notice, LA mode selection | +| `views/helpers/ModalView.kt` | ModalManager class, ModalPlacement enum, modal stack management | +| `views/onboarding/OnboardingView.kt` | OnboardingStage enum | +| `views/onboarding/SimpleXInfo.kt` | Step 1: App introduction | +| `views/WelcomeView.kt` | Step 2: Profile creation (CreateFirstProfile) | +| `views/onboarding/LinkAMobileView.kt` | Desktop: Link a mobile device | +| `views/onboarding/SetupDatabasePassphrase.kt` | Step 2.5: Database passphrase | +| `views/onboarding/ChooseServerOperators.kt` | Step 3: Server operators and conditions | +| `views/onboarding/SetNotificationsMode.kt` | Step 4: Notification setup | +| `views/chatlist/ChatListView.kt` | Chat list (StartPartOfScreen content) | +| `views/chatlist/UserPicker.kt` | User switching panel | +| `views/chat/ChatView.kt` | Chat view (CenterPartOfScreen content) | +| `views/database/DatabaseErrorView.kt` | Database error recovery | +| `views/SplashView.kt` | Splash / loading screen | +| `views/call/CallView.kt` | In-call fullscreen view (ActiveCallView) | +| `views/localauth/PasswordEntry.kt` | Column divider utility (contains VerticalDivider) | diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md new file mode 100644 index 0000000000..f6ecedb721 --- /dev/null +++ b/apps/multiplatform/spec/database.md @@ -0,0 +1,393 @@ +# Database & Storage + +## Table of Contents + +1. [Overview](#1-overview) +2. [Database Files & Paths](#2-database-files--paths) +3. [Haskell Store Modules](#3-haskell-store-modules) +4. [Migrations](#4-migrations) +5. [Database Encryption](#5-database-encryption) +6. [File Storage](#6-file-storage) +7. [Export & Import](#7-export--import) +8. [Source Files](#8-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses **two SQLite databases** managed entirely by the Haskell core. Kotlin code **never reads or writes the databases directly** -- all data access goes through the JNI command/response protocol defined in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt). + +The two databases are: + +| Database | Suffix | Contents | +|----------|--------|----------| +| Chat database | `_chat.db` | Users, contacts, groups, messages, files metadata, settings | +| Agent database | `_agent.db` | SMP/XFTP agent state: connections, queues, encryption keys, delivery tracking | + +Both databases are created and migrated by the `chatMigrateInit` JNI function. The Kotlin layer handles: +- Providing the correct file path prefix (`dbAbsolutePrefixPath`) +- Providing the encryption key +- Interpreting migration results (`DBMigrationResult`) +- Exposing API functions that proxy to Haskell store operations + +--- + +## 2. Database Files & Paths + +### Expect Declarations + +The common module declares platform-dependent paths as `expect` values in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +```kotlin +expect val dataDir: File // L18 +expect val tmpDir: File // L19 +expect val filesDir: File // L20 +expect val appFilesDir: File // L21 +expect val wallpapersDir: File // L22 +expect val coreTmpDir: File // L23 +expect val dbAbsolutePrefixPath: String // L24 +expect val preferencesDir: File // L25 +expect val preferencesTmpDir: File // L26 + +expect val chatDatabaseFileName: String // L28 +expect val agentDatabaseFileName: String // L29 + +expect val databaseExportDir: File // L35 +expect val remoteHostsDir: File // L37 +``` + +### Android Actual Values + +From [Files.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `androidAppContext.dataDir` | `/data/data//` | +| `tmpDir` | `getDir("temp", MODE_PRIVATE)` | Private temp directory | +| `filesDir` | `dataDir/files` | Parent for all file storage | +| `appFilesDir` | `filesDir/app_files` | User-visible chat file attachments | +| `wallpapersDir` | `filesDir/assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `filesDir/temp_files` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/files` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"files_chat.db"` | Full filename: `files_chat.db` | +| `agentDatabaseFileName` | `"files_agent.db"` | Full filename: `files_agent.db` | +| `databaseExportDir` | `androidAppContext.cacheDir` | Temp location for archive export | +| `remoteHostsDir` | `tmpDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `dataDir/shared_prefs` | Android SharedPreferences directory | + +### Desktop Actual Values + +From [Files.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt): + +| Variable | Value | Notes | +|----------|-------|-------| +| `dataDir` | `desktopPlatform.dataPath` | XDG_DATA_HOME (Linux), AppData (Windows), Application Support (macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` | System temp with `deleteOnExit` | +| `filesDir` | `dataDir/simplex_v1_files` | Flat file storage | +| `appFilesDir` | Same as `filesDir` | No subdirectory on desktop | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | Custom wallpaper images | +| `coreTmpDir` | `dataDir/tmp` | Haskell core temp directory | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | Prefix: core appends `_chat.db` / `_agent.db` | +| `chatDatabaseFileName` | `"simplex_v1_chat.db"` | Full filename: `simplex_v1_chat.db` | +| `agentDatabaseFileName` | `"simplex_v1_agent.db"` | Full filename: `simplex_v1_agent.db` | +| `databaseExportDir` | Same as `tmpDir` | Temp location for archive export | +| `remoteHostsDir` | `dataDir/remote_hosts` | Remote host file staging | +| `preferencesDir` | `desktopPlatform.configPath` | Platform config directory | + +### Resulting Database Paths + +| Platform | Chat DB | Agent DB | +|----------|---------|----------| +| Android | `/data/data//files_chat.db` | `/data/data//files_agent.db` | +| Desktop (Linux) | `~/.local/share/simplex/simplex_v1_chat.db` | `~/.local/share/simplex/simplex_v1_agent.db` | +| Desktop (macOS) | `~/Library/Application Support/simplex/simplex_v1_chat.db` | ... | +| Desktop (Windows) | `%APPDATA%/simplex/simplex_v1_chat.db` | ... | + +--- + +## 3. Haskell Store Modules + +The Haskell core organizes database access into store modules. Kotlin code invokes these indirectly through `CC` commands. The store modules are: + +| Module | Path | Responsibilities | +|--------|------|-----------------| +| `Messages.hs` | `src/Simplex/Chat/Store/Messages.hs` | Message CRUD, chat items, reactions, delivery statuses, TTL cleanup | +| `Groups.hs` | `src/Simplex/Chat/Store/Groups.hs` | Group profiles, membership, roles, invitations, group links | +| `Direct.hs` | `src/Simplex/Chat/Store/Direct.hs` | Contact management, direct connections, contact requests | +| `Files.hs` | `src/Simplex/Chat/Store/Files.hs` | File transfer metadata, XFTP state, standalone files | +| `Profiles.hs` | `src/Simplex/Chat/Store/Profiles.hs` | User profiles, display names, address book | +| `Connections.hs` | `src/Simplex/Chat/Store/Connections.hs` | SMP agent connections, pending connections, server switches | + +All store operations execute within SQLite transactions managed by the Haskell core. The Kotlin layer has no direct knowledge of table schemas or SQL queries. + +--- + +## 4. Migrations + +### JNI Entry Point + +Database migration is triggered by the `chatMigrateInit` external function ([Core.kt#L25](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L25)): + +```kotlin +external fun chatMigrateInit(dbPath: String, dbKey: String, confirm: String): Array +``` + +**Parameters:** +- `dbPath` -- the `dbAbsolutePrefixPath` (core appends `_chat.db` and `_agent.db`) +- `dbKey` -- encryption passphrase (empty string = unencrypted) +- `confirm` -- migration confirmation mode: `"error"`, `"yesUp"`, or `"yesUpDown"` + +**Returns:** `Array` where: +- `[0]` -- JSON string encoding a `DBMigrationResult` +- `[1]` -- `ChatCtrl` handle (Long) if migration succeeded + +### Migration Flow in `initChatController` + +The full initialization sequence is in [Core.kt#L62](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L62): + +1. Obtain the DB encryption key from `DatabaseUtils.useDatabaseKey()`. +2. Determine the confirmation mode (default: `YesUp`; developer mode with confirm upgrades: `Error`). +3. Call `chatMigrateInit(dbAbsolutePrefixPath, dbKey, "error")` -- first attempt with `Error` to detect pending migrations. +4. Parse the result as `DBMigrationResult`. +5. If the result is `ErrorMigration` with an `Upgrade` error and confirmation allows it, re-run `chatMigrateInit` with the appropriate confirmation (`"yesUp"`). +6. If `OK`, store the `ChatCtrl` handle, set `chatDbEncrypted`, and proceed to start the chat. +7. If not `OK`, handle special case: if the `newDatabaseInitialized` preference is not set AND the database was only partially initialized (single DB file exists), remove both files and retry once. + + + +### DBMigrationResult + +Defined in [DatabaseUtils.kt#L79](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt#L79): + +```kotlin +sealed class DBMigrationResult { + object OK // Migration succeeded + object InvalidConfirmation // Invalid confirmation parameter + data class ErrorNotADatabase(val dbFile: String) // File exists but is not a valid database + data class ErrorMigration(val dbFile: String, // Migration error with details + val migrationError: MigrationError) + data class ErrorSQL(val dbFile: String, // SQL error during migration + val migrationSQLError: String) + object ErrorKeychain // Keychain/keystore error + data class Unknown(val json: String) // Unparseable response +} +``` + +### MigrationError + +```kotlin +sealed class MigrationError { + class Upgrade(val upMigrations: List) // Pending forward migrations + class Downgrade(val downMigrations: List) // Database is newer than app + class Error(val mtrError: MTRError) // Conflict or missing migrations +} +``` + +### MigrationConfirmation + +```kotlin +enum class MigrationConfirmation(val value: String) { + YesUp("yesUp"), // Auto-confirm forward migrations + YesUpDown("yesUpDown"), // Auto-confirm both directions (not used in UI) + Error("error") // Report errors without running migrations +} +``` + +--- + +## 5. Database Encryption + +### Encryption API + +Two API functions manage database encryption, both in [SimpleXAPI.kt](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Parameters | Description | Line | +|----------|-----------|-------------|------| +| `apiStorageEncryption` | `currentKey: String, newKey: String` | Change or set the database encryption passphrase | [L999](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L999) | +| `testStorageEncryption` | `key: String, ctrl: ChatCtrl?` | Test whether a given key can decrypt the database | [L1006](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1006) | + +Both delegate to the Haskell core via `CC.ApiStorageEncryption(DBEncryptionConfig)` and `CC.TestStorageEncryption(key)` respectively. + + + +`DBEncryptionConfig` ([SimpleXAPI.kt#L4166](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4166)): + +```kotlin +class DBEncryptionConfig(val currentKey: String, val newKey: String) +``` + +### Passphrase Storage -- CryptorInterface + +The `CryptorInterface` ([Cryptor.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt)) provides platform-specific key encryption for storing the DB passphrase at rest: + +```kotlin +interface CryptorInterface { + fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? + fun encryptText(text: String, alias: String): Pair + fun deleteKey(alias: String) +} + +expect val cryptor: CryptorInterface +``` + +### Android Implementation + +[Cryptor.android.kt](../common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt): + +- Uses **Android KeyStore** (`"AndroidKeyStore"` provider) +- Algorithm: **AES/GCM/NoPadding** (128-bit authentication tag) +- Keys are hardware-backed when available +- On decryption failure with a random initial passphrase, throws to prevent overwriting +- Shows user alerts for keychain errors + +```kotlin +internal class Cryptor: CryptorInterface { + private var keyStore: KeyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + // AES-GCM encryption/decryption using AndroidKeyStore-managed keys +} +``` + +### Desktop Implementation + +[Cryptor.desktop.kt](../common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt): + +- **Placeholder/no-op implementation** -- data is returned as-is +- No actual encryption of the stored passphrase on desktop +- `decryptData` returns `String(data)` without decryption +- `encryptText` returns the raw bytes without encryption + +```kotlin +actual val cryptor: CryptorInterface = object : CryptorInterface { + override fun decryptData(data: ByteArray, iv: ByteArray, alias: String): String? = String(data) + override fun encryptText(text: String, alias: String) = text.toByteArray() to text.toByteArray() + override fun deleteKey(alias: String) {} +} +``` + +### Passphrase Management + +`DatabaseUtils` ([DatabaseUtils.kt](../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt)) provides: + +- `ksDatabasePassword` -- encrypted passphrase stored in platform preferences (SharedPreferences on Android, file-based on desktop) +- `useDatabaseKey()` -- retrieves the passphrase, decrypting it via `CryptorInterface` +- `randomDatabasePassword()` -- generates a 32-byte random passphrase (Base64-encoded) for initial database creation + +The flow: +1. On first launch, `randomDatabasePassword()` generates a key. +2. `CryptorInterface.encryptText()` encrypts the key for storage. +3. The encrypted (data, IV) pair is saved to preferences via `ksDatabasePassword`. +4. On subsequent launches, `ksDatabasePassword.get()` retrieves the encrypted pair, and `CryptorInterface.decryptData()` recovers the plaintext key. +5. The key is passed to `chatMigrateInit` to open the encrypted SQLite databases. + +--- + +## 6. File Storage + +### Directory Layout + +Declared in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) with platform-specific implementations: + +| Directory | Variable | Android Path | Desktop Path | Purpose | +|-----------|----------|-------------|--------------|---------| +| App files | `appFilesDir` | `dataDir/files/app_files` | `dataDir/simplex_v1_files` | Chat file attachments (images, videos, documents) | +| Wallpapers | `wallpapersDir` | `dataDir/files/assets/wallpapers` | `dataDir/simplex_v1_assets/wallpapers` | Custom chat wallpaper images | +| Core temp | `coreTmpDir` | `dataDir/files/temp_files` | `dataDir/tmp` | Haskell core temporary files (in-progress transfers) | +| App temp | `tmpDir` | `getDir("temp", MODE_PRIVATE)` | `java.io.tmpdir/simplex` | Application-level temporary files | +| Remote hosts | `remoteHostsDir` | `tmpDir/remote_hosts` | `dataDir/remote_hosts` | Files staged for remote host sessions | +| DB export | `databaseExportDir` | `androidAppContext.cacheDir` | Same as `tmpDir` | Temporary storage for database archive ZIP | +| Preferences | `preferencesDir` | `dataDir/shared_prefs` | `desktopPlatform.configPath` | User preferences, theme YAML | +| Migration temp | `getMigrationTempFilesDirectory()` | `dataDir/migration_temp_files` | `dataDir/migration_temp_files` | Temporary files during database migration | + +### File Path Resolution + +Files referenced by chat items use `CryptoFile` (optional encryption metadata + relative path). Path resolution is handled by helper functions in [Files.kt](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt): + +- `getAppFilePath(fileName)` ([L81](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81)) -- resolves to `appFilesDir/fileName` for local, or `remoteHostsDir//simplex_v1_files/fileName` for remote hosts +- `getWallpaperFilePath(fileName)` ([L91](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91)) -- resolves wallpaper paths similarly +- `getLoadedFilePath(file)` ([L105](../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105)) -- returns the full path if the file is downloaded and ready + +### Local File Encryption + +The `apiSetEncryptLocalFiles(enable)` command ([SimpleXAPI.kt#L967](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L967)) tells the Haskell core to encrypt files stored in `appFilesDir`. When enabled, files are written as `CryptoFile` with a random AES key and nonce. The JNI functions `chatEncryptFile` and `chatDecryptFile` ([Core.kt#L39-L40](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt#L39)) handle the actual crypto operations. + +--- + +## 7. Export & Import + +### API Functions + +| Function | CC Command | CR Response | Line | +|----------|-----------|-------------|------| +| `apiExportArchive(config)` | `CC.ApiExportArchive(config)` | `CR.ArchiveExported(archiveErrors)` | [SimpleXAPI.kt#L981](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L981) | +| `apiImportArchive(config)` | `CC.ApiImportArchive(config)` | `CR.ArchiveImported(archiveErrors)` | [SimpleXAPI.kt#L987](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L987) | +| `apiDeleteStorage()` | `CC.ApiDeleteStorage()` | `CR.CmdOk` | [SimpleXAPI.kt#L993](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L993) | + +### ArchiveConfig + +Defined at [SimpleXAPI.kt#L4162](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L4162): + +```kotlin +class ArchiveConfig( + val archivePath: String, // Full path to the ZIP archive + val disableCompression: Boolean?, // Skip compression for speed + val parentTempDirectory: String? // Temp directory for extraction +) +``` + +### Export Flow + +1. UI constructs an `ArchiveConfig` with a path under `databaseExportDir`. +2. Calls `apiExportArchive(config)` which sends `CC.ApiExportArchive` to the Haskell core. +3. The core creates a ZIP containing both `_chat.db` and `_agent.db` (and optionally files). +4. Returns `CR.ArchiveExported` with a list of `ArchiveError` (non-fatal issues during export). +5. UI offers the archive file for sharing/saving. + +### Import Flow + +1. User selects an archive file. +2. UI copies it to a temp location and constructs an `ArchiveConfig`. +3. Calls `apiImportArchive(config)` which sends `CC.ApiImportArchive` to the Haskell core. +4. The core extracts and replaces both databases. +5. Returns `CR.ArchiveImported` with a list of `ArchiveError` (non-fatal issues during import). +6. UI triggers re-initialization via `initChatController`. + + + +### ArchiveError + +Defined at [SimpleXAPI.kt#L7658](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7658): + +```kotlin +sealed class ArchiveError { + class ArchiveErrorImport(val importError: String) // General import error + class ArchiveErrorFile(val file: String, val fileError: String) // Per-file error +} +``` + +### Delete Storage + +`apiDeleteStorage()` removes both database files entirely. This is used during account deletion or database reset operations. After calling this, `initChatController` must be called to create fresh databases. + +--- + +## 8. Source Files + +| File | Purpose | Path | +|------|---------|------| +| SimpleXAPI.kt | API functions: encryption, export/import, storage commands | `common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +| Core.kt | JNI externals (`chatMigrateInit`, `chatEncryptFile`, etc.), `initChatController` | `common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt` | +| Files.kt | Platform-expect file/directory path declarations | `common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt` | +| Files.android.kt | Android actual paths (dataDir, appFilesDir, etc.) | `common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt` | +| Files.desktop.kt | Desktop actual paths (XDG/AppData, etc.) | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt` | +| Cryptor.kt | Platform-expect encryption interface for passphrase storage | `common/src/commonMain/kotlin/chat/simplex/common/platform/Cryptor.kt` | +| Cryptor.android.kt | Android: AES-GCM via AndroidKeyStore | `common/src/androidMain/kotlin/chat/simplex/common/platform/Cryptor.android.kt` | +| Cryptor.desktop.kt | Desktop: placeholder (no-op) implementation | `common/src/desktopMain/kotlin/chat/simplex/common/platform/Cryptor.desktop.kt` | +| DatabaseUtils.kt | `DBMigrationResult`, `MigrationError`, `MigrationConfirmation`, passphrase helpers | `common/src/commonMain/kotlin/chat/simplex/common/views/helpers/DatabaseUtils.kt` | +| Messages.hs | Haskell store: message CRUD, reactions, delivery | `src/Simplex/Chat/Store/Messages.hs` | +| Groups.hs | Haskell store: groups, membership, roles | `src/Simplex/Chat/Store/Groups.hs` | +| Direct.hs | Haskell store: contacts, direct connections | `src/Simplex/Chat/Store/Direct.hs` | +| Files.hs | Haskell store: file transfer metadata | `src/Simplex/Chat/Store/Files.hs` | +| Profiles.hs | Haskell store: user profiles | `src/Simplex/Chat/Store/Profiles.hs` | +| Connections.hs | Haskell store: SMP agent connections | `src/Simplex/Chat/Store/Connections.hs` | + +All Kotlin paths are relative to `apps/multiplatform/`. All Haskell paths are relative to the repository root. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md new file mode 100644 index 0000000000..cd0f836585 --- /dev/null +++ b/apps/multiplatform/spec/impact.md @@ -0,0 +1,532 @@ +# SimpleX Chat Android & Desktop -- Impact Graph + +> Source file to product concept mapping. Use this to identify which product documents must be updated when a source file changes. +> +> Covers Kotlin Multiplatform (Compose) sources: commonMain, androidMain, desktopMain, and the Android and Desktop app modules. Also covers the shared Haskell core. + +--- + +## Product Concept Legend + +| ID | Concept | +|----|---------| +| PC1 | Chat List | +| PC2 | Direct Chat | +| PC3 | Group Chat | +| PC4 | Message Composition | +| PC5 | Message Reactions | +| PC6 | Message Editing | +| PC7 | Message Deletion | +| PC8 | Timed Messages | +| PC9 | Voice Messages | +| PC10 | File Transfer | +| PC11 | Link Previews | +| PC12 | Contact Connection | +| PC13 | Contact Verification | +| PC14 | Group Management | +| PC15 | Group Links | +| PC16 | Member Roles | +| PC17 | Audio/Video Calls | +| PC18 | Notifications | +| PC19 | User Profiles | +| PC20 | Incognito Mode | +| PC21 | Hidden Profiles | +| PC22 | Local Authentication | +| PC23 | Database Encryption | +| PC24 | Theme System | +| PC25 | Network Configuration | +| PC26 | Device Migration | +| PC27 | Remote Desktop | +| PC28 | Chat Tags | +| PC29 | User Address | +| PC30 | Member Support Chat | + +--- + +## 1. Common Sources (commonMain) + +Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` + +### 1.1 Core Model & Platform + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `App.kt` | PC1 through PC30 | High | Root composable — navigation scaffold for all features | +| `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | +| `model/ChatModel.kt` | PC1 through PC30 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC30 | High | FFI bridge to Haskell core — all commands and responses | +| `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | +| `platform/Core.kt` | PC1 through PC30 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC30 | Medium | Shared app initialization logic | +| `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | +| `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | +| `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | +| `platform/SimplexService.kt` | PC18 | Medium | Background service expect declarations | +| `platform/RecAndPlay.kt` | PC9 | Medium | Audio recording and playback abstractions | +| `platform/VideoPlayer.kt` | PC10, PC17 | Low | Video playback abstractions | +| `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | +| `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | +| `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | +| `platform/Platform.kt` | PC1 through PC30 | Low | Platform detection and capability flags | +| `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | +| `platform/Back.kt` | PC1 | Low | Back navigation handling | +| `platform/UI.kt` | PC24 | Low | UI density and locale helpers | +| `platform/ScrollableColumn.kt` | PC1 | Low | Scrollable list abstractions | +| `platform/Log.kt` | — | Low | Logging utility — no direct product impact | +| `platform/Modifier.kt` | PC24 | Low | Compose modifier extensions | +| `platform/Resources.kt` | PC24 | Low | Resource loading helpers | + +### 1.2 Theme + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `ui/theme/ThemeManager.kt` | PC24 | Medium | Theme resolution engine — all color and wallpaper logic | +| `ui/theme/Theme.kt` | PC24 | Medium | Theme composables and `SimpleXTheme` | +| `ui/theme/Color.kt` | PC24 | Low | Color palette definitions | +| `ui/theme/Shape.kt` | PC24 | Low | Shape token definitions | +| `ui/theme/Type.kt` | PC24 | Low | Typography definitions | + +### 1.3 Views — Chat List + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chatlist/ChatListView.kt` | PC1, PC28 | High | Main screen — chat list rendering and search | +| `views/chatlist/ChatListNavLinkView.kt` | PC1, PC2, PC3 | Medium | Navigation from chat list item to chat | +| `views/chatlist/ChatPreviewView.kt` | PC1, PC2, PC3, PC11 | Medium | Chat row preview rendering | +| `views/chatlist/TagListView.kt` | PC28 | Medium | Chat tag filter UI | +| `views/chatlist/UserPicker.kt` | PC19, PC21 | Medium | Multi-profile switcher overlay | +| `views/chatlist/ShareListView.kt` | PC10 | Low | Share target list | +| `views/chatlist/ShareListNavLinkView.kt` | PC10 | Low | Share target navigation | +| `views/chatlist/ChatHelpView.kt` | PC1 | Low | Empty-state help content | +| `views/chatlist/ContactRequestView.kt` | PC12 | Medium | Incoming contact request row | +| `views/chatlist/ContactConnectionView.kt` | PC12 | Low | Pending connection row | +| `views/chatlist/ServersSummaryView.kt` | PC25 | Low | Server status summary | + +### 1.4 Views — Chat & Messaging + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | +| `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | +| `views/chat/ComposeVoiceView.kt` | PC9 | Medium | Voice message recording UI | +| `views/chat/ComposeFileView.kt` | PC10 | Low | File attachment preview in compose area | +| `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | +| `views/chat/ContextItemView.kt` | PC6 | Low | Reply/edit quote preview | +| `views/chat/SelectableChatItemToolbars.kt` | PC7, PC10 | Medium | Multi-select toolbar (delete, forward) | +| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20 | Medium | Contact details and verification | +| `views/chat/ContactPreferences.kt` | PC2, PC8 | Medium | Per-contact feature preferences | +| `views/chat/ChatItemInfoView.kt` | PC2, PC3 | Low | Message delivery detail | +| `views/chat/ChatItemsLoader.kt` | PC2, PC3 | Medium | Pagination and message loading logic | +| `views/chat/ChatItemsMerger.kt` | PC2, PC3 | Medium | Merges incremental message updates | +| `views/chat/VerifyCodeView.kt` | PC13 | Medium | Contact security code verification | +| `views/chat/ScanCodeView.kt` | PC13 | Low | QR code scanning for verification | +| `views/chat/CommandsMenuView.kt` | PC4 | Low | Slash-command menu | +| `views/chat/ComposeContextProfilePickerView.kt` | PC20 | Low | Incognito profile picker in compose | +| `views/chat/ComposeContextPendingMemberActionsView.kt` | PC14, PC30 | Low | Pending member action buttons in compose | +| `views/chat/ComposeContextGroupDirectInvitationActionsView.kt` | PC14 | Low | Direct invitation action buttons in compose | +| `views/chat/ComposeContextContactRequestActionsView.kt` | PC12 | Low | Contact request action buttons in compose | + +### 1.5 Views — Chat Items + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8 | High | Root chat item renderer with context menus | +| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4 | Medium | Text message bubble rendering | +| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/CIImageView.kt` | PC10 | Medium | Image message rendering | +| `views/chat/item/CIVideoView.kt` | PC10 | Medium | Video message rendering | +| `views/chat/item/CIFileView.kt` | PC10 | Medium | File message rendering | +| `views/chat/item/CIVoiceView.kt` | PC9 | Medium | Voice message rendering and playback | +| `views/chat/item/EmojiItemView.kt` | PC5 | Low | Emoji reaction display | +| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8 | Low | Timestamp, delivery status, timed message indicator | +| `views/chat/item/CICallItemView.kt` | PC17 | Low | Call event item rendering | +| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16 | Low | Group event item rendering | +| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14 | Low | Group invitation item rendering | +| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12 | Low | Member-created contact event | +| `views/chat/item/CIChatFeatureView.kt` | PC8 | Low | Feature change event rendering | +| `views/chat/item/CIFeaturePreferenceView.kt` | PC8 | Low | Feature preference change rendering | +| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3 | Low | Decryption error display | +| `views/chat/item/DeletedItemView.kt` | PC7 | Low | Deleted message placeholder | +| `views/chat/item/MarkedDeletedItemView.kt` | PC7 | Low | Moderated/marked-deleted placeholder | +| `views/chat/item/ImageFullScreenView.kt` | PC10 | Low | Full-screen image viewer | +| `views/chat/item/CIBrokenComposableView.kt` | — | Low | Fallback for render failures | +| `views/chat/item/CIInvalidJSONView.kt` | — | Low | Fallback for malformed items | +| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3 | Low | Message integrity error display | + +### 1.6 Views — Groups + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/chat/group/GroupChatInfoView.kt` | PC3, PC14, PC15, PC16, PC30 | High | Group management hub | +| `views/chat/group/AddGroupMembersView.kt` | PC14, PC16 | Medium | Member invitation flow | +| `views/chat/group/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30 | Medium | Member details and role management | +| `views/chat/group/GroupProfileView.kt` | PC3, PC14 | Medium | Group profile editing | +| `views/chat/group/GroupLinkView.kt` | PC15 | Low | Group link creation and sharing | +| `views/chat/group/GroupPreferences.kt` | PC3, PC8, PC14 | Medium | Group feature toggles | +| `views/chat/group/GroupMentions.kt` | PC3, PC4 | Medium | @mention resolution and display | +| `views/chat/group/GroupMembersToolbar.kt` | PC3, PC14 | Low | Member list toolbar | +| `views/chat/group/GroupReportsView.kt` | PC3, PC14 | Low | Group content reports | +| `views/chat/group/MemberAdmission.kt` | PC14, PC16 | Medium | Member admission settings | +| `views/chat/group/MemberSupportView.kt` | PC30 | Medium | Member support chat toggle | +| `views/chat/group/MemberSupportChatView.kt` | PC30 | Medium | Member support chat conversation | +| `views/chat/group/WelcomeMessageView.kt` | PC3, PC14 | Low | Group welcome message editor | + +### 1.7 Views — Calls + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/call/CallView.kt` | PC17 | High | Call UI and WebRTC composable | +| `views/call/CallManager.kt` | PC17 | High | Call lifecycle management | +| `views/call/WebRTC.kt` | PC17 | High | WebRTC types and signaling | +| `views/call/IncomingCallAlertView.kt` | PC17, PC18 | Medium | Incoming call overlay | + +### 1.8 Views — New Chat & Contacts + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/newchat/NewChatView.kt` | PC12, PC29 | High | New connection creation — onramp for all contacts | +| `views/newchat/NewChatSheet.kt` | PC12 | Medium | Bottom sheet with connection options | +| `views/newchat/ConnectPlan.kt` | PC12, PC15 | Medium | Link parsing and connection plan resolution | +| `views/newchat/AddGroupView.kt` | PC3, PC14 | Medium | New group creation flow | +| `views/newchat/ContactConnectionInfoView.kt` | PC12 | Low | Pending connection details | +| `views/newchat/AddContactLearnMore.kt` | PC12 | Low | Educational content | +| `views/newchat/QRCode.kt` | PC12 | Low | QR code display | +| `views/newchat/QRCodeScanner.kt` | PC12 | Low | QR code camera scanner | +| `views/contacts/ContactListNavView.kt` | PC1, PC12 | Medium | Contact list navigation | +| `views/contacts/ContactPreviewView.kt` | PC12 | Low | Contact row preview | + +### 1.9 Views — User Settings + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | +| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization | +| `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings | +| `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing | +| `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management | +| `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access | +| `views/usersettings/IncognitoView.kt` | PC20 | Low | Incognito mode explanation | +| `views/usersettings/UserAddressView.kt` | PC29 | Medium | User SimpleX address management | +| `views/usersettings/UserAddressLearnMore.kt` | PC29 | Low | Address educational content | +| `views/usersettings/NotificationsSettingsView.kt` | PC18 | Medium | Notification mode configuration | +| `views/usersettings/CallSettings.kt` | PC17 | Low | Call-related settings | +| `views/usersettings/Preferences.kt` | PC2, PC3, PC8 | Medium | Chat feature preferences UI | +| `views/usersettings/SetDeliveryReceiptsView.kt` | PC2 | Low | Delivery receipts toggle | +| `views/usersettings/RTCServers.kt` | PC17, PC25 | Medium | WebRTC ICE server configuration | +| `views/usersettings/DeveloperView.kt` | — | Low | Developer/debug settings | +| `views/usersettings/HelpView.kt` | — | Low | Help and support links | +| `views/usersettings/MarkdownHelpView.kt` | PC4 | Low | Markdown formatting guide | +| `views/usersettings/VersionInfoView.kt` | — | Low | Version display | +| `views/usersettings/networkAndServers/NetworkAndServers.kt` | PC25 | High | Server and network configuration hub | +| `views/usersettings/networkAndServers/AdvancedNetworkSettings.kt` | PC25 | Medium | SOCKS proxy, timeouts, etc. | +| `views/usersettings/networkAndServers/OperatorView.kt` | PC25 | Medium | Server operator management | +| `views/usersettings/networkAndServers/ProtocolServersView.kt` | PC25 | Medium | SMP/XFTP server list | +| `views/usersettings/networkAndServers/ProtocolServerView.kt` | PC25 | Low | Individual server editing | +| `views/usersettings/networkAndServers/NewServerView.kt` | PC25 | Low | Add new server | +| `views/usersettings/networkAndServers/ScanProtocolServer.kt` | PC25 | Low | QR scan for server address | + +### 1.10 Views — Database & Migration + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/database/DatabaseView.kt` | PC23, PC26 | High | Database management — export, import, passphrase | +| `views/database/DatabaseEncryptionView.kt` | PC23 | High | Database encryption passphrase change | +| `views/database/DatabaseErrorView.kt` | PC23 | Medium | Database open error recovery | +| `views/migration/MigrateFromDevice.kt` | PC26 | High | Outbound device migration | +| `views/migration/MigrateToDevice.kt` | PC26 | High | Inbound device migration | + +### 1.11 Views — Local Auth & Onboarding + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/localauth/LocalAuthView.kt` | PC22 | Medium | App lock authentication flow | +| `views/localauth/SetAppPasscodeView.kt` | PC22 | Medium | Passcode creation and change | +| `views/localauth/PasscodeView.kt` | PC22 | Medium | Passcode entry UI | +| `views/localauth/PasswordEntry.kt` | PC22 | Low | Password input field | +| `views/onboarding/OnboardingView.kt` | PC1 | Medium | Onboarding flow navigation | +| `views/onboarding/SimpleXInfo.kt` | PC1 | Low | Welcome screen | +| `views/onboarding/SetNotificationsMode.kt` | PC18 | Medium | Notification permission and mode setup | +| `views/onboarding/SetupDatabasePassphrase.kt` | PC23 | Medium | Initial database passphrase setup | +| `views/onboarding/ChooseServerOperators.kt` | PC25 | Medium | Initial server operator selection | +| `views/onboarding/WhatsNewView.kt` | — | Low | Release notes display | +| `views/onboarding/HowItWorks.kt` | — | Low | Educational content | +| `views/onboarding/LinkAMobileView.kt` | PC27 | Low | Mobile linking onboarding | + +### 1.12 Views — Remote Desktop + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/remote/ConnectDesktopView.kt` | PC27 | Medium | Connect-to-desktop flow (from mobile) | +| `views/remote/ConnectMobileView.kt` | PC27 | Medium | Connect-to-mobile flow (from desktop) | + +### 1.13 Views — Helpers + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/helpers/AlertManager.kt` | PC1 through PC30 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC30 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC30 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | +| `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | +| `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | +| `views/helpers/ChatWallpaper.kt` | PC24 | Low | Chat wallpaper rendering | +| `views/helpers/ChatInfoImage.kt` | PC19 | Low | Profile image composable | +| `views/helpers/ThemeModeEditor.kt` | PC24 | Low | Theme mode toggle | +| `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker | +| `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop | +| `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers | +| `views/helpers/SearchTextField.kt` | PC1 | Low | Search bar composable | +| `views/helpers/CustomTimePicker.kt` | PC8 | Low | Time picker for timed messages | +| `views/helpers/DragAndDrop.kt` | PC10 | Low | Drag-and-drop file handling | +| `views/helpers/ProcessedErrors.kt` | — | Low | Error aggregation | +| `views/helpers/AnimationUtils.kt` | PC24 | Low | Animation helpers | +| `views/helpers/DefaultDialog.kt` | — | Low | Dialog composable primitives | +| `views/helpers/DefaultDropdownMenu.kt` | — | Low | Dropdown menu composable | +| `views/helpers/Section.kt` | — | Low | Settings section composable | +| `views/helpers/SimpleButton.kt` | — | Low | Button composable | +| `views/helpers/DefaultTopAppBar.kt` | — | Low | App bar composable | +| `views/helpers/DefaultBasicTextField.kt` | PC4 | Low | Text field composable | +| `views/helpers/AppBarTitle.kt` | — | Low | App bar title composable | +| `views/helpers/BlurModifier.kt` | PC22 | Low | Blur modifier for app lock | +| `views/helpers/CollapsingAppBar.kt` | — | Low | Collapsing toolbar composable | +| `views/helpers/CustomIcons.kt` | — | Low | Custom icon definitions | +| `views/helpers/DataClasses.kt` | — | Low | Shared data class utilities | +| `views/helpers/DefaultProgressBar.kt` | — | Low | Progress bar composable | +| `views/helpers/DefaultSwitch.kt` | — | Low | Switch composable | +| `views/helpers/Enums.kt` | — | Low | Enum utility extensions | +| `views/helpers/ExposedDropDownSettingRow.kt` | — | Low | Dropdown setting row composable | +| `views/helpers/GestureDetector.kt` | — | Low | Touch gesture utilities | +| `views/helpers/Modifiers.kt` | — | Low | Compose modifier extensions | +| `views/helpers/SubscriptionStatusIcon.kt` | PC25 | Low | Server connection status icon | + +### 1.14 Views — Other + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `views/TerminalView.kt` | — | Low | Developer chat console | +| `views/SplashView.kt` | — | Low | Splash screen | +| `views/WelcomeView.kt` | PC1 | Low | Empty-state welcome | +| `views/Preview.kt` | — | Low | Compose preview utilities | + +--- + +## 2. Android Sources + +### 2.1 Android App Module + +Path prefix: `android/src/main/java/chat/simplex/app/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `SimplexApp.kt` | PC1 through PC30 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC30 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | +| `CallService.kt` | PC17 | Medium | Foreground service for active calls | +| `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | +| `model/NtfManager.android.kt` | PC18 | High | Android notification channels, display, and actions | +| `views/call/CallActivity.kt` | PC17 | Medium | Dedicated activity for full-screen call UI | +| `views/helpers/Util.kt` | — | Low | Android-specific utility extensions | + +### 2.2 Android Platform Implementations (androidMain) + +Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `platform/AppCommon.android.kt` | PC1 through PC30 | Medium | Android app initialization actual declarations | +| `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | +| `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | +| `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | +| `platform/RecAndPlay.android.kt` | PC9 | Medium | Android MediaRecorder/MediaPlayer actual implementation | +| `platform/VideoPlayer.android.kt` | PC10 | Low | Android ExoPlayer actual implementation | +| `platform/Notifications.android.kt` | PC18 | Medium | Android notification channel creation | +| `platform/Images.android.kt` | PC10, PC19 | Low | Android bitmap processing | +| `platform/PlatformTextField.android.kt` | PC4 | Low | Android native text field actual implementation | +| `platform/Share.android.kt` | PC10 | Low | Android share intent actual implementation | +| `platform/Back.android.kt` | PC1 | Low | Android back press handler | +| `platform/UI.android.kt` | PC24 | Low | Android density and locale | +| `platform/ScrollableColumn.android.kt` | PC1 | Low | Android lazy list actual implementation | +| `platform/Log.android.kt` | — | Low | Android Log wrapper | +| `platform/Modifier.android.kt` | — | Low | Android modifier extensions | +| `platform/Resources.android.kt` | — | Low | Android resource loading | +| `helpers/NetworkObserver.kt` | PC25 | Medium | Android ConnectivityManager observer | +| `helpers/Permissions.kt` | PC9, PC10, PC17, PC18 | Medium | Android runtime permission requests | +| `helpers/SoundPlayer.kt` | PC17, PC18 | Low | Android sound playback for calls and notifications | +| `helpers/Extensions.kt` | — | Low | Kotlin extension utilities | +| `helpers/Locale.kt` | — | Low | Locale helpers | +| `views/call/CallView.android.kt` | PC17 | Medium | Android WebView-based WebRTC call | +| `views/call/CallAudioDeviceManager.kt` | PC17 | Medium | Android audio routing (speaker, earpiece, bluetooth) | +| `views/chat/ComposeView.android.kt` | PC4, PC10 | Low | Android compose view extensions | +| `views/chat/SendMsgView.android.kt` | PC4 | Low | Android send button extensions | +| `views/chat/item/ChatItemView.android.kt` | PC2, PC3 | Low | Android chat item extensions | +| `views/chat/item/CIImageView.android.kt` | PC10 | Low | Android image rendering extensions | +| `views/chat/item/CIVideoView.android.kt` | PC10 | Low | Android video rendering extensions | +| `views/chat/item/CIFileView.android.kt` | PC10 | Low | Android file view extensions | +| `views/chat/item/EmojiItemView.android.kt` | PC5 | Low | Android emoji rendering extensions | +| `views/chat/item/ImageFullScreenView.android.kt` | PC10 | Low | Android full-screen image viewer | +| `views/chatlist/ChatListView.android.kt` | PC1 | Low | Android chat list extensions | +| `views/chatlist/ChatListNavLinkView.android.kt` | PC1 | Low | Android chat list navigation extensions | +| `views/chatlist/TagListView.android.kt` | PC28 | Low | Android tag list extensions | +| `views/chatlist/UserPicker.android.kt` | PC19 | Low | Android profile picker extensions | +| `views/database/DatabaseView.android.kt` | PC23, PC26 | Low | Android database view extensions | +| `views/database/DatabaseEncryptionView.android.kt` | PC23 | Low | Android encryption view extensions | +| `views/helpers/LocalAuthentication.android.kt` | PC22 | Medium | Android BiometricPrompt actual implementation | +| `views/helpers/ChooseAttachmentView.android.kt` | PC10 | Low | Android file/camera chooser | +| `views/helpers/GetImageView.android.kt` | PC10, PC19 | Low | Android image capture | +| `views/helpers/CustomTimePicker.android.kt` | PC8 | Low | Android time picker | +| `views/helpers/Utils.android.kt` | — | Low | Android utility extensions | +| `views/helpers/DefaultDialog.android.kt` | — | Low | Android dialog extensions | +| `views/helpers/WorkaroundFocusSearchLayout.kt` | — | Low | Android focus workaround | +| `views/newchat/QRCode.android.kt` | PC12 | Low | Android QR code rendering | +| `views/newchat/QRCodeScanner.android.kt` | PC12 | Low | Android camera QR scanner | +| `views/onboarding/SimpleXInfo.android.kt` | PC1 | Low | Android onboarding extensions | +| `views/onboarding/SetNotificationsMode.android.kt` | PC18 | Low | Android notification mode extensions | +| `views/usersettings/Appearance.android.kt` | PC24 | Low | Android appearance extensions | +| `views/usersettings/PrivacySettings.android.kt` | PC20, PC22 | Low | Android privacy settings extensions | +| `views/usersettings/SettingsView.android.kt` | — | Low | Android settings extensions | +| `views/usersettings/networkAndServers/OperatorView.android.kt` | PC25 | Low | Android operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.android.kt` | PC25 | Low | Android server QR scan | +| `ui/theme/Theme.android.kt` | PC24 | Low | Android dynamic color / system theme | +| `ui/theme/Type.android.kt` | PC24 | Low | Android typography | + +--- + +## 3. Desktop Sources + +### 3.1 Desktop App Module + +Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `Main.kt` | PC1 through PC30 | High | JVM entry point — Haskell init, migrations, app launch | + +### 3.2 Desktop Platform Implementations (desktopMain) + +Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `DesktopApp.kt` | PC1, PC2, PC3 | High | Desktop Compose window — window lifecycle, crash recovery | +| `StoreWindowState.kt` | — | Low | Window position/size persistence | +| `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | +| `platform/AppCommon.desktop.kt` | PC1 through PC30 | Medium | Desktop app initialization actual declarations | +| `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | +| `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | +| `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | +| `platform/RecAndPlay.desktop.kt` | PC9 | Medium | Desktop audio recording/playback actual implementation | +| `platform/VideoPlayer.desktop.kt` | PC10 | Low | Desktop VLC-based video player | +| `platform/Videos.desktop.kt` | PC10 | Low | Desktop video utilities | +| `platform/Notifications.desktop.kt` | PC18 | Low | Desktop notification setup | +| `platform/Images.desktop.kt` | PC10 | Low | Desktop image processing | +| `platform/PlatformTextField.desktop.kt` | PC4 | Low | Desktop text field actual implementation | +| `platform/Share.desktop.kt` | PC10 | Low | Desktop clipboard/share | +| `platform/Back.desktop.kt` | PC1 | Low | Desktop back navigation | +| `platform/UI.desktop.kt` | PC24 | Low | Desktop density and locale | +| `platform/ScrollableColumn.desktop.kt` | PC1 | Low | Desktop lazy list | +| `platform/Platform.desktop.kt` | — | Low | Platform detection | +| `platform/Log.desktop.kt` | — | Low | Desktop log output | +| `platform/Modifier.desktop.kt` | — | Low | Desktop modifier extensions | +| `platform/Resources.desktop.kt` | — | Low | Desktop resource loading | +| `views/call/CallView.desktop.kt` | PC17 | Medium | Desktop WebView-based WebRTC call | +| `views/chat/ComposeView.desktop.kt` | PC4, PC10 | Low | Desktop compose view (drag-and-drop, paste) | +| `views/chat/SendMsgView.desktop.kt` | PC4 | Low | Desktop send shortcut (Enter key handling) | +| `views/chat/item/ChatItemView.desktop.kt` | PC2, PC3 | Low | Desktop chat item extensions | +| `views/chat/item/CIImageView.desktop.kt` | PC10 | Low | Desktop image rendering | +| `views/chat/item/CIVideoView.desktop.kt` | PC10 | Low | Desktop video rendering | +| `views/chat/item/CIFileView.desktop.kt` | PC10 | Low | Desktop file open/save | +| `views/chat/item/EmojiItemView.desktop.kt` | PC5 | Low | Desktop emoji rendering | +| `views/chat/item/ImageFullScreenView.desktop.kt` | PC10 | Low | Desktop full-screen image | +| `views/chatlist/ChatListView.desktop.kt` | PC1 | Low | Desktop chat list extensions | +| `views/chatlist/ChatListNavLinkView.desktop.kt` | PC1 | Low | Desktop chat list navigation | +| `views/chatlist/TagListView.desktop.kt` | PC28 | Low | Desktop tag list extensions | +| `views/chatlist/UserPicker.desktop.kt` | PC19 | Low | Desktop profile picker | +| `views/database/DatabaseView.desktop.kt` | PC23, PC26 | Low | Desktop database view extensions | +| `views/database/DatabaseEncryptionView.desktop.kt` | PC23 | Low | Desktop encryption view extensions | +| `views/helpers/AppUpdater.kt` | — | Low | Desktop auto-update checker and installer | +| `views/helpers/OkHttpProgressListener.kt` | — | Low | Download progress tracking for updates | +| `views/helpers/LocalAuthentication.desktop.kt` | PC22 | Low | Desktop passcode-only auth (no biometrics) | +| `views/helpers/ChooseAttachmentView.desktop.kt` | PC10 | Low | Desktop file chooser dialog | +| `views/helpers/GetImageView.desktop.kt` | PC10, PC19 | Low | Desktop image file picker | +| `views/helpers/CustomTimePicker.desktop.kt` | PC8 | Low | Desktop time picker | +| `views/helpers/Utils.desktop.kt` | — | Low | Desktop utility extensions | +| `views/helpers/DefaultDialog.desktop.kt` | — | Low | Desktop dialog extensions | +| `views/newchat/QRCode.desktop.kt` | PC12 | Low | Desktop QR code rendering | +| `views/newchat/QRCodeScanner.desktop.kt` | PC12 | Low | Desktop QR code scanner (screen/clipboard) | +| `views/onboarding/SimpleXInfo.desktop.kt` | PC1 | Low | Desktop onboarding extensions | +| `views/onboarding/SetNotificationsMode.desktop.kt` | PC18 | Low | Desktop notification mode extensions | +| `views/usersettings/Appearance.desktop.kt` | PC24 | Low | Desktop appearance extensions | +| `views/usersettings/PrivacySettings.desktop.kt` | PC20, PC22 | Low | Desktop privacy settings extensions | +| `views/usersettings/SettingsView.desktop.kt` | — | Low | Desktop settings extensions | +| `views/usersettings/networkAndServers/OperatorView.desktop.kt` | PC25 | Low | Desktop operator view extensions | +| `views/usersettings/networkAndServers/ScanProtocolServer.desktop.kt` | PC25 | Low | Desktop server address scan | +| `ui/theme/Theme.desktop.kt` | PC24 | Low | Desktop system theme detection | +| `ui/theme/Type.desktop.kt` | PC24 | Low | Desktop typography | +| `other/videoplayer/SkiaBitmapVideoSurface.kt` | PC10 | Low | Desktop Skia video surface for VLC | + +--- + +## 4. Haskell Core Impact + +The Haskell core is compiled as a shared native library (`libsimplex.so` / `libsimplex.dylib`) and linked via JNI through `Core.kt`. Changes here affect both Android and Desktop identically. + +| Source File | Product Concepts Affected | Risk Level | Notes | +|-------------|--------------------------|------------|-------| +| `src/Simplex/Chat.hs` | PC1 through PC30 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC30 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC30 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC30 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC30 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC30 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC30 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | +| `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | +| `src/Simplex/Chat/Messages/CIContent/Events.hs` | PC3, PC14, PC16 | Medium | Group event content types | +| `src/Simplex/Chat/Messages/Batch.hs` | PC2, PC3, PC4 | Medium | Message batching for efficient delivery | +| `src/Simplex/Chat/Call.hs` | PC17 | Medium | Call signaling types | +| `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | +| `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | +| `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | +| `src/Simplex/Chat/Store.hs` | PC1 through PC30 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC30 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | +| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | +| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | +| `src/Simplex/Chat/Store/Files.hs` | PC10 | Medium | File transfer persistence | +| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21 | Medium | User profile persistence | +| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12 | High | Connection persistence and entity resolution | +| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12 | Medium | Contact request persistence | +| `src/Simplex/Chat/Store/NoteFolders.hs` | PC1 | Low | Note folder (self-chat) persistence | +| `src/Simplex/Chat/Store/Delivery.hs` | PC2, PC3 | Medium | Delivery task persistence | +| `src/Simplex/Chat/Store/AppSettings.hs` | PC25 | Low | App settings persistence | +| `src/Simplex/Chat/Store/Remote.hs` | PC27 | Low | Remote desktop session persistence | +| `src/Simplex/Chat/Archive.hs` | PC26 | Medium | Database export/import for migration | +| `src/Simplex/Chat/Options.hs` | PC23, PC25 | Low | Startup options (DB path, key, etc.) | +| `src/Simplex/Chat/Remote.hs` | PC27 | Medium | Remote desktop protocol handler | +| `src/Simplex/Chat/Remote/Types.hs` | PC27 | Low | Remote desktop data types | +| `src/Simplex/Chat/Remote/Protocol.hs` | PC27 | Medium | Remote desktop wire protocol | +| `src/Simplex/Chat/Remote/Transport.hs` | PC27 | Low | Remote desktop transport layer | +| `src/Simplex/Chat/Remote/RevHTTP.hs` | PC27 | Low | Reverse HTTP for remote desktop | +| `src/Simplex/Chat/Remote/AppVersion.hs` | PC27 | Low | Remote version negotiation | +| `src/Simplex/Chat/ProfileGenerator.hs` | PC20 | Low | Random profile generation for incognito | +| `src/Simplex/Chat/Types/UITheme.hs` | PC24 | Low | Theme data types for UI customization | +| `src/Simplex/Chat/Types/Preferences.hs` | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | +| `src/Simplex/Chat/Types/Shared.hs` | PC3, PC16 | Medium | Shared types including GroupMemberRole | +| `src/Simplex/Chat/Types/MemberRelations.hs` | PC3, PC16, PC30 | Medium | Member relationship state machine | +| `src/Simplex/Chat/Operators.hs` | PC25 | Medium | Server operator management | +| `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | +| `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | +| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC30 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC30 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | +| `src/Simplex/Chat/View.hs` | PC1 through PC30 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | +| `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | +| `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | +| `src/Simplex/Chat/Help.hs` | — | Low | Terminal help text | +| `src/Simplex/Chat/Bot.hs` | — | Low | Chat bot framework | +| `src/Simplex/Chat/Bot/KnownContacts.hs` | — | Low | Bot known contacts | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md new file mode 100644 index 0000000000..a8d056ebea --- /dev/null +++ b/apps/multiplatform/spec/services/calls.md @@ -0,0 +1,175 @@ +# WebRTC Calling Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [Call State Machine](#2-call-state-machine) +3. [Android Implementation](#3-android-implementation) +4. [Desktop Implementation](#4-desktop-implementation) +5. [Common Call API](#5-common-call-api) +6. [IncomingCallAlertView](#6-incomingcallalertview) +7. [Source Files](#7-source-files) + +## Executive Summary + +WebRTC calling in SimpleX Chat operates over SMP (SimpleX Messaging Protocol) for signaling, with platform-specific WebRTC media implementations. Android uses a WebView-based approach with a dedicated `CallActivity` and foreground `CallService`, while Desktop opens the system browser and communicates via a NanoWSD WebSocket server on localhost. Both platforms share a common `CallManager` for call lifecycle and a `CallState` enum for state tracking. Call commands and responses are serialized as JSON and exchanged between the native layer and the WebRTC JavaScript layer. + +--- + +## 1. Overview + +Call signaling uses the same SMP protocol on all platforms -- call invitations, offers, answers, ICE candidates, and status updates flow through the chat backend via API commands. The WebRTC media plane, however, is implemented differently per platform: + +- **Android**: WebView loads `call.html` from bundled assets; a `@JavascriptInterface` bridge (`WebRTCInterface`) forwards JSON messages between Kotlin and JavaScript. +- **Desktop**: The system browser opens `http://localhost:50395/simplex/call/`; a NanoWSD HTTP+WebSocket server serves `call.html` from classpath resources and relays JSON commands/responses over WebSocket. + +Both platforms share the [`CallManager`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) class (119 lines), which orchestrates incoming call acceptance, call ending, and notification management. + +--- + + + +## 2. Call State Machine + +Defined in [`WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt#L50): + +``` +enum class CallState { + WaitCapabilities, // Call initiated, waiting for local WebRTC capabilities + InvitationSent, // Invitation sent to peer via SMP + InvitationAccepted, // Peer's invitation accepted locally + OfferSent, // SDP offer sent to peer + OfferReceived, // SDP offer received from peer + AnswerReceived, // SDP answer received from peer + Negotiated, // ICE negotiation in progress + Connected, // Media flowing + Ended; // Call terminated +} +``` + +**Outgoing call flow**: `WaitCapabilities` -> `InvitationSent` -> `OfferSent` -> `AnswerReceived` -> `Negotiated` -> `Connected` -> `Ended` + +**Incoming call flow**: `InvitationAccepted` -> `OfferReceived` -> `Negotiated` -> `Connected` -> `Ended` + +State transitions are driven by `WCallResponse` messages from the WebRTC layer. Each transition typically triggers a corresponding API command (e.g., `apiSendCallInvitation`, `apiSendCallOffer`). + +--- + + + +## 3. Android Implementation + +### 3.1 CallActivity.kt (464 lines) + +[`CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) + +A dedicated `ComponentActivity` that hosts the call UI. Key responsibilities: + +- **Intent handling** ([line 64](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L64)): On `AcceptCallAction` intent, looks up the matching `RcvCallInvitation` and calls `callManager.acceptIncomingCall()`. +- **Lock screen support** ([line 160](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L160)): `unlockForIncomingCall()` uses `setShowWhenLocked(true)` / `setTurnScreenOn(true)` on API 27+, falls back to window flags on older versions. `lockAfterIncomingCall()` reverses these settings. +- **Picture-in-Picture** ([line 99](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L99)): `setPipParams()` configures PiP aspect ratio and source rect hint. On Android 12+ (`Build.VERSION_CODES.S`), auto-enter PiP is enabled for video calls. `onPictureInPictureModeChanged` toggles `activeCallViewIsCollapsed` and sends a `WCallCommand.Layout` command. +- **Permission checks** ([line 122](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L122)): Checks `RECORD_AUDIO` and conditionally `CAMERA` permissions. +- **Service binding** ([line 181](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L181)): Binds to `CallService` as a workaround for Android 12 background activity launch restrictions. +- **CallActivityView composable** ([line 208](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt#L208)): Renders `ActiveCallView()` when permissions are granted and a call is active. Shows `CallPermissionsView` when permissions are needed. Shows `IncomingCallLockScreenAlert` for incoming calls on the lock screen. + +### 3.2 CallService.kt (207 lines) + +[`CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) + +An Android foreground `Service` that keeps the call alive when the app is backgrounded: + +- **Foreground notification** ([line 131](../../android/src/main/java/chat/simplex/app/CallService.kt#L131)): Shows contact name (respecting `NotificationPreviewMode`), call type (audio/video), a chronometer when connected, and an "End call" action button. +- **WakeLock** ([line 66](../../android/src/main/java/chat/simplex/app/CallService.kt#L66)): Acquires `PARTIAL_WAKE_LOCK` to prevent CPU sleep during calls. +- **Notification channel** ([line 121](../../android/src/main/java/chat/simplex/app/CallService.kt#L121)): Creates `CALL_NOTIFICATION_CHANNEL_ID` with `IMPORTANCE_DEFAULT`. +- **Foreground service type** ([line 100](../../android/src/main/java/chat/simplex/app/CallService.kt#L100)): Uses `MEDIA_PLAYBACK | MICROPHONE` (+ `CAMERA` for video) on API 30+, `REMOTE_MESSAGING` on API 34+ when no active call. +- **Binder** ([line 158](../../android/src/main/java/chat/simplex/app/CallService.kt#L158)): `CallServiceBinder` allows `CallActivity` to call `updateNotification()` when call state changes. +- **CallActionReceiver** ([line 170](../../android/src/main/java/chat/simplex/app/CallService.kt#L170)): `BroadcastReceiver` that handles the `EndCallAction` from the notification. + +### 3.3 CallView.android.kt (891 lines) + +[`CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) + +The `actual` platform implementation of `ActiveCallView()` and supporting composables: + +- **ActiveCallState** ([line 74](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74)): Manages proximity lock (screen-off wake lock), `CallAudioDeviceManager` for audio routing (earpiece/speaker/bluetooth), `CallSoundsPlayer` for ringtones and vibration. Implements `Closeable` to clean up resources on call end. +- **ActiveCallView** ([line 114](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L114)): Renders `WebRTCView` plus `ActiveCallOverlay`. Handles `WCallResponse` messages and dispatches corresponding API calls. Manages volume control stream (`STREAM_VOICE_CALL`), screen keep-on, and call command lifecycle. +- **WebRTCView** ([line 691](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L691)): Creates/reuses a static `WebView` via `AndroidView`. Configures `WebViewAssetLoader` for local asset loading. Sets up `WebRTCInterface` JavaScript bridge. Loads `file:android_asset/www/android/call.html`. Processes `WCallCommand` queue by evaluating `processCommand()` JavaScript. +- **ActiveCallOverlayLayout** ([line 329](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L329)): Full overlay with mic toggle, speaker/device selector, end call, video toggle, and camera flip buttons. Adapts layout for video vs audio calls. +- **CallPermissionsView** ([line 569](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L569)): Handles runtime permission requests for microphone and camera with a fallback to settings if the system dialog is not shown. + +### 3.4 ActiveCallState + +[`ActiveCallState`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt#L74) (line 74 of `CallView.android.kt`): + +| Component | Purpose | +|---|---| +| `proximityLock` | `PROXIMITY_SCREEN_OFF_WAKE_LOCK` -- turns screen off when phone is held to ear | +| `callAudioDeviceManager` | Manages audio routing between earpiece, speaker, Bluetooth, wired headset | +| `CallSoundsPlayer` | Plays connecting/ringing sounds and vibration patterns | +| `wasConnected` | Tracks if call ever connected (for end-of-call vibration) | +| `close()` | Stops sounds, vibrates on disconnect, releases proximity lock, clears audio manager overrides | + +--- + +## 4. Desktop Implementation + +### 4.1 CallView.desktop.kt (263 lines) + +[`CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) + +Desktop calls run WebRTC in the system browser, not an embedded WebView: + +- **NanoWSD server** ([line 209](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L209)): `startServer()` creates a `NanoWSD` instance bound to `localhost:50395`. The server serves `call.html` from JAR resources at `/assets/www/desktop/call.html` for the path `/simplex/call/`. All other paths serve resources from `/assets/www/`. +- **WebSocket communication** ([line 238](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L238)): `MyWebSocket` handles WebSocket frames from the browser. `onMessage` deserializes JSON into `WVAPIMessage` and forwards to the response handler. `onClose` triggers `WCallResponse.End`. +- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Opens `http://localhost:50395/simplex/call/` via `LocalUriHandler`. Processes `WCallCommand` queue by sending JSON over WebSocket to all active connections. On dispose, sends `WCallCommand.End` and stops the server. +- **SendStateUpdates** ([line 137](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L137)): Sends `WCallCommand.Description` with call state and encryption info text to the browser for display. +- **ActiveCallView** ([line 28](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L28)): Handles `WCallResponse` messages identically to Android (same state machine), plus a `WCallCommand.Permission` message on `Capabilities` error for browser permission denial guidance. + +--- + +## 5. Common Call API + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Description | +|---|---|---| +| `apiGetCallInvitations` | [L1842](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1842) | Retrieve pending call invitations from the backend | +| `apiSendCallInvitation` | [L1849](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1849) | Send call invitation to a contact with `CallType` | +| `apiRejectCall` | [L1854](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1854) | Reject an incoming call | +| `apiSendCallOffer` | [L1859](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1859) | Send SDP offer with ICE candidates and capabilities | +| `apiSendCallAnswer` | [L1866](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1866) | Send SDP answer with ICE candidates | +| `apiSendCallExtraInfo` | [L1872](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1872) | Send additional ICE candidates discovered after initial exchange | +| `apiEndCall` | [L1878](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1878) | Terminate a call | +| `apiCallStatus` | [L1883](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1883) | Report WebRTC connection status to the backend | + +All functions send commands via `sendCmd()` to the chat core and return `Boolean` success status (except `apiGetCallInvitations` which returns `List`). + +--- + + + +## 6. IncomingCallAlertView + +[`IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) (128 lines) + +An in-app notification banner shown when a call invitation arrives while the app is in the foreground: + +- **IncomingCallAlertView** ([line 27](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L27)): Starts `SoundPlayer` for the ringtone (suppressed if already in a call view). Shows `IncomingCallAlertLayout`. +- **IncomingCallAlertLayout** ([line 49](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L49)): Colored banner with `ProfilePreview` of the caller, call type icon (audio/video), and three action buttons: Reject (red), Ignore (primary), Accept (green). +- **IncomingCallInfo** ([line 74](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt#L74)): Shows the user profile image (for multi-user), call media type icon, and call type text (encrypted/unencrypted audio/video). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CallView.kt` | [`common/src/commonMain/.../views/call/CallView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt) | 28 | `expect fun ActiveCallView()`, delivery receipt waiting | +| `CallView.android.kt` | [`common/src/androidMain/.../views/call/CallView.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt) | 891 | Android WebView WebRTC, overlay, permissions | +| `CallView.desktop.kt` | [`common/src/desktopMain/.../views/call/CallView.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt) | 263 | Desktop browser WebRTC via NanoWSD | +| `CallActivity.kt` | [`android/src/main/java/.../views/call/CallActivity.kt`](../../android/src/main/java/chat/simplex/app/views/call/CallActivity.kt) | 464 | Android call Activity, PiP, lock screen | +| `CallService.kt` | [`android/src/main/java/.../CallService.kt`](../../android/src/main/java/chat/simplex/app/CallService.kt) | 207 | Android foreground service for calls | +| `CallManager.kt` | [`common/src/commonMain/.../views/call/CallManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/CallManager.kt) | 119 | Call lifecycle management | +| `WebRTC.kt` | [`common/src/commonMain/.../views/call/WebRTC.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt) | -- | `CallState` enum, `WCallCommand`, `WCallResponse` types | +| `IncomingCallAlertView.kt` | [`common/src/commonMain/.../views/call/IncomingCallAlertView.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/call/IncomingCallAlertView.kt) | 128 | In-app incoming call notification banner | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | Call API commands (L1837--L1881) | diff --git a/apps/multiplatform/spec/services/files.md b/apps/multiplatform/spec/services/files.md new file mode 100644 index 0000000000..329e37dbb1 --- /dev/null +++ b/apps/multiplatform/spec/services/files.md @@ -0,0 +1,213 @@ +# File Transfer Service + +## Table of Contents + +1. [Overview](#1-overview) +2. [File Size Constants](#2-file-size-constants) +3. [CryptoFile](#3-cryptofile) +4. [File Storage Paths](#4-file-storage-paths) +5. [API Commands](#5-api-commands) +6. [Auto-Receive Logic](#6-auto-receive-logic) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses two file transfer mechanisms: inline SMP transfers for small files (embedded in message bodies) and XFTP (eXtended File Transfer Protocol) for larger files up to 1 GB. Files are optionally encrypted at rest using `CryptoFile` functions backed by the chat core's native crypto library. File storage paths are platform-specific: Android uses `Context.dataDir`-based directories while Desktop uses platform-appropriate data directories (XDG on Linux, AppData on Windows, Application Support on macOS). Auto-receive logic automatically accepts images, voice messages, and videos below configurable size thresholds. + +--- + +## 1. Overview + +File transfer decision logic: + +- **Inline (SMP)**: Files small enough to be base64-encoded and embedded directly in an SMP message body. The practical limit is defined by `MAX_IMAGE_SIZE` (255 KB) for compressed images. The maximum SMP inline size is `MAX_FILE_SIZE_SMP` (~7.6 MB). +- **XFTP**: For files exceeding the inline threshold, up to `MAX_FILE_SIZE_XFTP` (1 GB). XFTP uses dedicated file relay servers with chunked, encrypted transfers. + +The `receiveFile` / `receiveFiles` API commands handle both protocols transparently -- the chat core selects the appropriate transfer mechanism based on file metadata received from the sender. + +--- + + + +## 2. File Size Constants + +Defined in [`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118): + +| Constant | Value | Human-Readable | Line | Purpose | +|---|---|---|---|---| +| `MAX_IMAGE_SIZE` | 261,120 | 255 KB | [L118](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L118) | Inline image compression target | +| `MAX_IMAGE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L119](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L119) | Auto-receive threshold for images (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VOICE_SIZE_AUTO_RCV` | 522,240 | 510 KB | [L120](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L120) | Auto-receive threshold for voice messages (`2 * MAX_IMAGE_SIZE`) | +| `MAX_VIDEO_SIZE_AUTO_RCV` | 1,047,552 | 1023 KB | [L121](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L121) | Auto-receive threshold for video | +| `MAX_VOICE_MILLIS_FOR_SENDING` | 300,000 | 5 min | [L123](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L123) | Maximum voice message duration | +| `MAX_FILE_SIZE_SMP` | 8,000,000 | ~7.6 MB | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L125) | Maximum SMP inline file size | +| `MAX_FILE_SIZE_XFTP` | 1,073,741,824 | 1 GB | [L127](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L127) | Maximum XFTP transfer size | +| `MAX_FILE_SIZE_LOCAL` | `Long.MAX_VALUE` | Unlimited | [L129](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L129) | Local file protocol (no size limit) | + +The `getMaxFileSize()` function ([`Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt#L442)) selects the limit based on `FileProtocol`: + +```kotlin +FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP +FileProtocol.SMP -> MAX_FILE_SIZE_SMP +FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL +``` + +--- + +## 3. CryptoFile + +[`CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) (62 lines) + +Provides encrypted file I/O backed by the chat core's native cryptography (via JNI/JNA calls to `chatWriteFile`, `chatReadFile`, `chatEncryptFile`, `chatDecryptFile`). + +### Data types + +```kotlin +@Serializable +sealed class WriteFileResult { + @SerialName("result") data class Result(val cryptoArgs: CryptoFileArgs): WriteFileResult() + @SerialName("error") data class Error(val writeError: String): WriteFileResult() +} +``` + +`CryptoFileArgs` contains `fileKey` and `fileNonce` -- the symmetric encryption key and nonce for AES-GCM encryption. + + + +### Functions + +| Function | Line | Signature | Description | +|---|---|---|---| +| `writeCryptoFile` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L24) | `(path: String, data: ByteArray): CryptoFileArgs` | Writes data to an encrypted file via a direct `ByteBuffer`. Returns the generated key and nonce. Requires initialized `ChatController`. | +| `readCryptoFile` | [L36](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L36) | `(path: String, cryptoArgs: CryptoFileArgs): ByteArray` | Reads and decrypts a file given its key and nonce. Returns the plaintext bytes. Throws on error (status != 0). | +| `encryptCryptoFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L47) | `(fromPath: String, toPath: String): CryptoFileArgs` | Encrypts an existing plaintext file to a new encrypted file. Returns the generated key and nonce. | +| `decryptCryptoFile` | [L57](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt#L57) | `(fromPath: String, cryptoArgs: CryptoFileArgs, toPath: String)` | Decrypts an encrypted file to a plaintext output file. Throws on non-empty error string. | + +All functions delegate to native C library functions through the chat core JNI bridge. + +--- + + + +## 4. File Storage Paths + +### Common expect declarations + +[`Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) (191 lines, commonMain) + +| Property | Line | Description | +|---|---|---| +| `dataDir` | [L18](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L18) | Root application data directory | +| `tmpDir` | [L19](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L19) | Temporary files directory | +| `filesDir` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L20) | Base files directory | +| `appFilesDir` | [L21](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L21) | Application files (chat attachments) | +| `wallpapersDir` | [L22](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L22) | Theme wallpaper images | +| `coreTmpDir` | [L23](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L23) | Temporary files for the chat core | +| `dbAbsolutePrefixPath` | [L24](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L24) | Database file path prefix | +| `preferencesDir` | [L25](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L25) | Preferences/config directory | +| `databaseExportDir` | [L35](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L35) | Temporary DB archive storage for export | +| `remoteHostsDir` | [L37](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L37) | Remote host connection data | + +### Android implementation + +[`Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) (79 lines) + +| Property | Value | +|---|---| +| `dataDir` | `androidAppContext.dataDir` | +| `tmpDir` | `androidAppContext.getDir("temp", MODE_PRIVATE)` | +| `filesDir` | `dataDir/files` | +| `appFilesDir` | `dataDir/files/app_files` | +| `wallpapersDir` | `dataDir/files/assets/wallpapers` | +| `coreTmpDir` | `dataDir/files/temp_files` | +| `dbAbsolutePrefixPath` | `dataDir/files` | +| `preferencesDir` | `dataDir/shared_prefs` | +| `databaseExportDir` | `androidAppContext.cacheDir` | +| `remoteHostsDir` | `tmpDir/remote_hosts` | + +### Desktop implementation + +[`Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) (116 lines) + +| Property | Value | +|---|---| +| `dataDir` | `desktopPlatform.dataPath` (XDG_DATA_HOME on Linux, AppData on Windows, Application Support on macOS) | +| `tmpDir` | `java.io.tmpdir/simplex` (deleted on exit) | +| `filesDir` | `dataDir/simplex_v1_files` | +| `appFilesDir` | Same as `filesDir` | +| `wallpapersDir` | `dataDir/simplex_v1_assets/wallpapers` | +| `coreTmpDir` | `dataDir/tmp` | +| `dbAbsolutePrefixPath` | `dataDir/simplex_v1` | +| `preferencesDir` | `desktopPlatform.configPath` | +| `databaseExportDir` | Same as `tmpDir` | +| `remoteHostsDir` | `dataDir/remote_hosts` | + +### Helper functions (common) + +| Function | Line | Description | +|---|---|---| +| `getAppFilePath` | [L81](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L81) | Resolves file path considering remote hosts | +| `getWallpaperFilePath` | [L91](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L91) | Resolves wallpaper image path, creates parent directories | +| `getLoadedFilePath` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L105) | Returns path if file exists and is fully loaded | +| `getLoadedFileSource` | [L115](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L115) | Returns `CryptoFile` source if file is loaded | +| `readThemeOverrides` | [L125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125) | Reads theme overrides from `themes.yaml` | +| `writeThemeOverrides` | [L151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151) | Atomically writes theme overrides to `themes.yaml` | +| `copyFileToFile` | [L47](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L47) | Copies a `File` to a `URI` destination with toast feedback | +| `copyBytesToFile` | [L63](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L63) | Copies a `ByteArrayInputStream` to a `URI` destination | + +--- + +## 5. API Commands + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt): + +| Function | Line | Signature | Description | +|---|---|---|---| +| `receiveFiles` | [L1946](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1946) | `(rhId, user, fileIds, userApprovedRelays, auto)` | Receive multiple files. Sends `CC.ReceiveFile` for each ID. Handles relay approval workflow: collects unapproved files, shows alert, re-calls with `userApprovedRelays=true`. Respects `privacyEncryptLocalFiles` preference. | +| `receiveFile` | [L2062](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2062) | `(rhId, user, fileId, userApprovedRelays, auto)` | Delegates to `receiveFiles` with a single-element list. | +| `cancelFile` | [L2072](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2072) | `(rh, user, fileId)` | Cancels an in-progress file transfer (send or receive). Cleans up the local file. | +| `apiCancelFile` | [L2080](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2080) | `(rh, fileId, ctrl?)` | Low-level cancel. Returns `AChatItem?` on success (`SndFileCancelled` or `RcvFileCancelled`). | +| `uploadStandaloneFile` | [L1916](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1916) | `(user, file, ctrl?)` | Upload a standalone file (for database migration). Returns `FileTransferMeta?` with XFTP link. | +| `downloadStandaloneFile` | [L1926](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L1926) | `(user, url, file, ctrl?)` | Download a standalone file from an XFTP URL. Returns `RcvFileTransfer?`. | + +--- + +## 6. Auto-Receive Logic + +Located in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L2696) within the `CR.NewChatItems` handler: + +```kotlin +if (file != null && + appPrefs.privacyAcceptImages.get() && + ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV + && file.fileStatus !is CIFileStatus.RcvAccepted)) +) { + receiveFile(rhId, r.user, file.fileId, auto = true) +} +``` + +**Conditions for auto-receive:** + +1. The `privacyAcceptImages` preference is enabled (user opt-in). +2. The content type and size match one of: + - **Images** (`MCImage`): file size <= 510 KB (`MAX_IMAGE_SIZE_AUTO_RCV`) + - **Video** (`MCVideo`): file size <= 1023 KB (`MAX_VIDEO_SIZE_AUTO_RCV`) + - **Voice** (`MCVoice`): file size <= 510 KB (`MAX_VOICE_SIZE_AUTO_RCV`) AND file is not already accepted +3. The file has a non-null `file` attachment. + +When `auto = true`, relay approval alerts are suppressed (the file is silently received). + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `CryptoFile.kt` | [`common/src/commonMain/.../model/CryptoFile.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/CryptoFile.kt) | 62 | Encrypted file read/write via native crypto | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | Common file path declarations, theme I/O, file helpers | +| `Files.android.kt` | [`common/src/androidMain/.../platform/Files.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/platform/Files.android.kt) | 79 | Android file path implementations | +| `Files.desktop.kt` | [`common/src/desktopMain/.../platform/Files.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/platform/Files.desktop.kt) | 116 | Desktop file path implementations | +| `Utils.kt` | [`common/src/commonMain/.../views/helpers/Utils.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt) | -- | File size constants (L117--L128), `getMaxFileSize()` (L442) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | File transfer API commands (L1911--L2085), auto-receive (L2690) | diff --git a/apps/multiplatform/spec/services/notifications.md b/apps/multiplatform/spec/services/notifications.md new file mode 100644 index 0000000000..6ce4bc9dc1 --- /dev/null +++ b/apps/multiplatform/spec/services/notifications.md @@ -0,0 +1,261 @@ +# Notification System + +## Table of Contents + +1. [Overview](#1-overview) +2. [NtfManager Abstract Class](#2-ntfmanager-abstract-class) +3. [Android Notification Manager](#3-android-notification-manager) +4. [Desktop Notification Manager](#4-desktop-notification-manager) +5. [Android Background Messaging](#5-android-background-messaging) +6. [Notification Privacy](#6-notification-privacy) +7. [Source Files](#7-source-files) + +## Executive Summary + +SimpleX Chat uses platform-specific notification strategies. The common `NtfManager` abstract class defines the notification contract with shared helper methods for message, contact, and call notifications. Android implements a full notification system with channels, grouped summaries, full-screen call intents, and a foreground service (`SimplexService`) or periodic `WorkManager` tasks for background message fetching. Desktop uses the TwoSlices library (with OS-native fallbacks) for system notifications. Notification privacy is controlled via `NotificationPreviewMode` (MESSAGE, CONTACT, HIDDEN). + +--- + +## 1. Overview + +Notifications serve three purposes in SimpleX Chat: + +1. **Message notifications** -- alert users to new messages when the app is not focused on the relevant chat. +2. **Call notifications** -- high-priority alerts for incoming WebRTC calls, with full-screen intent support on Android for lock-screen scenarios. +3. **Contact events** -- notifications for contact connection and contact request events. + +The architecture uses an abstract `NtfManager` in common code with platform-specific `actual` implementations. On Android, background message delivery requires a foreground service or periodic WorkManager tasks since SimpleX does not use push notifications (no Firebase/APNs dependency for privacy). + +--- + + + + +## 2. NtfManager Abstract Class + +[`NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) (139 lines, commonMain) + +The global `ntfManager` instance is declared at [line 17](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L17) and initialized by each platform at startup. + +### Concrete methods + +| Method | Line | Description | +|---|---|---| +| `notifyContactConnected` | [L20](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L20) | Displays "contact connected" notification for a `Contact` | +| `notifyContactRequestReceived` | [L27](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L27) | Shows contact request notification with an "Accept" action button | +| `notifyMessageReceived` | [L38](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L38) | Conditionally shows message notification based on `ntfsEnabled`, `showNotification`, and whether user is viewing that chat | +| `acceptContactRequestAction` | [L51](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L51) | Accepts a contact request from a notification action | +| `openChatAction` | [L59](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L59) | Opens a specific chat from a notification tap, switching user if needed | +| `showChatsAction` | [L74](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L74) | Opens the chat list, switching user if needed | +| `acceptCallAction` | [L88](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L88) | Accepts a call invitation from a notification action | + +### Abstract methods + +| Method | Line | Description | +|---|---|---| +| `notifyCallInvitation` | [L98](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L98) | Show call notification; returns `true` if notification was shown | +| `displayNotification` | [L102](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L102) | Display a message notification with optional image and action buttons | +| `cancelCallNotification` | [L103](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L103) | Cancel the active call notification | +| `hasNotificationsForChat` | [L99](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L99) | Check if notifications exist for a given chat | +| `cancelNotificationsForChat` | [L100](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L100) | Cancel all notifications for a specific chat | +| `cancelNotificationsForUser` | [L101](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L101) | Cancel all notifications for a user profile | +| `cancelAllNotifications` | [L104](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L104) | Cancel all notifications | +| `showMessage` | [L105](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L105) | Show a simple title+text notification | +| `androidCreateNtfChannelsMaybeShowAlert` | [L107](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L107) | Android-only: create notification channels (triggers permission prompt on Android 13+) | + +### Private helpers + +- `awaitChatStartedIfNeeded` ([line 109](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L109)): Waits up to 30 seconds for chat initialization (handles database decryption delay). +- `hideSecrets` ([line 122](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt#L122)): Replaces `Format.Secret` formatted text with `"..."` in notification previews. + +--- + +## 3. Android Notification Manager + +[`NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) (331 lines) + +Implemented as a Kotlin `object` (singleton) in the Android module. + +### Notification channels + +| Channel | Constant | Importance | Purpose | +|---|---|---|---| +| Messages | `MessageChannel` (`chat.simplex.app.MESSAGE_NOTIFICATION`) | HIGH | All chat message notifications | +| Calls | `CallChannel` (`chat.simplex.app.CALL_NOTIFICATION_2`) | HIGH | Incoming call alerts with custom ringtone and vibration | + +Channel creation happens in `createNtfChannelsMaybeShowAlert()` ([line 298](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L298)). Old channel IDs (`CALL_NOTIFICATION`, `CALL_NOTIFICATION_1`, `LOCK_SCREEN_CALL_NOTIFICATION`) are explicitly deleted. + +### displayNotification (messages) + +[Line 102](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L102): + +- Uses `NotificationCompat.Builder` with `MessageChannel`. +- Groups notifications using `MessageGroup` with `GROUP_ALERT_CHILDREN` behavior. +- Applies rate limiting: silent mode if notification for the same `(userId, chatId)` was shown within 30 seconds (`msgNtfTimeoutMs`). +- Creates a group summary notification ([line 142](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L142)) with `setGroupSummary(true)`. +- Content intent uses `TaskStackBuilder` for proper back stack. +- Supports `NotificationAction.ACCEPT_CONTACT_REQUEST` action buttons via `NtfActionReceiver` broadcast receiver. + +### notifyCallInvitation + +[Line 160](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L160): + +- Returns `false` (no notification) if app is in foreground -- in-app alert is used instead. +- **Lock screen / screen off**: Uses `setFullScreenIntent` with a `PendingIntent` to `CallActivity`, plus `VISIBILITY_PUBLIC`. +- **Foreground / unlocked**: Uses regular notification with Accept/Reject action buttons and a custom ringtone (`ring_once` raw resource). +- Notification flags include `FLAG_INSISTENT` for repeating sound and vibration. +- Call notification channel vibration pattern: `[250, 250, 0, 2600]` ms. + +### Cancel operations + +| Method | Line | Description | +|---|---|---| +| `cancelNotificationsForChat` | [L75](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L75) | Cancels by `chatId.hashCode()`, cleans up group summary if no children remain | +| `cancelNotificationsForUser` | [L88](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L88) | Iterates and cancels all notifications for a given `userId` | +| `cancelCallNotification` | [L261](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L261) | Cancels the singleton call notification (`CallNotificationId = -1`) | +| `cancelAllNotifications` | [L265](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L265) | Cancels all via `NotificationManager.cancelAll()` | + +### NtfActionReceiver + +[Line 311](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt#L311): A `BroadcastReceiver` that handles notification action intents: +- `ACCEPT_CONTACT_REQUEST` -- calls `ntfManager.acceptContactRequestAction()` +- `RejectCallAction` -- calls `callManager.endCall()` on the invitation + +--- + +## 4. Desktop Notification Manager + +[`NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) (193 lines) + +Implemented as a Kotlin `object` using the [TwoSlices](https://github.com/sshtools/two-slices) library (`Toast` builder API) for cross-platform desktop notifications. + +### displayNotification + +[Line 97](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L97): + +- Suppresses if `!user.showNotifications`. +- Respects `NotificationPreviewMode` for title and content. +- Calls `displayNotificationViaLib()` ([line 114](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L114)) which builds a `Toast` with title, content, icon, action buttons, and default action. +- Icon images are written to a temporary PNG file via `prepareIconPath()` ([line 150](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L150)). +- Default action on click opens the relevant chat via `openChatAction()`. + +### notifyCallInvitation + +[Line 22](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L22): + +- Returns `false` if the SimpleX window is focused (in-app alert used instead). +- Creates a notification with Accept and Reject action buttons. +- Default click action opens the chat. + +### OS-native fallbacks + +[Line 162](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt#L162): The `displayNotification` private method dispatches based on `desktopPlatform`: + +| Platform | Method | +|---|---| +| Linux | `notify-send` command with optional `-i` icon | +| Windows | `SystemTray` with `TrayIcon.displayMessage()` | +| macOS | `osascript -e 'display notification ...'` | + +### Notification tracking + +Previous notifications are tracked in `prevNtfs: ArrayList, Slice>>` with a `Mutex` for thread safety. Cancel operations remove entries from this list. + +--- + +## 5. Android Background Messaging + +### 5.1 SimplexService.kt (734 lines) + +[`SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) + +A foreground `Service` that keeps the app process alive for continuous message receiving. This is SimpleX's privacy-preserving alternative to push notifications. + +**Service lifecycle:** + +- `startService()` ([line 128](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L128)): Waits for database migration, validates DB status, saves service state as STARTED. WakeLock acquisition is commented out -- the app relies on battery optimization whitelisting instead. +- `onDestroy()` ([line 87](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L87)): Releases wakelocks, saves state as STOPPED, sends broadcast to `AutoRestartReceiver` if allowed. +- `onTaskRemoved()` ([line 211](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L211)): Schedules restart via `AlarmManager` when the app is swiped from recents. + +**Notification:** + +- Channel: `SIMPLEX_SERVICE_NOTIFICATION` with `IMPORTANCE_LOW` and badge disabled ([line 165](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L165)). +- Shows a persistent notification with a "Hide notification" action that opens channel settings. +- Service ID: `6789`. + +**Restart mechanisms:** + +| Receiver | Line | Trigger | +|---|---|---| +| `StartReceiver` | [L234](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L234) | Device boot (`BOOT_COMPLETED`) | +| `AutoRestartReceiver` | [L253](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L253) | Service destruction | +| `AppUpdateReceiver` | [L261](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L261) | App update (`MY_PACKAGE_REPLACED`) | +| `ServiceStartWorker` | [L283](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L283) | WorkManager one-time task | + +**Battery optimization:** + +- `isBackgroundAllowed()` ([line 681](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L681)): Checks both `isIgnoringBatteryOptimizations` and `!isBackgroundRestricted`. +- `showBackgroundServiceNoticeIfNeeded()` ([line 430](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L430)): Shows alerts guiding users to disable battery optimization or background restriction. Includes Xiaomi-specific guidance. +- `disableNotifications()` ([line 722](../../android/src/main/java/chat/simplex/app/SimplexService.kt#L722)): Switches mode to OFF, disables receivers, cancels workers. + +### 5.2 MessagesFetcherWorker.kt (100 lines) + +[`MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) + +A `CoroutineWorker` used in `PERIODIC` notification mode as an alternative to the persistent foreground service: + +- `scheduleWork()` ([line 18](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L18)): Schedules a `OneTimeWorkRequest` with a default 600-second (10 minute) initial delay and 60-second duration. Requires `NetworkType.CONNECTED` constraint. +- `doWork()` ([line 53](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt#L53)): Skips if `SimplexService` is already running. Initializes chat controller if needed (self-destruct mode). Waits for DB migration. Runs for up to `durationSec` seconds, polling every 5 seconds until no messages have been received for 10 seconds (`WAIT_AFTER_LAST_MESSAGE`). +- Self-rescheduling: Always calls `reschedule()` at the end (creating a chain of one-time tasks that simulate periodic execution). + + + +### 5.3 Notification modes + +Defined in [`SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L7739): + +```kotlin +enum class NotificationsMode { + OFF, // No background message fetching + PERIODIC, // WorkManager periodic tasks (MessagesFetcherWorker) + SERVICE; // Persistent foreground service (SimplexService) +} +``` + +Default is `SERVICE`. The `requiresIgnoringBattery` property is an Android extension property (defined in `Extensions.kt`, not on the enum itself) whose value depends on the SDK version: `SERVICE` requires ignoring battery optimizations since SDK S (API 31), `PERIODIC` since SDK M (API 23). + +--- + +## 6. Notification Privacy + +Defined in [`ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L4823): + +```kotlin +enum class NotificationPreviewMode { + MESSAGE, // Show sender name and message text + CONTACT, // Show sender name, generic "new message" text + HIDDEN; // Show "Somebody" as sender, generic "new message" text +} +``` + +Privacy mode affects: +- **Notification title**: `HIDDEN` uses `"Somebody"` instead of contact name. +- **Notification content**: Only `MESSAGE` mode shows actual message text. +- **Large icon**: `HIDDEN` uses the app icon instead of the contact's profile image. +- **Call notifications**: `HIDDEN` hides the caller's name and profile image. + +Both Android and Desktop implementations check `appPreferences.notificationPreviewMode.get()` before constructing notification content. + +--- + +## 7. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `NtfManager.kt` | [`common/src/commonMain/.../platform/NtfManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/NtfManager.kt) | 139 | Abstract notification manager with shared logic | +| `NtfManager.android.kt` | [`android/src/main/java/.../model/NtfManager.android.kt`](../../android/src/main/java/chat/simplex/app/model/NtfManager.android.kt) | 331 | Android notification channels, groups, call intents | +| `NtfManager.desktop.kt` | [`common/src/desktopMain/.../model/NtfManager.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/model/NtfManager.desktop.kt) | 193 | Desktop notifications via TwoSlices/OS-native | +| `SimplexService.kt` | [`android/src/main/java/.../SimplexService.kt`](../../android/src/main/java/chat/simplex/app/SimplexService.kt) | 734 | Android foreground service for background messaging | +| `MessagesFetcherWorker.kt` | [`android/src/main/java/.../MessagesFetcherWorker.kt`](../../android/src/main/java/chat/simplex/app/MessagesFetcherWorker.kt) | 100 | WorkManager periodic message fetcher | +| `ChatModel.kt` | [`common/src/commonMain/.../model/ChatModel.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | -- | `NotificationPreviewMode` enum (L4823) | +| `SimpleXAPI.kt` | [`common/src/commonMain/.../model/SimpleXAPI.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | -- | `NotificationsMode` enum (L7739) | diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md new file mode 100644 index 0000000000..e5839fc193 --- /dev/null +++ b/apps/multiplatform/spec/services/theme.md @@ -0,0 +1,498 @@ +# Theme Engine + +## Table of Contents + +1. [Overview](#1-overview) +2. [ThemeManager](#2-thememanager) +3. [Default Themes](#3-default-themes) +4. [Theme Types](#4-theme-types) +5. [Color System](#5-color-system) +6. [SimpleXTheme Composable](#6-simplextheme-composable) +7. [Platform Theme](#7-platform-theme) +8. [YAML Import/Export](#8-yaml-importexport) +9. [Source Files](#9-source-files) + +## Executive Summary + +The SimpleX Chat theme engine implements a four-level cascade: per-chat theme overrides take precedence over per-user overrides, which take precedence over global (app-settings) overrides, which take precedence over built-in presets. Four preset themes exist (LIGHT, DARK, SIMPLEX, BLACK), each defining a Material `Colors` palette and custom `AppColors` for chat-specific elements. Themes support wallpaper customization (preset patterns or custom images) with background and tint color overrides. Theme configuration is persisted as YAML and can be imported/exported. The `SimpleXTheme` composable wraps `MaterialTheme` with additional `CompositionLocal` providers for app colors and wallpaper. + +--- + +## 1. Overview + +Theme resolution follows a priority chain: + +``` +per-chat override > per-user override > global override > preset default +``` + +At each level, individual color properties can be overridden. Unspecified properties fall through to the next level. The resolution is performed by `ThemeManager.currentColors()`, which merges all levels into a single `ActiveTheme` containing Material `Colors`, `AppColors`, and `AppWallpaper`. + +Wallpapers follow the same cascade, with additional support for preset wallpapers (built-in patterns like `SCHOOL`) and custom images. Wallpaper presets can define their own color overrides that sit between the global override and the base preset. + +--- + +## 2. ThemeManager + +[`ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) (241 lines) + +A singleton `object` that manages theme state, persistence, and resolution. + +### Core resolution + + + +**`currentColors()`** ([line 57](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L57)): + +```kotlin +fun currentColors( + themeOverridesForType: WallpaperType?, + perChatTheme: ThemeModeOverride?, + perUserTheme: ThemeModeOverrides?, + appSettingsTheme: List +): ActiveTheme +``` + +This is the core resolution function. It: +1. Determines the non-system theme name (resolving `SYSTEM` to light or dark based on `systemInDarkThemeCurrently`). +2. Selects the base theme palette (LIGHT/DARK/SIMPLEX/BLACK). +3. Finds the matching `ThemeOverrides` from `appSettingsTheme` based on wallpaper type and theme name. +4. Selects the `perUserTheme` for the current light/dark mode. +5. Resolves wallpaper preset colors if applicable. +6. Merges all color layers via `toColors()`, `toAppColors()`, and `toAppWallpaper()`. + +Returns `ActiveTheme(name, base, colors, appColors, wallpaper)`. + +### Theme application + +**`applyTheme()`** ([line 105](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L105)): + +Persists the theme name, recalculates `CurrentColors`, and updates Android system bar appearance: + +```kotlin +fun applyTheme(theme: String) { + if (appPrefs.currentTheme.get() != theme) { + appPrefs.currentTheme.set(theme) + } + CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + platform.androidSetNightModeIfSupported() + val c = CurrentColors.value.colors + platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight) +} +``` + +**`changeDarkTheme()`** ([line 115](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L115)): + +Sets the dark mode variant (DARK, SIMPLEX, or BLACK) and recalculates colors. + +### Color and wallpaper modification + +**`saveAndApplyThemeColor()`** ([line 120](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L120)): + +Persists a single color change to the global theme overrides: +1. Gets or creates `ThemeOverrides` for the current base theme. +2. Calls `withUpdatedColor()` to update the specific `ThemeColor`. +3. Updates `currentThemeIds` mapping. +4. Recalculates `CurrentColors`. + +**`applyThemeColor()`** ([line 132](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L132)): + +In-memory-only color change (for per-chat/per-user theme editing before save). + +**`saveAndApplyWallpaper()`** ([line 136](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L136)): + +Persists wallpaper type change. Finds or creates matching `ThemeOverrides` (matching by wallpaper type + theme name), updates the wallpaper, and persists. + +### Reset + +**`resetAllThemeColors()` (global)** ([line 204](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L204)): + +Resets all custom colors in the current global theme override to defaults. Preserves wallpaper but clears its background and tint overrides. + +**`resetAllThemeColors()` (per-chat/per-user)** ([line 213](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L213)): + +In-memory reset of a `ThemeModeOverride` state. + +### Import/Export + +**`saveAndApplyThemeOverrides()`** ([line 188](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L188)): + +Imports a complete `ThemeOverrides` (from YAML). Handles wallpaper image import (base64 to file), replaces existing override for the same type, and applies. + +**`currentThemeOverridesForExport()`** ([line 92](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L92)): + +Exports the fully resolved current theme as a `ThemeOverrides` with all colors filled and wallpaper image embedded as base64. + +### Utility + +**`colorFromReadableHex()`** ([line 224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L224)): + +Parses `#AARRGGBB` hex string to `Color`. + +**`toReadableHex()`** ([line 227](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt#L227)): + +Converts `Color` to `#AARRGGBB` hex string with intelligent alpha handling. + +--- + + + +## 3. Default Themes + +[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L26): + +```kotlin +enum class DefaultTheme { + LIGHT, DARK, SIMPLEX, BLACK; + + companion object { + const val SYSTEM_THEME_NAME: String = "SYSTEM" + } +} +``` + +| Theme | `mode` | Description | +|---|---|---| +| `LIGHT` | LIGHT | Standard light theme with white/light gray surfaces | +| `DARK` | DARK | Standard dark theme with dark gray surfaces | +| `SIMPLEX` | DARK | SimpleX branded dark theme with deep blue background and cyan accent | +| `BLACK` | DARK | AMOLED-optimized pure black theme | + +`SYSTEM` is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime. + +`DefaultThemeMode` ([line 46](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L46)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. + +--- + +## 4. Theme Types + + + +### AppColors (line 53) + +[`Theme.kt` L53](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L53): + +```kotlin +@Stable +class AppColors( + title: Color, + primaryVariant2: Color, + sentMessage: Color, + sentQuote: Color, + receivedMessage: Color, + receivedQuote: Color, +) +``` + +Mutable state properties (for efficient recomposition) representing chat-specific colors not covered by Material's `Colors`. + + + +### AppWallpaper (line 106) + +[`Theme.kt` L106](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L106): + +```kotlin +@Stable +class AppWallpaper( + background: Color? = null, + tint: Color? = null, + type: WallpaperType = WallpaperType.Empty, +) +``` + +Represents the active wallpaper state with optional background color, tint overlay, and wallpaper type (Empty, Preset, or Image). + + + +### ThemeColor (line 140) + +Enum of all customizable color slots: + +`PRIMARY`, `PRIMARY_VARIANT`, `SECONDARY`, `SECONDARY_VARIANT`, `BACKGROUND`, `SURFACE`, `TITLE`, `SENT_MESSAGE`, `SENT_QUOTE`, `RECEIVED_MESSAGE`, `RECEIVED_QUOTE`, `PRIMARY_VARIANT2`, `WALLPAPER_BACKGROUND`, `WALLPAPER_TINT` + +Each has a `fromColors()` method to extract the current value and a `text` property for UI display. + + + +### ThemeColors (line 183) + +[`Theme.kt` L183](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L183): + +Serializable data class with optional hex color strings for each slot. Uses `@SerialName` annotations for YAML compatibility (`accent` for `primary`, `accentVariant` for `primaryVariant`, `menus` for `surface`, etc.). + + + +### ThemeWallpaper (line 224) + +[`Theme.kt` L224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L224): + +```kotlin +@Serializable +data class ThemeWallpaper( + val preset: String? = null, // Preset wallpaper name + val scale: Float? = null, // Wallpaper scale factor + val scaleType: WallpaperScaleType? = null, // Fill/fit mode + val background: String? = null, // Background color hex + val tint: String? = null, // Tint overlay color hex + val image: String? = null, // Base64-encoded image (for import/export) + val imageFile: String? = null, // Local image file name +) +``` + +Key methods: +- `toAppWallpaper()`: Converts to runtime `AppWallpaper`. +- `withFilledWallpaperBase64()`: Embeds the image as base64 for export. +- `importFromString()`: Saves a base64 image to disk and returns a copy with `imageFile` set. +- `from(type, background, tint)`: Factory from `WallpaperType`. + + + +### ThemeOverrides (line 304) + +[`Theme.kt` L304](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L304): + +```kotlin +@Serializable +data class ThemeOverrides( + val themeId: String = UUID.randomUUID().toString(), + val base: DefaultTheme, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A complete theme override entry. Multiple can coexist (one per wallpaper type per base theme). The `themeId` is a UUID for identity tracking. Key methods: +- `isSame(type, themeName)`: Matches by wallpaper type and base theme. +- `withUpdatedColor(name, color)`: Returns a copy with one color changed. +- `toColors()`, `toAppColors()`, `toAppWallpaper()`: Merge with base theme and per-user/per-chat overrides. + + + +### ThemeModeOverrides (line 475) + +[`Theme.kt` L475](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L475): + +```kotlin +@Serializable +data class ThemeModeOverrides( + val light: ThemeModeOverride? = null, + val dark: ThemeModeOverride? = null, +) +``` + +Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the `User` model as `uiThemes`. + + + +### ThemeModeOverride (line 487) + +[`Theme.kt` L487](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L487): + +```kotlin +@Serializable +data class ThemeModeOverride( + val mode: DefaultThemeMode = CurrentColors.value.base.mode, + val colors: ThemeColors = ThemeColors(), + val wallpaper: ThemeWallpaper? = null, +) +``` + +A single mode's override with colors and wallpaper. Has `withUpdatedColor()` and `removeSameColors()` (strips colors that match base defaults). + +--- + +## 5. Color System + +Four built-in color palettes, each consisting of a Material `Colors` and an `AppColors`: + +### DarkColorPalette ([line 634](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L634)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `#222222` | | +| `sentMessage` | `#18262E` | Dark blue-gray | +| `receivedMessage` | `#262627` | Neutral dark | + +### LightColorPalette ([line 656](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L656)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `SimplexBlue` | `#0088ff` | +| `surface` | `White` | | +| `sentMessage` | `#E9F7FF` | Light blue | +| `receivedMessage` | `#F5F5F6` | Near-white | + +### SimplexColorPalette ([line 678](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L678)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#70F0F9` | Cyan | +| `primaryVariant` | `#1298A5` | Dark cyan | +| `background` | `#111528` | Deep navy | +| `surface` | `#121C37` | Dark navy | +| `title` | `#267BE5` | Blue | + +### BlackColorPalette ([line 701](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L701)) + +| Property | Value | Notes | +|---|---|---| +| `primary` | `#0077E0` | Darker blue | +| `background` | `#070707` | Near-black | +| `surface` | `#161617` | Very dark | +| `sentMessage` | `#18262E` | Same as Dark | +| `receivedMessage` | `#1B1B1B` | Very dark | + +--- + + + +## 6. SimpleXTheme Composable + +[`Theme.kt` line 773](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L773): + +```kotlin +@Composable +fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) +``` + +The root theme composable that wraps all app content: + +1. **System dark mode tracking** ([line 781](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L781)): Uses `snapshotFlow` on `isSystemInDarkTheme()` to call `reactOnDarkThemeChanges()` when the system theme changes. This triggers `ThemeManager.applyTheme(SYSTEM)` if the app is in system theme mode. + +2. **User theme tracking** ([line 790](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L790)): Monitors `chatModel.currentUser.value?.uiThemes` and re-applies the theme when the active user changes. + +3. **MaterialTheme wrapping** ([line 797](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L797)): Provides `theme.colors` to `MaterialTheme`, plus custom `CompositionLocal` providers: + - `LocalContentColor` -- set to `MaterialTheme.colors.onBackground` + - `LocalAppColors` -- the `AppColors` instance (remembered and updated) + - `LocalAppWallpaper` -- the `AppWallpaper` instance (remembered and updated) + - `LocalDensity` -- scaled by `desktopDensityScaleMultiplier` and `fontSizeMultiplier` + +4. **`SimpleXThemeOverride`** ([line 825](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L825)): A variant that accepts an explicit `ActiveTheme` for per-chat theme previews and overlays. + +### CompositionLocal access + +```kotlin +val MaterialTheme.appColors: AppColors // via LocalAppColors +val MaterialTheme.wallpaper: AppWallpaper // via LocalAppWallpaper +``` + +### Global state + + + +`CurrentColors` ([line 727](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L727)): A `MutableStateFlow` that holds the current resolved theme. Updated by `ThemeManager.applyTheme()` and collected by `SimpleXTheme`. + +`systemInDarkThemeCurrently` ([line 724](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L724)): Tracks the current system dark mode state. + +--- + +## 7. Platform Theme + +### isSystemInDarkTheme + +**Android** ([`Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt)): + +```kotlin +@Composable +actual fun isSystemInDarkTheme(): Boolean = androidx.compose.foundation.isSystemInDarkTheme() +``` + +Delegates to the standard Compose function which reads `Configuration.uiMode`. + +**Desktop** ([`Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt)): + +```kotlin +private val detector: OsThemeDetector = OsThemeDetector.getDetector() + .apply { registerListener(::reactOnDarkThemeChanges) } + +@Composable +actual fun isSystemInDarkTheme(): Boolean = try { + detector.isDark +} catch (e: Exception) { + false // Fallback for macOS exceptions +} +``` + +Uses the [jSystemThemeDetector](https://github.com/Dansoftowner/jSystemThemeDetector) library (`OsThemeDetector`). The detector also registers a listener that calls `reactOnDarkThemeChanges()` proactively when the OS theme changes, ensuring the app responds even outside of composition. + +### reactOnDarkThemeChanges + +[`Theme.kt` line 763](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L763): + +```kotlin +fun reactOnDarkThemeChanges(isDark: Boolean) { + systemInDarkThemeCurrently = isDark + if (appPrefs.currentTheme.get() == DefaultTheme.SYSTEM_THEME_NAME + && CurrentColors.value.colors.isLight == isDark) { + ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME) + } +} +``` + +Only triggers a theme switch if the app is in SYSTEM mode and the current light/dark state disagrees with the OS. + +--- + +## 8. YAML Import/Export + +Theme overrides are persisted in `themes.yaml` (located in `preferencesDir`). + +### readThemeOverrides + +[`Files.kt` line 125](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L125): + +```kotlin +fun readThemeOverrides(): List +``` + +1. Reads `themes.yaml` from `preferencesDir`. +2. Parses the YAML node tree. +3. Extracts the `themes` list. +4. Deserializes each entry as `ThemeOverrides`, skipping entries that fail to parse (with error logging). +5. Calls `skipDuplicates()` to remove entries with the same type+base combination. + +### writeThemeOverrides + +[`Files.kt` line 151](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt#L151): + +```kotlin +fun writeThemeOverrides(overrides: List): Boolean +``` + +1. Serializes `ThemesFile(themes = overrides)` to YAML string. +2. Writes to a temporary file in `preferencesTmpDir`. +3. Atomically moves the temp file to `themes.yaml` using `Files.move` with `REPLACE_EXISTING`. +4. Thread-safe via `synchronized(lock)`. + +### YAML format + +```yaml +themes: + - themeId: "uuid-string" + base: "LIGHT" + colors: + accent: "#ff0088ff" + background: "#ffffffff" + sentMessage: "#ffe9f7ff" + wallpaper: + preset: "school" + scale: 1.0 + background: "#ccffffff" + tint: "#22000000" +``` + +Uses the [kaml](https://github.com/charleskorn/kaml) YAML library for serialization. `ThemeColors` uses `@SerialName` annotations for cross-platform YAML key compatibility (e.g., `accent` for `primary`, `menus` for `surface`). + +--- + +## 9. Source Files + +| File | Path | Lines | Description | +|---|---|---|---| +| `ThemeManager.kt` | [`common/src/commonMain/.../ui/theme/ThemeManager.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/ThemeManager.kt) | 241 | Theme resolution, persistence, color/wallpaper management | +| `Theme.kt` | [`common/src/commonMain/.../ui/theme/Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt) | 848 | Type definitions, color palettes, `SimpleXTheme` composable | +| `Theme.android.kt` | [`common/src/androidMain/.../ui/theme/Theme.android.kt`](../../common/src/androidMain/kotlin/chat/simplex/common/ui/theme/Theme.android.kt) | 6 | Android `isSystemInDarkTheme` | +| `Theme.desktop.kt` | [`common/src/desktopMain/.../ui/theme/Theme.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/ui/theme/Theme.desktop.kt) | 25 | Desktop `isSystemInDarkTheme` via OsThemeDetector | +| `Files.kt` | [`common/src/commonMain/.../platform/Files.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/platform/Files.kt) | 191 | `readThemeOverrides()` (L125), `writeThemeOverrides()` (L151) | diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md new file mode 100644 index 0000000000..900d6593ab --- /dev/null +++ b/apps/multiplatform/spec/state.md @@ -0,0 +1,486 @@ +# State Management + +## Table of Contents + +1. [Overview](#1-overview) +2. [ChatModel](#2-chatmodel) +3. [ChatsContext](#3-chatscontext) +4. [Chat](#4-chat) +5. [AppPreferences](#5-apppreferences) +6. [Source Files](#6-source-files) + +--- + +## 1. Overview + +SimpleX Chat uses a **singleton-based, Compose-reactive state model**. The primary state holder is `ChatModel`, a Kotlin `object` annotated with `@Stable`. All mutable fields are Compose `MutableState`, `MutableStateFlow`, or `SnapshotStateList`/`SnapshotStateMap` instances, which trigger Compose recomposition on mutation. + +There is no ViewModel layer, no dependency injection framework, and no Redux/MVI pattern. The architecture is: + +``` +ChatModel (singleton, global Compose state) + | + +-- ChatController (command dispatch + event processing) + | | + | +-- sendCmd() -> chatSendCmdRetry() [JNI] + | +-- recvMsg() -> chatRecvMsgWait() [JNI] + | +-- processReceivedMsg() -> mutates ChatModel fields + | + +-- AppPreferences (150+ SharedPreferences via multiplatform-settings) + | + +-- ChatsContext (primary) -- chat list + current chat items + +-- ChatsContext? (secondary) -- optional second context for dual-pane/support chat +``` + +State mutations originate from two sources: +1. **User actions**: Compose UI handlers call `api*()` suspend functions on `ChatController`, which send commands to the Haskell core, receive responses, and update `ChatModel`. +2. **Core events**: The receiver coroutine (`startReceiver`) calls `processReceivedMsg()`, which updates `ChatModel` fields on `Dispatchers.Main`. + +--- + + + +## 2. ChatModel + +Defined at [`ChatModel.kt line 86`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L86) as `@Stable object ChatModel`. + +### Controller Reference + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`controller`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L87) | `ChatController` | 87 | Reference to the `ChatController` singleton | + +### User State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`currentUser`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L89) | `MutableState` | 89 | Currently active user profile | +| [`users`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L90) | `SnapshotStateList` | 90 | All user profiles (multi-account) | +| [`localUserCreated`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L91) | `MutableState` | 91 | Whether a local user has been created (null = unknown during init) | +| [`setDeliveryReceipts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L88) | `MutableState` | 88 | Trigger for delivery receipts setup dialog | +| [`switchingUsersAndHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L100) | `MutableState` | 100 | True while switching active user/remote host | +| [`changingActiveUserMutex`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L193) | `Mutex` | 193 | Prevents concurrent user switches | + +### Chat Runtime State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatRunning`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L92) | `MutableState` | 92 | `null` = initializing, `true` = running, `false` = stopped | +| [`chatDbChanged`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L93) | `MutableState` | 93 | Database was changed externally (needs restart) | +| [`chatDbEncrypted`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L94) | `MutableState` | 94 | Whether database is encrypted | +| [`chatDbStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L95) | `MutableState` | 95 | Result of database migration attempt | +| [`ctrlInitInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L96) | `MutableState` | 96 | Controller initialization in progress | +| [`dbMigrationInProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L97) | `MutableState` | 97 | Database migration in progress | +| [`incompleteInitializedDbRemoved`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L98) | `MutableState` | 98 | Tracks if incomplete DB files were removed (prevents infinite retry) | + +### Current Chat State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`chatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L103) | `MutableState` | 103 | ID of the currently open chat (null = chat list shown) | +| [`chatAgentConnId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L104) | `MutableState` | 104 | Agent connection ID for current chat | +| [`chatSubStatus`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L105) | `MutableState` | 105 | Subscription status for current chat | +| [`openAroundItemId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L106) | `MutableState` | 106 | Item ID to scroll to when opening chat | +| [`chatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L107) | `ChatsContext` | 107 | Primary chat context (see [ChatsContext](#3-chatscontext)) | +| [`secondaryChatsContext`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L108) | `MutableState` | 108 | Optional secondary context for dual-pane views | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L110) | `State>` | 110 | Derived from `chatsContext.chats` | +| [`deletedChats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L112) | `MutableState>>` | 112 | Recently deleted chats (rhId, chatId) | + +### Group Members + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`groupMembers`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L113) | `MutableState>` | 113 | Members of currently viewed group | +| [`groupMembersIndexes`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L114) | `MutableState>` | 114 | Index lookup by `groupMemberId` | +| [`membersLoaded`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L115) | `MutableState` | 115 | Whether group members have been loaded | + +### Chat Tags and Filters + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L118) | `MutableState>` | 118 | User-defined chat tags | +| [`activeChatTagFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L119) | `MutableState` | 119 | Currently active filter in chat list | +| [`presetTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L120) | `SnapshotStateMap` | 120 | Counts for preset tag categories (favorites, groups, contacts, etc.) | +| [`unreadTags`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L121) | `SnapshotStateMap` | 121 | Unread counts per user-defined tag | + +### Terminal and Developer + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`terminalsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L125) | `Set` | 125 | Tracks which terminal views are visible (default vs floating) | +| [`terminalItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L126) | `MutableState>` | 126 | Command/response log for developer terminal | + +### Calls (WebRTC) + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`callManager`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L161) | `CallManager` | 161 | WebRTC call lifecycle manager | +| [`callInvitations`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L162) | `SnapshotStateMap` | 162 | Pending incoming call invitations keyed by chatId | +| [`activeCallInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L163) | `MutableState` | 163 | Currently displayed incoming call invitation | +| [`activeCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L164) | `MutableState` | 164 | Currently active call | +| [`activeCallViewIsVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L165) | `MutableState` | 165 | Whether call UI is showing | +| [`activeCallViewIsCollapsed`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L166) | `MutableState` | 166 | Whether call UI is in PiP/collapsed mode | +| [`callCommand`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L167) | `SnapshotStateList` | 167 | Pending WebRTC commands | +| [`showCallView`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L168) | `MutableState` | 168 | Call view visibility toggle | +| [`switchingCall`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L169) | `MutableState` | 169 | True during call switching | + +### Compose Draft and Sharing + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`draft`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L176) | `MutableState` | 176 | Saved compose draft for current chat | +| [`draftChatId`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L177) | `MutableState` | 177 | Chat ID the draft belongs to | +| [`sharedContent`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L180) | `MutableState` | 180 | Content received via share intent or internal forwarding | + +### Remote Hosts + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`remoteHosts`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L199) | `SnapshotStateList` | 199 | Connected remote hosts (for desktop-mobile pairing) | +| [`currentRemoteHost`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L200) | `MutableState` | 200 | Currently selected remote host | +| [`remoteHostPairing`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L203) | `MutableState?>` | 203 | Remote host pairing state | +| [`remoteCtrlSession`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L204) | `MutableState` | 204 | Remote controller session | + +### Miscellaneous UI State + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`userAddress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L127) | `MutableState` | 127 | User's public contact address | +| [`chatItemTTL`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L128) | `MutableState` | 128 | Chat item time-to-live setting | +| [`clearOverlays`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L131) | `MutableState` | 131 | Signal to close all overlays/modals | +| [`appOpenUrl`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L137) | `MutableState?>` | 137 | URL opened via deep link (rhId, uri) | +| [`appOpenUrlConnecting`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L138) | `MutableState` | 138 | Whether a deep link connection is in progress | +| [`newChatSheetVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L141) | `MutableState` | 141 | Whether new chat bottom sheet is visible | +| [`fullscreenGalleryVisible`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L144) | `MutableState` | 144 | Fullscreen gallery mode | +| [`notificationPreviewMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L147) | `MutableState` | 147 | Notification content preview level | +| [`showAuthScreen`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L156) | `MutableState` | 156 | Whether to show authentication screen | +| [`showChatPreviews`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L158) | `MutableState` | 158 | Whether to show chat preview text in list | +| [`clipboardHasText`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L185) | `MutableState` | 185 | System clipboard has text content | +| [`networkInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L186) | `MutableState` | 186 | Network type and online status | +| [`conditions`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L188) | `MutableState` | 188 | Server operator terms/conditions | +| [`updatingProgress`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L190) | `MutableState` | 190 | Progress indicator for app updates | +| [`simplexLinkMode`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L183) | `MutableState` | 183 | How SimpleX links are displayed | +| [`migrationState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L174) | `MutableState` | 174 | Database migration to new device state | +| [`showingInvitation`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L172) | `MutableState` | 172 | Currently displayed invitation | +| [`desktopOnboardingRandomPassword`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L134) | `MutableState` | 134 | Desktop: user skipped password setup | +| [`filesToDelete`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L182) | `MutableSet` | 182 | Temporary files pending cleanup | + +--- + + + +## 3. ChatsContext + +Defined as inner class at [`ChatModel.kt line 339`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339): + +```kotlin +class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) +``` + +`ChatsContext` holds the chat list and current chat items for a given context. The `ChatModel` maintains a **primary** context (`chatsContext` at line 107) and an optional **secondary** context (`secondaryChatsContext` at line 108). + +The secondary context is used for: +- **Group support chat scope** (`SecondaryContextFilter.GroupChatScopeContext`) -- viewing member support threads alongside the main group chat +- **Message content tag filtering** (`SecondaryContextFilter.MsgContentTagContext`) -- filtering messages by content type + +### Fields + +| Field | Type | Line | Purpose | +|---|---|---|---| +| [`secondaryContextFilter`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L339) | `SecondaryContextFilter?` | 339 | Filter type: null = primary, GroupChatScope or MsgContentTag | +| [`chats`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L340) | `MutableState>` | 340 | List of all chats in this context | +| [`chatItems`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L345) | `MutableState>` | 345 | Items for the currently open chat in this context | +| [`chatState`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L347) | `ActiveChatState` | 347 | Tracks unread counts, splits, scroll state | + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| [`contentTag`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L353) | 353 | `MsgContentTag?` -- content filter tag if context is MsgContentTag | +| [`groupScopeInfo`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L360) | 360 | `GroupChatScopeInfo?` -- group scope if context is GroupChatScope | +| [`isUserSupportChat`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L367) | 367 | True when viewing own support chat (no specific member) | + +### Key Operations + +- `addChat(chat)` -- adds chat at index 0, triggers pop animation +- `reorderChat(chat, toIndex)` -- reorders chat list (e.g., when a chat receives a new message) +- `updateChatInfo(rhId, cInfo)` -- updates chat metadata while preserving connection stats +- `hasChat(rhId, id)` / `getChat(id)` -- lookup methods + +### ActiveChatState + +Defined at [`ChatItemsMerger.kt line 196`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt#L196): + +```kotlin +data class ActiveChatState( + val splits: MutableStateFlow> = MutableStateFlow(emptyList()), + val unreadAfterItemId: MutableStateFlow = MutableStateFlow(-1L), + val totalAfter: MutableStateFlow = MutableStateFlow(0), + val unreadTotal: MutableStateFlow = MutableStateFlow(0), + val unreadAfter: MutableStateFlow = MutableStateFlow(0), + val unreadAfterNewestLoaded: MutableStateFlow = MutableStateFlow(0) +) +``` + +This tracks the scroll position and unread item accounting for the lazy-loaded chat item list: + +| Field | Purpose | +|---|---| +| `splits` | List of item IDs where pagination gaps exist (items not yet loaded) | +| `unreadAfterItemId` | The item ID that marks the boundary of "read" vs "unread after" | +| `totalAfter` | Total items after the unread boundary | +| `unreadTotal` | Total unread items in the chat | +| `unreadAfter` | Unread items after the boundary (exclusive) | +| `unreadAfterNewestLoaded` | Unread items after the newest loaded batch | + +--- + + + +## 4. Chat + +Defined at [`ChatModel.kt line 1328`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1328): + +```kotlin +@Serializable @Stable +data class Chat( + val remoteHostId: Long?, + val chatInfo: ChatInfo, + val chatItems: List, + val chatStats: ChatStats = ChatStats() +) +``` + +### Fields + +| Field | Type | Purpose | +|---|---|---| +| `remoteHostId` | `Long?` | Remote host ID (null = local) | +| `chatInfo` | `ChatInfo` | Sealed class: `Direct`, `Group`, `Local`, `ContactRequest`, `ContactConnection`, `InvalidJSON` | +| `chatItems` | `List` | Latest chat items (summary; full list is in `ChatsContext.chatItems`) | +| `chatStats` | `ChatStats` | Unread counts and stats | + + + +### ChatStats + +Defined at [`ChatModel.kt line 1370`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1370): + +```kotlin +data class ChatStats( + val unreadCount: Int = 0, + val unreadMentions: Int = 0, + val reportsCount: Int = 0, + val minUnreadItemId: Long = 0, + val unreadChat: Boolean = false +) +``` + +### Derived Properties + +| Property | Line | Purpose | +|---|---|---| +| `id` | 1349 | Chat ID derived from `chatInfo.id` | +| `unreadTag` | 1343 | Whether chat counts as "unread" for tag filtering (considers notification settings) | +| `supportUnreadCount` | 1351 | Unread count in support/moderation context | +| `nextSendGrpInv` | 1337 | Whether next message should send group invitation | + + + +### ChatInfo Variants + +`ChatInfo` is a sealed class at [`ChatModel.kt line 1391`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1391): + +| Variant | SerialName | Key Data | +|---|---|---| +| `ChatInfo.Direct` | `"direct"` | `contact: Contact` | +| `ChatInfo.Group` | `"group"` | `groupInfo: GroupInfo, groupChatScope: GroupChatScopeInfo?` | +| `ChatInfo.Local` | `"local"` | `noteFolder: NoteFolder` | +| `ChatInfo.ContactRequest` | `"contactRequest"` | `contactRequest: UserContactRequest` | +| `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | +| `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | + +--- + + + + +## 5. AppPreferences + +Defined at [`SimpleXAPI.kt line 94`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L94) as `class AppPreferences`. + +Uses the `multiplatform-settings` library (`com.russhwolf.settings.Settings`) for cross-platform key-value storage (Android `SharedPreferences` / Desktop `java.util.prefs.Preferences`). + +The `AppPreferences` instance is created lazily in `ChatController` at [`SimpleXAPI.kt line 496`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt#L496): +```kotlin +val appPrefs: AppPreferences by lazy { AppPreferences() } +``` + +### Preference Categories + +#### Notifications (lines 96-103) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `notificationsMode` | `NotificationsMode` | `SERVICE` (if previously enabled) | OFF / SERVICE / PERIODIC | +| `notificationPreviewMode` | `String` | `"message"` | message / contact / hidden | +| `canAskToEnableNotifications` | `Boolean` | `true` | Whether to show notification enable prompt | +| `backgroundServiceNoticeShown` | `Boolean` | `false` | Background service notice already shown | +| `backgroundServiceBatteryNoticeShown` | `Boolean` | `false` | Battery notice already shown | +| `autoRestartWorkerVersion` | `Int` | `0` | Worker version for periodic restart | + +#### Calls (lines 105-111) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `webrtcPolicyRelay` | `Boolean` | `true` | Use TURN relay for WebRTC | +| `callOnLockScreen` | `CallOnLockScreen` | `SHOW` | DISABLE / SHOW / ACCEPT | +| `webrtcIceServers` | `String?` | `null` | Custom ICE servers | +| `experimentalCalls` | `Boolean` | `false` | Enable experimental call features | + +#### Authentication (lines 107-110) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `performLA` | `Boolean` | `false` | Enable local authentication | +| `laMode` | `LAMode` | default | Authentication mode | +| `laLockDelay` | `Int` | `30` | Seconds before re-auth required | +| `laNoticeShown` | `Boolean` | `false` | LA notice shown | + +#### Privacy (lines 112-128) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `privacyProtectScreen` | `Boolean` | `true` | FLAG_SECURE on Android | +| `privacyAcceptImages` | `Boolean` | `true` | Auto-accept images | +| `privacyLinkPreviews` | `Boolean` | `true` | Generate link previews | +| `privacySanitizeLinks` | `Boolean` | `false` | Remove tracking params from links | +| `simplexLinkMode` | `SimplexLinkMode` | `DESCRIPTION` | DESCRIPTION / FULL / BROWSER | +| `privacyShowChatPreviews` | `Boolean` | `true` | Show chat previews in list | +| `privacySaveLastDraft` | `Boolean` | `true` | Save compose draft | +| `privacyDeliveryReceiptsSet` | `Boolean` | `false` | Delivery receipts configured | +| `privacyEncryptLocalFiles` | `Boolean` | `true` | Encrypt local files | +| `privacyAskToApproveRelays` | `Boolean` | `true` | Ask before using relays | +| `privacyMediaBlurRadius` | `Int` | `0` | Blur radius for media | + +#### Network (lines 140-175) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `networkUseSocksProxy` | `Boolean` | `false` | Enable SOCKS proxy | +| `networkProxy` | `NetworkProxy` | localhost:9050 | Proxy host/port | +| `networkSessionMode` | `TransportSessionMode` | default | Session mode | +| `networkSMPProxyMode` | `SMPProxyMode` | default | SMP proxy mode | +| `networkSMPProxyFallback` | `SMPProxyFallback` | default | Proxy fallback policy | +| `networkHostMode` | `HostMode` | default | Host mode (onion routing) | +| `networkRequiredHostMode` | `Boolean` | `false` | Enforce host mode | +| `networkSMPWebPortServers` | `SMPWebPortServers` | default | Web port server config | +| `networkShowSubscriptionPercentage` | `Boolean` | `false` | Show subscription stats | +| `networkTCPConnectTimeout*` | `Long` | varies | TCP connect timeouts (background/interactive) | +| `networkTCPTimeout*` | `Long` | varies | TCP operation timeouts | +| `networkTCPTimeoutPerKb` | `Long` | varies | Per-KB timeout | +| `networkRcvConcurrency` | `Int` | default | Receive concurrency | +| `networkSMPPingInterval` | `Long` | default | SMP ping interval | +| `networkSMPPingCount` | `Int` | default | SMP ping count | +| `networkEnableKeepAlive` | `Boolean` | default | TCP keep-alive | +| `networkTCPKeepIdle` | `Int` | default | Keep-alive idle time | +| `networkTCPKeepIntvl` | `Int` | default | Keep-alive interval | +| `networkTCPKeepCnt` | `Int` | default | Keep-alive count | + +#### Appearance (lines 213-233) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `currentTheme` | `String` | `"SYSTEM"` | Active theme name | +| `systemDarkTheme` | `String` | `"SIMPLEX"` | Theme for system dark mode | +| `currentThemeIds` | `Map` | empty | Theme ID per base theme | +| `themeOverrides` | `List` | empty | Custom theme overrides | +| `profileImageCornerRadius` | `Float` | `22.5f` | Avatar corner radius | +| `chatItemRoundness` | `Float` | `0.75f` | Message bubble roundness | +| `chatItemTail` | `Boolean` | `true` | Show bubble tail | +| `fontScale` | `Float` | `1f` | Font scale factor | +| `densityScale` | `Float` | `1f` | UI density scale | +| `inAppBarsAlpha` | `Float` | varies | Bar transparency | +| `appearanceBarsBlurRadius` | `Int` | 50 or 0 | Bar blur radius (device-dependent) | + +#### Developer (lines 135-139) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `developerTools` | `Boolean` | `false` | Enable developer tools | +| `logLevel` | `LogLevel` | `WARNING` | Log level | +| `showInternalErrors` | `Boolean` | `false` | Show internal errors to user | +| `showSlowApiCalls` | `Boolean` | `false` | Alert on slow API calls | +| `terminalAlwaysVisible` | `Boolean` | `false` | Floating terminal window (desktop) | + +#### Database (lines 188-208) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `onboardingStage` | `OnboardingStage` | `OnboardingComplete` | Current onboarding step | +| `storeDBPassphrase` | `Boolean` | `true` | Store DB passphrase in keystore | +| `initialRandomDBPassphrase` | `Boolean` | `false` | DB was created with random passphrase | +| `encryptedDBPassphrase` | `String?` | null | Encrypted DB passphrase | +| `confirmDBUpgrades` | `Boolean` | `false` | Confirm DB migrations | +| `chatStopped` | `Boolean` | `false` | Chat was explicitly stopped | +| `chatLastStart` | `Instant?` | null | Last chat start timestamp | +| `newDatabaseInitialized` | `Boolean` | `false` | DB successfully initialized at least once | +| `shouldImportAppSettings` | `Boolean` | `false` | Import settings after DB import | +| `selfDestruct` | `Boolean` | `false` | Self-destruct enabled | +| `selfDestructDisplayName` | `String?` | null | Display name for self-destruct profile | + +#### UI Preferences (lines 255-257) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `oneHandUI` | `Boolean` | `true` | One-hand mode | +| `chatBottomBar` | `Boolean` | `true` | Bottom bar in chat | + +#### Remote Access (lines 238-243) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `deviceNameForRemoteAccess` | `String` | device model | Device name shown to paired devices | +| `confirmRemoteSessions` | `Boolean` | `false` | Confirm remote sessions | +| `connectRemoteViaMulticast` | `Boolean` | `false` | Use multicast for discovery | +| `connectRemoteViaMulticastAuto` | `Boolean` | `true` | Auto-connect via multicast | +| `offerRemoteMulticast` | `Boolean` | `true` | Offer multicast connection | + +#### Migration (lines 189-190) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `migrationToStage` | `String?` | null | Migration-to-device progress | +| `migrationFromStage` | `String?` | null | Migration-from-device progress | + +#### Updates and Versioning (lines 184-186, 235-237) + +| Key | Type | Default | Purpose | +|---|---|---|---| +| `appUpdateChannel` | `AppUpdatesChannel` | `DISABLED` | DISABLED / STABLE / BETA | +| `appSkippedUpdate` | `String` | `""` | Skipped update version | +| `appUpdateNoticeShown` | `Boolean` | `false` | Update notice shown | +| `whatsNewVersion` | `String?` | null | Last "What's New" version seen | +| `lastMigratedVersionCode` | `Int` | `0` | Last app version code for data migrations | +| `customDisappearingMessageTime` | `Int` | `300` | Custom disappearing message time (seconds) | + +### Preference Utility Types + +The `SharedPreference` wrapper (defined in SimpleXAPI.kt) provides: +- `get(): T` -- read current value +- `set(value: T)` -- write value +- `state: MutableState` -- Compose-observable state (derived lazily) + +Factory methods: `mkBoolPreference`, `mkIntPreference`, `mkLongPreference`, `mkFloatPreference`, `mkStrPreference`, `mkEnumPreference`, `mkSafeEnumPreference`, `mkDatePreference`, `mkMapPreference`, `mkTimeoutPreference`. + +--- + +## 6. Source Files + +| File | Path | Key Contents | +|---|---|---| +| ChatModel.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt) | `ChatModel` singleton (line 86), `ChatsContext` (line 339), `Chat` (line 1328), `ChatInfo` (line 1391), `ChatStats` (line 1370), helper methods | +| SimpleXAPI.kt | [`common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt`](../common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt) | `AppPreferences` (line 94), `ChatController` (line 493), `startReceiver` (line 660), `sendCmd` (line 804), `recvMsg` (line 829), `processReceivedMsg` (line 2568) | +| ChatItemsMerger.kt | [`common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt`](../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt) | `ActiveChatState` (line 196), chat item merge/diff logic | +| Core.kt | [`common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt`](../common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt) | `initChatController` (line 62), state initialization flow | +| App.kt | [`common/src/commonMain/kotlin/chat/simplex/common/App.kt`](../common/src/commonMain/kotlin/chat/simplex/common/App.kt) | `AppScreen` (line 47), `MainScreen` (line 84), top-level UI state reads | diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a6ddc97e19..34b63ff06a 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -54,7 +54,7 @@ import Simplex.Chat.Core import Simplex.Chat.Markdown (Format (..), FormattedText (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options -import Simplex.Chat.Protocol (MsgContent (..)) +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) import Simplex.Chat.Store.Direct (getContact) import Simplex.Chat.Store.Groups (getGroupLink, getGroupMember, setGroupCustomData) -- TODO remove setGroupCustomData import Simplex.Chat.Store.Profiles (GroupLinkInfo (..), getGroupLinkInfo) @@ -326,6 +326,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName notifyAdminUsers msg logError msg groupInfoText p@GroupProfile {description = d} = groupNameDescr p <> maybe "" ("\nWelcome message:\n" <>) d + knockingStr :: Maybe GroupMemberAdmission -> [Text] + knockingStr = \case + Just GroupMemberAdmission {review = Just MCAll} -> ["New members are reviewed by admins"] + _ -> [] groupNameDescr GroupProfile {displayName = n, fullName = fn, shortDescr = sd_} = n <> maybe "" (\d' -> " (" <> d' <> ")") descr where @@ -485,9 +489,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName GroupInfo {groupId, groupProfile = p} = fromGroup GroupInfo {groupProfile = p'} = toGroup sameProfile - GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d'} = - n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') + GroupProfile {displayName = n, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} + GroupProfile {displayName = n', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = + n == n' && fn == fn' && i == i' && sd == sd' && (T.words <$> d) == (T.words <$> d') && ma == ma' groupLinkAdded gr byMember = getDuplicateGroup toGroup >>= \case Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e @@ -532,9 +536,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName checkRolesSendToApprove gr' n' where onlyLinkChanged - GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d} - GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d'} = - dn == dn' && fn == fn' && i == i' && sd == sd' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') + GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} + GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = + dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) where @@ -569,7 +573,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g captchaNotice = "Captcha is generated by SimpleX Directory service.\n\n*Send captcha text* to join the group " <> displayName <> "." - <> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" + <> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" sendMemberCaptcha :: GroupInfo -> GroupMember -> Maybe ChatItemId -> Text -> Int -> CaptchaMode -> IO () sendMemberCaptcha GroupInfo {groupId} m quotedId noticeText prevAttempts mode = do @@ -586,7 +590,7 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName mc <- getCaptchaContent s sendComposedMessages_ cc sendRef [(quotedId, MCText noticeText), (Nothing, mc)] where - sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False gmId = groupMemberId' m sendVoiceCaptcha :: SendRef -> String -> IO () @@ -614,6 +618,11 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName img : _ -> MCImage "" $ ImageData img textMsg = MCText $ T.pack s + canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool + canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) + approvePendingMember :: DirectoryMemberAcceptance -> GroupInfo -> GroupMember -> IO () approvePendingMember a g@GroupInfo {groupId} m@GroupMember {memberProfile = LocalProfile {displayName, image}} = do gli_ <- join . eitherToMaybe <$> withDB' "getGroupLinkInfo" cc (\db -> getGroupLinkInfo db userId groupId) @@ -631,22 +640,26 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName dePendingMemberMsg g@GroupInfo {groupId, groupProfile = GroupProfile {displayName = n}} m@GroupMember {memberProfile = LocalProfile {displayName}} ciId msgText | memberRequiresCaptcha a m = do let gmId = groupMemberId' m - sendRef = SRGroup groupId $ Just $ GCSMemberSupport (Just gmId) + sendRef = SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False -- /audio is matched as text, not as DirectoryCmd, because it is only valid -- in group context at captcha stage, while DirectoryCmd is for DM commands. isAudioCmd = T.strip msgText == "/audio" cmd = fromRight (ADC SDRUser DCUnknownCommand) $ A.parseOnly (directoryCmdP <* A.endOfInput) $ T.strip msgText atomically (TM.lookup gmId $ pendingCaptchas env) >>= \case - Nothing -> - let mode = if isAudioCmd then CMAudio else CMText - in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode + Nothing + | isAudioCmd && canSendVoiceCaptcha g m -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + | isAudioCmd -> sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] + | otherwise -> sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMText Just pc@PendingCaptcha {captchaText, sentAt, attempts, captchaMode} - | isAudioCmd -> case captchaMode of - CMText -> do - atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env - sendVoiceCaptcha sendRef (T.unpack captchaText) - CMAudio -> - sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + | isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] | otherwise -> case cmd of ADC SDRUser (DCSearchGroup _) -> do ts <- getCurrentTime @@ -663,9 +676,9 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName a = groupMemberAcceptance g rejectPendingMember rjctNotice = do let gmId = groupMemberId' m - sendComposedMessages cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [MCText rjctNotice] + sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice] sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case - Right (CRUserDeletedMembers _ _ (_ : _) _) -> do + Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do atomically $ TM.delete gmId $ pendingCaptchas env logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g r -> logError $ "unexpected remove member response: " <> tshow r @@ -679,6 +692,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName noCaptcha = "Unexpected message, please try again." audioAlreadyEnabled :: Text audioAlreadyEnabled = "Audio captcha is already enabled." + voiceCaptchaUnavailable :: Text + voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." unknownCommand :: Text unknownCommand = "Unknown command, please enter captcha text." tooManyAttempts :: Text @@ -697,8 +712,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName <> ("\n" <> groupInfoText p <> "\n" <> membersStr <> "\nTo approve send:") msg = maybe (MCText text) (\image -> MCImage {text, image}) image' withAdminUsers $ \cId -> do - sendComposedMessage' cc cId Nothing msg - sendMessage' cc cId $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + let approveCmd = MCText $ "/approve " <> tshow groupId <> ":" <> viewName displayName <> " " <> tshow gaId <> if promoted then " promote=on" else "" + sendComposedMessages cc (SRDirect cId) [msg, approveCmd] deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do @@ -985,10 +1000,10 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary = GroupSummary {currentMembers}}, _) = + foundGroup (GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary = GroupSummary {currentMembers}}, _) = let membersStr = "_" <> tshow currentMembers <> " members_" showId = if isAdmin then tshow groupId <> ". " else "" - text = showId <> groupInfoText p <> "\n" <> membersStr + text = T.unlines $ [showId <> groupInfoText p, membersStr] ++ knockingStr memberAdmission in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") @@ -1170,14 +1185,14 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName sendComposedMessages_ cc (SRDirect $ contactId' ct) $ replyMsg :| map groupMessage gs' where groupMessage ((g, gr), ct_) = - let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_}, groupSummary} = g + let GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary} = g GroupReg {userGroupRegId, groupRegStatus} = gr useGroupId = if isAdmin then groupId else userGroupRegId statusStr = "Status: " <> groupRegStatusText groupRegStatus membersStr = "_" <> tshow (currentMembers groupSummary) <> " members_" cmds = "/'role " <> tshow useGroupId <> "', /'filter " <> tshow useGroupId <> "'" ownerStr = maybe "" (("Owner: " <>) . either (("getContact error: " <>) . T.pack) localDisplayName') ct_ - text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr, cmds] + text = T.unlines $ [tshow useGroupId <> ". " <> groupInfoText p] ++ [ownerStr | isAdmin] ++ [membersStr, statusStr] ++ knockingStr memberAdmission ++ [cmds] msg = maybe (MCText text) (\image -> MCImage {text, image}) image_ in (Nothing, msg) diff --git a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs index e22f4ed470..aa101d7bf7 100644 --- a/apps/simplex-directory-service/src/Directory/Store/Migrate.hs +++ b/apps/simplex-directory-service/src/Directory/Store/Migrate.hs @@ -22,8 +22,9 @@ import Simplex.Chat.Controller (ChatConfig (..), ChatDatabase (..)) import Simplex.Chat.Options (CoreChatOpts (..)) import Simplex.Chat.Options.DB import Simplex.Chat.Protocol (supportedChatVRange) -import Simplex.Chat.Store.Groups (getGroupInfo, getHostMember) +import Simplex.Chat.Store.Groups (getHostMember) import Simplex.Chat.Store.Profiles (getUsers) +import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types import Simplex.Messaging.Agent.Store.Common import qualified Simplex.Messaging.Agent.Store.DB as DB diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index b408d8eb30..b5d49077c0 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -30,6 +30,8 @@ This file is generated automatically. - [APILeaveGroup](#apileavegroup) - [APIListMembers](#apilistmembers) - [APINewGroup](#apinewgroup) +- [APINewPublicGroup](#apinewpublicgroup) +- [APIGetGroupRelays](#apigetgrouprelays) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -50,6 +52,9 @@ This file is generated automatically. - [APIListContacts](#apilistcontacts) - [APIListGroups](#apilistgroups) - [APIDeleteChat](#apideletechat) +- [APISetGroupCustomData](#apisetgroupcustomdata) +- [APISetContactCustomData](#apisetcontactcustomdata) +- [APISetUserAutoAcceptMemberContacts](#apisetuserautoacceptmembercontacts) [User profile commands](#user-profile-commands) - [ShowActiveUser](#showactiveuser) @@ -593,7 +598,7 @@ Add contact to group. Requires bot to have Admin role. **Syntax**: ``` -/_add # observer|author|member|moderator|admin|owner +/_add # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -672,7 +677,7 @@ Accept group member. Requires Admin role. **Syntax**: ``` -/_accept member # observer|author|member|moderator|admin|owner +/_accept member # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -715,7 +720,7 @@ Set members role. Requires Admin role. **Syntax**: ``` -/_member role # [,...] observer|author|member|moderator|admin|owner +/_member role # [,...] relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -734,6 +739,7 @@ MembersRoleUser: Members role changed by user. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -775,6 +781,7 @@ MembersBlockedForAllUser: Members blocked for all by admin. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - blocked: bool +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -816,6 +823,7 @@ UserDeletedMembers: Members deleted. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - members: [[GroupMember](./TYPES.md#groupmember)] - withMessages: bool +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -940,6 +948,86 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APINewPublicGroup + +Create public group. + +*Network usage*: interactive. + +**Parameters**: +- userId: int64 +- incognito: bool +- relayIds: [int64] +- groupProfile: [GroupProfile](./TYPES.md#groupprofile) + +**Syntax**: + +``` +/_public group [ incognito=on] [,...] +``` + +```javascript +'/_public group ' + userId + (incognito ? ' incognito=on' : '') + ' ' + relayIds.join(',') + ' ' + JSON.stringify(groupProfile) // JavaScript +``` + +```python +'/_public group ' + str(userId) + (' incognito=on' if incognito else '') + ' ' + ','.join(map(str, relayIds)) + ' ' + json.dumps(groupProfile) # Python +``` + +**Responses**: + +PublicGroupCreated: Public group created. +- type: "publicGroupCreated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APIGetGroupRelays + +Get group relays. + +*Network usage*: no. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_get relays # +``` + +```javascript +'/_get relays #' + groupId // JavaScript +``` + +```python +'/_get relays #' + str(groupId) # Python +``` + +**Responses**: + +GroupRelays: Group relays. +- type: "groupRelays" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. @@ -972,6 +1060,7 @@ GroupUpdated: Group updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: bool ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" @@ -998,7 +1087,7 @@ Create group link. **Syntax**: ``` -/_create link # observer|author|member|moderator|admin|owner +/_create link # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1037,7 +1126,7 @@ Set member role for group link. **Syntax**: ``` -/_set link role # observer|author|member|moderator|admin|owner +/_set link role # relay|observer|author|member|moderator|admin|owner ``` ```javascript @@ -1518,6 +1607,118 @@ GroupDeletedUser: User deleted group. - type: "groupDeletedUser" - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- msgSigned: bool + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetGroupCustomData + +Set group custom data. + +*Network usage*: no. + +**Parameters**: +- groupId: int64 +- customData: JSONObject? + +**Syntax**: + +``` +/_set custom #[ ] +``` + +```javascript +'/_set custom #' + groupId + (customData ? ' ' + JSON.stringify(customData) : '') // JavaScript +``` + +```python +'/_set custom #' + str(groupId) + ((' ' + json.dumps(customData)) if customData is not None else '') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetContactCustomData + +Set contact custom data. + +*Network usage*: no. + +**Parameters**: +- contactId: int64 +- customData: JSONObject? + +**Syntax**: + +``` +/_set custom @[ ] +``` + +```javascript +'/_set custom @' + contactId + (customData ? ' ' + JSON.stringify(customData) : '') // JavaScript +``` + +```python +'/_set custom @' + str(contactId) + ((' ' + json.dumps(customData)) if customData is not None else '') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + +### APISetUserAutoAcceptMemberContacts + +Set auto-accept member contacts. + +*Network usage*: no. + +**Parameters**: +- userId: int64 +- onOff: bool + +**Syntax**: + +``` +/_set accept member contacts on|off +``` + +```javascript +'/_set accept member contacts ' + userId + ' ' + (onOff ? 'on' : 'off') // JavaScript +``` + +```python +'/_set accept member contacts ' + str(userId) + ' ' + ('on' if onOff else 'off') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? ChatCmdError: Command error (only used in WebSockets API). - type: "chatCmdError" diff --git a/bots/api/EVENTS.md b/bots/api/EVENTS.md index d7405ef846..947c60586a 100644 --- a/bots/api/EVENTS.md +++ b/bots/api/EVENTS.md @@ -38,6 +38,8 @@ This file is generated automatically. - [MemberAcceptedByOther](#memberacceptedbyother) - [MemberBlockedForAll](#memberblockedforall) - [GroupMemberUpdated](#groupmemberupdated) + - [GroupLinkDataUpdated](#grouplinkdataupdated) + - [GroupRelayUpdated](#grouprelayupdated) [File events](#file-events) - Main events @@ -300,6 +302,7 @@ Group profile or preferences updated. - fromGroup: [GroupInfo](./TYPES.md#groupinfo) - toGroup: [GroupInfo](./TYPES.md#groupinfo) - member_: [GroupMember](./TYPES.md#groupmember)? +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -329,6 +332,7 @@ Member (or bot user's) group role changed. - member: [GroupMember](./TYPES.md#groupmember) - fromRole: [GroupMemberRole](./TYPES.md#groupmemberrole) - toRole: [GroupMemberRole](./TYPES.md#groupmemberrole) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -344,6 +348,7 @@ Another member is removed from the group. - byMember: [GroupMember](./TYPES.md#groupmember) - deletedMember: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -357,6 +362,7 @@ Another member left the group. - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -371,6 +377,7 @@ Bot user was removed from the group. - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) - withMessages: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -384,6 +391,7 @@ Group was deleted by the owner (not bot user). - user: [User](./TYPES.md#user) - groupInfo: [GroupInfo](./TYPES.md#groupinfo) - member: [GroupMember](./TYPES.md#groupmember) +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -427,6 +435,7 @@ Another member blocked for all members. - byMember: [GroupMember](./TYPES.md#groupmember) - member: [GroupMember](./TYPES.md#groupmember) - blocked: bool +- msgSigned: [MsgSigStatus](./TYPES.md#msgsigstatus)? --- @@ -445,6 +454,35 @@ Another group member profile updated. --- +### GroupLinkDataUpdated + +Group link data updated. + +**Record type**: +- type: "groupLinkDataUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- groupLink: [GroupLink](./TYPES.md#grouplink) +- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)] +- relaysChanged: bool + +--- + + +### GroupRelayUpdated + +Group relay member updated. + +**Record type**: +- type: "groupRelayUpdated" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) +- member: [GroupMember](./TYPES.md#groupmember) +- groupRelay: [GroupRelay](./TYPES.md#grouprelay) + +--- + + ## File events Bots that send or receive files may process these events to track delivery status and to process completion. diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index a66f1b379e..c2303e9e6d 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -90,6 +90,7 @@ This file is generated automatically. - [GroupFeature](#groupfeature) - [GroupFeatureEnabled](#groupfeatureenabled) - [GroupInfo](#groupinfo) +- [GroupKeys](#groupkeys) - [GroupLink](#grouplink) - [GroupLinkPlan](#grouplinkplan) - [GroupMember](#groupmember) @@ -102,9 +103,13 @@ This file is generated automatically. - [GroupPreference](#grouppreference) - [GroupPreferences](#grouppreferences) - [GroupProfile](#groupprofile) +- [GroupRelay](#grouprelay) +- [GroupRootKey](#grouprootkey) - [GroupShortLinkData](#groupshortlinkdata) +- [GroupShortLinkInfo](#groupshortlinkinfo) - [GroupSummary](#groupsummary) - [GroupSupportChat](#groupsupportchat) +- [GroupType](#grouptype) - [HandshakeError](#handshakeerror) - [InlineFileMode](#inlinefilemode) - [InvitationLinkPlan](#invitationlinkplan) @@ -121,6 +126,7 @@ This file is generated automatically. - [MsgFilter](#msgfilter) - [MsgReaction](#msgreaction) - [MsgReceiptStatus](#msgreceiptstatus) +- [MsgSigStatus](#msgsigstatus) - [NetworkError](#networkerror) - [NewUser](#newuser) - [NoteFolder](#notefolder) @@ -132,6 +138,8 @@ This file is generated automatically. - [Profile](#profile) - [ProxyClientError](#proxyclienterror) - [ProxyError](#proxyerror) +- [PublicGroupData](#publicgroupdata) +- [PublicGroupProfile](#publicgroupprofile) - [RCErrorType](#rcerrortype) - [RatchetSyncState](#ratchetsyncstate) - [RcvConnEvent](#rcvconnevent) @@ -140,6 +148,8 @@ This file is generated automatically. - [RcvFileStatus](#rcvfilestatus) - [RcvFileTransfer](#rcvfiletransfer) - [RcvGroupEvent](#rcvgroupevent) +- [RelayProfile](#relayprofile) +- [RelayStatus](#relaystatus) - [ReportReason](#reportreason) - [RoleGroupPreference](#rolegrouppreference) - [SMPAgentError](#smpagenterror) @@ -164,6 +174,7 @@ This file is generated automatically. - [UIThemeEntityOverrides](#uithemeentityoverrides) - [UpdatedMessage](#updatedmessage) - [User](#user) +- [UserChatRelay](#userchatrelay) - [UserContact](#usercontact) - [UserContactLink](#usercontactlink) - [UserContactRequest](#usercontactrequest) @@ -601,6 +612,9 @@ GroupRcv: - type: "groupRcv" - groupMember: [GroupMember](#groupmember) +ChannelRcv: +- type: "channelRcv" + LocalSnd: - type: "localSnd" @@ -771,6 +785,7 @@ Group: - editable: bool - forwardedByMember: int64? - showGroupAsSender: bool +- msgSigned: [MsgSigStatus](#msgsigstatus)? - createdAt: UTCTime - updatedAt: UTCTime @@ -960,6 +975,9 @@ UserExists: - type: "userExists" - contactName: string +ChatRelayExists: +- type: "chatRelayExists" + DifferentActiveUser: - type: "differentActiveUser" - commandUserId: int64 @@ -1207,6 +1225,10 @@ ConnectionUserChangeProhibited: PeerChatVRangeIncompatible: - type: "peerChatVRangeIncompatible" +RelayTestError: +- type: "relayTestError" +- message: string + InternalError: - type: "internalError" - message: string @@ -1473,15 +1495,35 @@ LARGE: ## ConnStatus -**Enum type**: -- "new" -- "prepared" -- "joined" -- "requested" -- "accepted" -- "snd-ready" -- "ready" -- "deleted" +**Discriminated union type**: + +New: +- type: "new" + +Prepared: +- type: "prepared" + +Joined: +- type: "joined" + +Requested: +- type: "requested" + +Accepted: +- type: "accepted" + +SndReady: +- type: "sndReady" + +Ready: +- type: "ready" + +Deleted: +- type: "deleted" + +Failed: +- type: "failed" +- connError: string --- @@ -1967,6 +2009,9 @@ Snippet: Secret: - type: "secret" +Small: +- type: "small" + Colored: - type: "colored" - color: [Color](#color) @@ -2133,6 +2178,7 @@ MemberSupport: **Record type**: - groupId: int64 - useRelays: bool +- relayOwnStatus: [RelayStatus](#relaystatus)? - localDisplayName: string - groupProfile: [GroupProfile](#groupprofile) - localAlias: string @@ -2152,6 +2198,17 @@ MemberSupport: - groupSummary: [GroupSummary](#groupsummary) - membersRequireAttention: int - viaGroupLinkUri: string? +- groupKeys: [GroupKeys](#groupkeys)? + + +--- + +## GroupKeys + +**Record type**: +- publicGroupId: string +- groupRootKey: [GroupRootKey](#grouprootkey) +- memberPrivKey: string --- @@ -2175,6 +2232,7 @@ MemberSupport: Ok: - type: "ok" +- groupSLinkInfo_: [GroupShortLinkInfo](#groupshortlinkinfo)? - groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)? OwnLink: @@ -2218,6 +2276,8 @@ Known: - createdAt: UTCTime - updatedAt: UTCTime - supportChat: [GroupSupportChat](#groupsupportchat)? +- memberPubKey: string? +- relayLink: string? --- @@ -2254,6 +2314,7 @@ Known: ## GroupMemberRole **Enum type**: +- "relay" - "observer" - "author" - "member" @@ -2328,16 +2389,55 @@ Known: - shortDescr: string? - description: string? - image: string? +- publicGroup: [PublicGroupProfile](#publicgroupprofile)? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +--- + +## GroupRelay + +**Record type**: +- groupRelayId: int64 +- groupMemberId: int64 +- userChatRelay: [UserChatRelay](#userchatrelay) +- relayStatus: [RelayStatus](#relaystatus) +- relayLink: string? + + +--- + +## GroupRootKey + +**Discriminated union type**: + +Private: +- type: "private" +- rootPrivKey: string + +Public: +- type: "public" +- rootPubKey: string + + --- ## GroupShortLinkData **Record type**: - groupProfile: [GroupProfile](#groupprofile) +- publicGroupData: [PublicGroupData](#publicgroupdata)? + + +--- + +## GroupShortLinkInfo + +**Record type**: +- direct: bool +- groupRelays: [string] +- publicGroupId: string? --- @@ -2346,6 +2446,7 @@ Known: **Record type**: - currentMembers: int64 +- publicMemberCount: int64? --- @@ -2360,6 +2461,14 @@ Known: - lastMsgFromMemberTs: UTCTime? +--- + +## GroupType + +**Enum type**: +- "channel" + + --- ## HandshakeError @@ -2632,6 +2741,15 @@ Unknown: - "badMsgHash" +--- + +## MsgSigStatus + +**Enum type**: +- "verified" +- "signedNoKey" + + --- ## NetworkError @@ -2667,6 +2785,7 @@ SubscribeError: **Record type**: - profile: [Profile](#profile)? - pastTimestamp: bool +- userChatRelay: bool --- @@ -2802,6 +2921,24 @@ NO_SESSION: - type: "NO_SESSION" +--- + +## PublicGroupData + +**Record type**: +- publicMemberCount: int64 + + +--- + +## PublicGroupProfile + +**Record type**: +- groupType: [GroupType](#grouptype) +- groupLink: string +- publicGroupId: string + + --- ## RCErrorType @@ -3038,6 +3175,31 @@ MemberProfileUpdated: NewMemberPendingReview: - type: "newMemberPendingReview" +MsgBadSignature: +- type: "msgBadSignature" + + +--- + +## RelayProfile + +**Record type**: +- displayName: string +- fullName: string +- shortDescr: string? +- image: string? + + +--- + +## RelayStatus + +**Enum type**: +- "new" +- "invited" +- "accepted" +- "active" + --- @@ -3277,6 +3439,9 @@ UserNotFound: - type: "userNotFound" - userId: int64 +RelayUserNotFound: +- type: "relayUserNotFound" + UserNotFoundByName: - type: "userNotFoundByName" - contactName: string @@ -3380,6 +3545,9 @@ GroupWithoutUser: DuplicateGroupMember: - type: "duplicateGroupMember" +DuplicateMemberId: +- type: "duplicateMemberId" + GroupAlreadyJoined: - type: "groupAlreadyJoined" @@ -3568,6 +3736,18 @@ OperatorNotFound: UsageConditionsNotFound: - type: "usageConditionsNotFound" +UserChatRelayNotFound: +- type: "userChatRelayNotFound" +- chatRelayId: int64 + +GroupRelayNotFound: +- type: "groupRelayNotFound" +- groupRelayId: int64 + +GroupRelayNotFoundByMemberId: +- type: "groupRelayNotFoundByMemberId" +- groupMemberId: int64 + InvalidQuote: - type: "invalidQuote" @@ -3746,6 +3926,22 @@ Handshake: - autoAcceptMemberContacts: bool - userMemberProfileUpdatedAt: UTCTime? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +- userChatRelay: bool + + +--- + +## UserChatRelay + +**Record type**: +- chatRelayId: int64 +- address: string +- relayProfile: [RelayProfile](#relayprofile) +- domains: [string] +- preset: bool +- tested: bool? +- enabled: bool +- deleted: bool --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 35745f9b42..26b48e56b0 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -117,6 +117,8 @@ chatCommandsDocsData = ("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"), ("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"), ("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"), + ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"), + ("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -142,7 +144,10 @@ chatCommandsDocsData = "Commands to list and delete conversations.", [ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"), ("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"), - ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode") + ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), + ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), + ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), + ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff") -- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""), -- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""), -- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""), @@ -240,6 +245,7 @@ cliCommands = "MemberRole", "MuteUser", "NewGroup", + "NewPublicGroup", "QuitChat", "ReactToMessage", "RejectContact", @@ -363,6 +369,7 @@ undocumentedCommands = "APIGetUsageConditions", "APIGetUserServers", "APIGroupInfo", + "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", "APIGroupMemberQueueInfo", "APIHideUser", @@ -398,7 +405,6 @@ undocumentedCommands = "APISetServerOperators", "APISetUserContactReceipts", "APISetUserGroupReceipts", - "APISetUserAutoAcceptMemberContacts", "APISetUserServers", "APISetUserUIThemes", "APIStandaloneFileInfo", @@ -408,6 +414,7 @@ undocumentedCommands = "APISwitchGroupMember", "APISyncContactRatchet", "APISyncGroupMemberRatchet", + "APITestChatRelay", "APITestProtoServer", "APIUnhideUser", "APIUnmuteUser", @@ -440,6 +447,7 @@ undocumentedCommands = "GetChatItemTTL", "GetRemoteFile", "GetUserProtoServers", + "GetUserChatRelays", "ListRemoteCtrls", "ListRemoteHosts", "ReconnectAllServers", @@ -457,12 +465,14 @@ undocumentedCommands = "SetServerOperators", "SetTempFolder", "SetUserProtoServers", + "SetUserChatRelays", "SlowSQLQueries", "StartRemoteHost", "StopRemoteCtrl", "StopRemoteHost", "StoreRemoteFile", "SwitchRemoteHost", + "TestChatRelay", "TestProtoServer", "TestStorageEncryption", "VerifyRemoteCtrlSession" diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 130ea89846..c8446e9e67 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -97,7 +97,9 @@ chatEventsDocsData = [ ("CEvtConnectedToGroupMember", "Connected to another group member."), ("CEvtMemberAcceptedByOther", "Another group owner, admin or moderator accepted member to the group after review (\"knocking\")."), ("CEvtMemberBlockedForAll", "Another member blocked for all members."), - ("CEvtGroupMemberUpdated", "Another group member profile updated.") + ("CEvtGroupMemberUpdated", "Another group member profile updated."), + ("CEvtGroupLinkDataUpdated", "Group link data updated."), + ("CEvtGroupRelayUpdated", "Group relay member updated.") ] ), ( "File events", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 60fe129cdb..873ca5eb97 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -68,6 +68,8 @@ chatResponsesDocsData = ("CRGroupLinkCreated", ""), ("CRGroupLinkDeleted", ""), ("CRGroupCreated", ""), + ("CRPublicGroupCreated", ""), + ("CRGroupRelays", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), @@ -130,6 +132,7 @@ undocumentedResponses = "CRChatItemInfo", "CRChatItems", "CRChatItemTTL", + "CRChatRelayTestResult", "CRChats", "CRConnectionsDiff", "CRChatTags", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index 73ad90e91b..37fc6121ce 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -2,6 +2,7 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE FlexibleInstances #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedLists #-} @@ -31,6 +32,8 @@ import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Operators +import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -240,7 +243,7 @@ chatTypesDocsData = (sti @ConnectionErrorType, STUnion, "", [], "", ""), (sti @ConnectionMode, (STEnum' $ take 3 . consLower "CM"), "", [], "", ""), (sti @ConnectionPlan, STUnion, "CP", [], "", ""), - (sti @ConnStatus, (STEnum' $ consSep "Conn" '-'), "", [], "", ""), + (sti @ConnStatus, STUnion, "Conn", [], "", ""), (sti @ConnType, (STEnum' $ consSep "Conn" '_'), "", [], "", ""), (sti @Contact, STRecord, "", [], "", ""), (sti @ContactAddressPlan, STUnion, "CAP", [], "", ""), @@ -256,7 +259,7 @@ chatTypesDocsData = (sti @FileError, STUnion, "FileErr", [], "", ""), (sti @FileErrorType, STUnion, "", [], "", ""), (sti @FileInvitation, STRecord, "", [], "", ""), - (sti @FileProtocol, (STEnum' $ consLower "FP"), "", [], "", ""), + (sti @FileProtocol, STEnum' (consLower "FP"), "", [], "", ""), (sti @FileStatus, STEnum, "FS", [], "", ""), (sti @FileTransferMeta, STRecord, "", [], "", ""), (sti @Format, STUnion, "", ["Unknown"], "", ""), @@ -269,21 +272,26 @@ chatTypesDocsData = (sti @GroupFeature, STEnum, "GF", [], "", ""), (sti @GroupFeatureEnabled, STEnum, "FE", [], "", ""), (sti @GroupInfo, STRecord, "", [], "", ""), + (sti @GroupKeys, STRecord, "", [], "", ""), + (sti @GroupRootKey, STUnion, "GRK", [], "", ""), (sti @GroupLink, STRecord, "", [], "", ""), (sti @GroupLinkPlan, STUnion, "GLP", [], "", ""), (sti @GroupMember, STRecord, "", [], "", ""), (sti @GroupMemberAdmission, STRecord, "", [], "", ""), - (sti @GroupMemberCategory, (STEnum' $ dropPfxSfx "GC" "Member"), "", [], "", ""), + (sti @GroupMemberCategory, STEnum' (dropPfxSfx "GC" "Member"), "", [], "", ""), (sti @GroupMemberRef, STRecord, "", [], "", ""), - (sti @GroupMemberRole, STEnum, "GR", [], "", ""), + (sti @GroupMemberRole, STEnum' (dropPfxSfx "GR" ""), "", ["GRUnknown"], "", ""), (sti @GroupMemberSettings, STRecord, "", [], "", ""), - (sti @GroupMemberStatus, (STEnum' $ (\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), + (sti @GroupMemberStatus, STEnum' ((\case "group_deleted" -> "deleted"; "intro_invited" -> "intro-inv"; s -> s) . consSep "GSMem" '_'), "", [], "", ""), (sti @GroupPreference, STRecord, "", [], "", ""), (sti @GroupPreferences, STRecord, "", [], "", ""), (sti @GroupProfile, STRecord, "", [], "", ""), + (sti @GroupRelay, STRecord, "", [], "", ""), (sti @GroupShortLinkData, STRecord, "", [], "", ""), + (sti @GroupShortLinkInfo, STRecord, "", [], "", ""), (sti @GroupSummary, STRecord, "", [], "", ""), (sti @GroupSupportChat, STRecord, "", [], "", ""), + (sti @GroupType, STEnum1, "GT", ["GTUnknown"], "", ""), (sti @HandshakeError, STEnum, "", [], "", ""), (sti @InlineFileMode, STEnum, "IFM", [], "", ""), (sti @InvitationLinkPlan, STUnion, "ILP", [], "", ""), @@ -300,6 +308,7 @@ chatTypesDocsData = (sti @MsgFilter, STEnum, "MF", [], "", ""), (sti @MsgReaction, STUnion, "MR", [], "", ""), (sti @MsgReceiptStatus, STEnum, "MR", [], "", ""), + (sti @MsgSigStatus, STEnum, "MSS", [], "", ""), (sti @NetworkError, STUnion, "NE", [], "", ""), (sti @NewUser, STRecord, "", [], "", ""), (sti @NoteFolder, STRecord, "", [], "", ""), @@ -312,6 +321,8 @@ chatTypesDocsData = (sti @Profile, STRecord, "", [], "", ""), (sti @ProxyClientError, STUnion, "Proxy", [], "", ""), (sti @ProxyError, STUnion, "", [], "", ""), + (sti @PublicGroupData, STRecord, "", [], "", ""), + (sti @PublicGroupProfile, STRecord, "", [], "", ""), (sti @RatchetSyncState, STEnum, "RS", [], "", ""), (sti @RCErrorType, STUnion, "RCE", [], "", ""), (sti @RcvConnEvent, STUnion, "RCE", [], "", ""), @@ -320,7 +331,9 @@ chatTypesDocsData = (sti @RcvFileStatus, STUnion, "RFS", [], "", ""), (sti @RcvFileTransfer, STRecord, "", [], "", ""), (sti @RcvGroupEvent, STUnion, "RGE", [], "", ""), - (sti @ReportReason, (STEnum' $ dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), + (sti @RelayProfile, STRecord, "", [], "", ""), + (sti @RelayStatus, STEnum, "RS", [], "", ""), + (sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""), (sti @RoleGroupPreference, STRecord, "", [], "", ""), (sti @SecurityCode, STRecord, "", [], "", ""), (sti @SimplePreference, STRecord, "", [], "", ""), @@ -344,6 +357,7 @@ chatTypesDocsData = (sti @UIThemeEntityOverrides, STRecord, "", [], "", ""), (sti @UpdatedMessage, STRecord, "", [], "", ""), (sti @User, STRecord, "", [], "", ""), + ((sti @UserChatRelay) {typeName = "UserChatRelay"}, STRecord, "", [], "", ""), (sti @UserContact, STRecord, "", [], "", ""), (sti @UserContactLink, STRecord, "", [], "", ""), (sti @UserContactRequest, STRecord, "", [], "", ""), @@ -456,6 +470,8 @@ deriving instance Generic GroupChatScopeInfo deriving instance Generic GroupFeature deriving instance Generic GroupFeatureEnabled deriving instance Generic GroupInfo +deriving instance Generic GroupKeys +deriving instance Generic GroupRootKey deriving instance Generic GroupLink deriving instance Generic GroupLinkPlan deriving instance Generic GroupMember @@ -468,7 +484,10 @@ deriving instance Generic GroupMemberStatus deriving instance Generic GroupPreference deriving instance Generic GroupPreferences deriving instance Generic GroupProfile +deriving instance Generic GroupRelay deriving instance Generic GroupShortLinkData +deriving instance Generic GroupShortLinkInfo +deriving instance Generic GroupType deriving instance Generic GroupSummary deriving instance Generic GroupSupportChat deriving instance Generic HandshakeError @@ -493,6 +512,7 @@ deriving instance Generic MsgErrorType deriving instance Generic MsgFilter deriving instance Generic MsgReaction deriving instance Generic MsgReceiptStatus +deriving instance Generic MsgSigStatus deriving instance Generic NetworkError deriving instance Generic NewUser deriving instance Generic NoteFolder @@ -505,6 +525,8 @@ deriving instance Generic PreparedGroup deriving instance Generic Profile deriving instance Generic ProxyClientError deriving instance Generic ProxyError +deriving instance Generic PublicGroupData +deriving instance Generic PublicGroupProfile deriving instance Generic RatchetSyncState deriving instance Generic RCErrorType deriving instance Generic RcvConnEvent @@ -513,6 +535,8 @@ deriving instance Generic RcvFileDescr deriving instance Generic RcvFileStatus deriving instance Generic RcvFileTransfer deriving instance Generic RcvGroupEvent +deriving instance Generic RelayProfile +deriving instance Generic RelayStatus deriving instance Generic ReportReason deriving instance Generic SecurityCode deriving instance Generic SimplexLinkType @@ -535,6 +559,7 @@ deriving instance Generic UIThemeEntityOverride deriving instance Generic UIThemeEntityOverrides deriving instance Generic UpdatedMessage deriving instance Generic User +deriving instance Generic (UserChatRelay' 'DBStored) deriving instance Generic UserContact deriving instance Generic UserContactLink deriving instance Generic UserContactRequest diff --git a/bots/src/API/TypeInfo.hs b/bots/src/API/TypeInfo.hs index a70de72d01..37f74e4275 100644 --- a/bots/src/API/TypeInfo.hs +++ b/bots/src/API/TypeInfo.hs @@ -1,5 +1,4 @@ {-# LANGUAGE AllowAmbiguousTypes #-} -{-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE FlexibleInstances #-} @@ -8,9 +7,7 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TypeOperators #-} -{-# LANGUAGE TypeSynonymInstances #-} {-# LANGUAGE TypeApplications #-} {-# OPTIONS_GHC -Wno-orphans #-} @@ -170,12 +167,14 @@ toTypeInfo tr = _ -> TIType (simpleType tr) simpleType tr' = primitiveToLower $ case tyConName (typeRepTyCon tr') of "AgentUserId" -> ST TInt64 [] + "DBEntityId'" -> ST TInt64 [] "Integer" -> ST TInt64 [] "Version" -> ST TInt [] "BoolDef" -> ST TBool [] "PQEncryption" -> ST TBool [] "PQSupport" -> ST TBool [] "ACreatedConnLink" -> ST "CreatedConnLink" [] + "UserChatRelay'" -> ST "UserChatRelay" [] "CChatItem" -> ST "ChatItem" [] "FormatColor" -> ST "Color" [] "CustomData" -> ST "JSONObject" [] @@ -210,6 +209,8 @@ toTypeInfo tr = "MemberId", "Text", "MREmojiChar", + "PrivateKey", + "PublicKey", "ProtocolServer", "SbKey", "SharedMsgId", diff --git a/cabal.project b/cabal.project index 0b2104ba76..e853187e42 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 8fdc0703bc9b89dae8b2fe6820b705580a669281 + tag: 97802a30fce1dfeea90f0b465e21fc8eca937abb source-repository-package type: git diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index b9889fe7a7..95cf972c0d 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -4,11 +4,12 @@ permalink: /downloads/index.html revision: 09.09.2024 --- -| Updated 09.09.2024 | Languages: EN | # Download SimpleX apps You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases). +If you cannot access GitHub, you can download SimpleX Chat apps from our mirror at [git.simplex.chat](https://git.simplex.chat/simplex-chat/simplex-chat/releases) + - [desktop](#desktop-app) - [mobile](#mobile-apps) - [terminal](#terminal-console-app) (console) diff --git a/docs/WHY.md b/docs/WHY.md new file mode 100644 index 0000000000..e1582984df --- /dev/null +++ b/docs/WHY.md @@ -0,0 +1,19 @@ +# Why we are building SimpleX Network + +You were born without an account. + +Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature — it was the way of life. + +Then we moved online, and every platform asked for a piece of you — your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way — telephone, email, messengers, social media. It seemed the only way possible. + +There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + +Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it — you are sovereign. + +Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + +The oldest human freedom — to speak to another person without being watched — built on infrastructure that cannot betray it. + +Because we destroyed the power to know who you are. So that your power can never be taken. + +Be free in your network. diff --git a/docs/contributing/CODE.md b/docs/contributing/CODE.md index 7ae6d176ac..1dcf795c00 100644 --- a/docs/contributing/CODE.md +++ b/docs/contributing/CODE.md @@ -2,6 +2,12 @@ This file provides guidance on coding style and approaches and on building the code. +## Code Security + +When designing code and planning implementations: +- Apply adversarial thinking, and consider what may happen if one of the communicating parties is malicious. +- Formulate an explicit threat model for each change - who can do which undesirable things and under which circumstances. + ## Code Style, Formatting and Approaches The project uses **fourmolu** for Haskell code formatting. Configuration is in `fourmolu.yaml`. @@ -38,9 +44,16 @@ Some files that use CPP language extension cannot be formatted as a whole, so in **Diff and refactoring:** - Avoid unnecessary changes and code movements +- Never rename existing variables, parameters, or functions unless the rename is the point of the change - Never do refactoring unless it substantially reduces cost of solving the current problem, including the cost of refactoring - Aim to minimize the code changes - do what is minimally required to solve users' problems +**Type-driven development:** +- Types must reflect business semantics, not data shape. E.g., `CIChannelRcv` (channel message) vs `CIGroupRcv GroupMember` (member message) are semantically distinct — do not collapse them into `CIGroupRcv (Maybe GroupMember)` just because the data overlaps. Duplicate pattern match arms across semantic constructors are acceptable. +- Duplicate function bodies are not acceptable. When adding a new variant of existing behavior, parameterize existing functions to handle both variants — do not copy function bodies into parallel code paths. +- Concrete example: if `groupMessageFileDescription` and `channelMessageFileDescription` share 90% of their logic, extract a shared helper and make both into thin wrappers — do not maintain two near-identical function bodies. +- When the return type differs between variants (e.g., one returns `Maybe X`, another returns `()`), use the more general return type and have callers discard what they don't need. + **Document and code structure:** - **Never move existing code or sections around** - add new content at appropriate locations without reorganizing existing structure. - When adding new sections to documents, continue the existing numbering scheme. diff --git a/docs/rfcs/2025-04-14-signing-messages.md b/docs/rfcs/2025-04-14-signing-messages.md index 8845de0cd8..1ad6e6778f 100644 --- a/docs/rfcs/2025-04-14-signing-messages.md +++ b/docs/rfcs/2025-04-14-signing-messages.md @@ -81,3 +81,13 @@ Cons: - two-stage decoding may be seen as a downside, but it is offset by the fact that re-encodings are avoided, and under the hood JSON is decoded in stages anyway. While deterministic JSON is [quite simple](https://github.com/simplex-chat/aeson/pull/4/files) for aeson implementation, the Option 2 seems more attractive overall, as it avoids questionable design of including signatures into JSON and the need to re-encode JSON to sign and to verify signatures. + +## Signing scope: roster changes only, not content messages + +Only roster-modifying and group management messages are signed (e.g. `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`). Regular content messages (`XMsgNew`, etc.) are not signed. + +Two reasons: + +1. **Deniability.** Signing content messages would create non-repudiable proof of authorship — any party with access to the message bytes could prove who wrote a specific message. This is antithetical to SimpleX's privacy model, where messages should be deniable. Administrative actions (adding/removing members, changing roles) don't need deniability — they are organizational actions, not personal communications. + +2. **Different threat model.** Content message manipulation by relays is detectable post-hoc: with multiple independent relays, members can cross-check message consistency and detect forgery after the fact. This is sufficient for content because content delivery is not irreversible — a forged message can be flagged and corrected. Roster and profile changes, on the other hand, are disruptive and irreversible (a member removed, a role changed, a group deleted). By the time forgery is detected, the damage is done. These actions must be authenticated at processing time, before they take effect. diff --git a/docs/rfcs/2025-10-20-chat-relays.md b/docs/rfcs/2025-10-20-chat-relays.md new file mode 100644 index 0000000000..d1a3180b70 --- /dev/null +++ b/docs/rfcs/2025-10-20-chat-relays.md @@ -0,0 +1,304 @@ +# Chat relays + +## Security objectives + +Group relay protocol should achieve following objectives: +1. Stable message delivery between group members. +2. No possibility for relay to substitute group. +3. No possibility for relay to impersonate owner(s). +4. Prevent relay from altering member roster (member removal, role change, etc.). +5. Prevent relay from terminally destabilizing group by stopping to serve it. At the same time, allow owner to remove (last) relay with possibility to restore group functionality. +6. Allow owner(s) to send messages as "message from channel", hiding specific sender out of multiple owners from members. +7. Prevent relays from altering/dropping messages. + +## Protocol for adding chat relays to group + +Activations (execution bars) with looped arrows indicate internal calls/steps. + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay(s) + participant RSMP as Chat relays'
SMP server(s) + +note over O, RSMP: Owner creates new group, adds chat relays + +activate O +O ->> O: 1. Create new group
(user action) +O ->> O: 2. Prepare group link,
owner key,
group ID (agent) +O ->> O: 3. Add link, owner key
to group profile, sign +O ->> OSMP: 4. Create group link,
signed profile as data +deactivate O +OSMP -->> O: Group link created +activate O +O ->> O: 5. Choose chat relays
(automatic/user choice) +note left of O: Relay status: New +par With each relay + O ->> R: 6. Contact request
(x.grp.relay.inv
incl. group link) + deactivate O + activate R + note left of O: Relay status: Invited + note right of R: Relay status: Invited + R ->> OSMP: 7. Retrieve group link data + deactivate R + OSMP -->> R: Group link data + activate R + R ->> R: 8. Validate group profile,
verify profile signature + opt Bad profile or signature + R -x R: Abort (reject) + end + R ->> RSMP: 9. Create relay link,
set group ID
in immutable data + deactivate R + RSMP -->> R: Relay link created + activate R + R ->> O: 10. Accept request
(x.grp.relay.acpt
incl. relay link) + deactivate R + activate O + note right of R: Relay status: Accepted + note left of O: Relay status: Accepted + note over O, R: RPC connection
with relay is ready + opt Protocol extension - 2 connections + O ->> R: * Connect via relay link
(share same owner key) + deactivate O + R -->> O: Accept messaging connection + activate O + note right of R: Relay status: Accepted,
"Connected" implied from
messaging connection + note left of O: Relay status: Accepted,
"Connected" implied from
messaging connection + note over O, R: Owner: Messaging connection with relay is ready,
relay link is tested + end + create participant M as Member + R --> M: + note over R, M: At this point relay can accept
connection requests from members + O ->> RSMP: 11. Retrieve relay link data + deactivate O + RSMP -->> O: Relay link data + activate O + O ->> O: 12. Validate group ID
in relay link data + opt Bad group ID + O -x O: Abort for relay (don't add) + end + O ->> OSMP: 13. Update group link
(add relay link) + deactivate O + OSMP -->> O: Group link updated + note left of O: Relay status: Active +end + +note over O, M: Chat relay checks link - monitoring + +loop Periodically + R ->> OSMP: Retrieve group link data for served gorup + OSMP -->> R: Group link data + activate R + R ->> R: Check relay link present + deactivate R + note right of R: Relay status: Active +end + +note over O, M: New member connects + +O -->> M: 14. Share group link
(social, out-of-band) +M ->> OSMP: 15. Retrieve short link data +par RPC connection + M ->> R: 16a. Connect via relay link +and + opt Protocol extension - Messaging connection + M ->> R: 16b*. Connect via relay link
(share same member key/
identifier to correlate) + end +end + +note over O, M: Message forwarding + +O ->> R: 17. Send message +R ->> M: 18. Forward message +activate M +M ->> M: 19. Deduplicate message +deactivate M +``` + +Notes: + +- Group ID - unique group identifier (not globally unique) baked in immutable part of group link data, and repeated by chat relays in immutable parts of respective relay links. + + Owner can validate they're adding relay link to the group link specifically for their group. + + Members can validate they join relay links corresponding to group link they connected to. + +- Protocol extension: Create connections pairs between relay and members with different priority for passing regular messages and for relay responding to member requests. + + Invitation sent in step 12 should contain same key as in group link, for relay to match connection to the same owner and "active" relay link (add to `XContact` message). + + Add new connection entity, special for groups with relay, referencing member record - parallel to first member connection. + +- Client can "know" link that will be created before creating it on server - so we can add it to profile before adding profile to group short link data. + + Agent to return link that will be created upon preparing connection record. + +- On adding group short link to group profile. + + Strengthens association between link and profile. Link already contains profile in attached data, but from perspective of group profile link itself is detached. All members "see" the same link they joined via in group profile. Chat relays "see" the same link they created relay links for, and can check it for presence of their relay link at any point. + + Link is recoverable from profile, e.g. for purpose of restoring connection with group via new chat relays. + + Overall it just seems a natural and convenient way to store group link for all members, rather than having it separately. + +- On updating group link data with one relay link at a time vs waiting for all links. + + Overhead is minimal - one request to owner's SMP server per relay. + + Waiting for a relay to send relay link can take indefinitely long. + + In proposed protocol owner doesn't have to wait for links from all relays for simplicity and to minimize wait time - it allows owner to conclude group creation potentially earlier, in case some relays are stuck or offline (owner can add their links later, once they successfully send it). + +- Lock owner group link from accepting connection on SMP server, possibly has some implementation gaps. + + Reject in owner code for foolproofing. + +- What should be in relay link user data: + + - Relay key for group. + - Relay identity if provided. + Operator relays want to provide identity for trust. + User relays may not want to provide identity. + Relay identity: profile, certificate, relay identity key (global across groups). + +## Protocol for removing chat relay from group, restoring connection to group + +```mermaid +sequenceDiagram + participant O as Owner + participant OSMP as Owner's
SMP server + participant R as Chat relay + participant RSMP as Chat relay
SMP server + participant M as Member + +note over O, M: Owner deletes chat relay, notifies relay + +O ->> OSMP: Remove relay link
(update group link data) +O ->> R: Delete chat relay
(x.grp.mem.del)
over RPC connection +par Chat relay to SMP + R ->> RSMP: Delete relay link +and Chat relay to members + R ->> M: Forward relay is deleted
over RPC connection +end + +note over O, M: Scenario 2. Owner deletes chat relay, fails to notify relay + +O ->> OSMP: Remove relay link
(update group link data) +O --x R: Fail to notify relay +opt Chat relay identifies
connection with owner is deleted + par Chat relay to SMP + destroy RSMP + R ->> RSMP: Delete relay link + and Chat relay to members + destroy R + R ->> M: Notify relay is deleted
over RPC connection + end +end + +note over O, M: Last relay is deleted + +O --x M: Owner can't send messages to members +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +activate M +M -x M: Members can't restore connection to group +deactivate M + +note over O, M: Restore connection to group + +create participant NR as New chat relay +O <<->> NR: Add new relay, relay creates and sends link +O <<->> OSMP: Update group link
(add relay link) +activate M +M ->> M: Attempt to restore
connection to group (manual) +M ->> OSMP: Retrieve group link data +deactivate M +OSMP -->> M: Group link data +par RPC connection + M ->> NR: Connect via relay link +and Messaging connection + M ->> NR: Connect via relay link
(share same member key/
identifier to correlate) +end +O ->> NR: Send message +NR ->> M: Forward message +activate M +M ->> M: Deduplicate message +deactivate M +``` + +Notes: + +- New relay doesn't have group history. + + - We can prohibit to remove last relay without adding new one. + - Relays can synchronize history. + - Can be considered after MVP. + +## Correlation of design objectives with design elements + +1. Redundant delivery by multiple relays. High availability of relay clients. +2. Same group ID baked in immutable data of group link and relay links. +3. Owner public key in group link. +4. Actions altering member roster can be signed by owner key, verified by members. +5. Protocol for restoring connection to group by checking group link for new relays. +6. XMsgNew protocol extension - "message from channel" flag - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). +7. Redundant delivery by multiple relays, highlighting deduplicated messages differences - see [channels forwarding rfc](./2025-08-11-channels-forwarding.md). + +## Threat model + +**Single compromised chat relay / Colluding chat relays** + +can: +- effectively substitute group bar group ID and signed profile, by sending unsigned content from other group (or any arbitrary content), that doesn't require signature verification, such as regular messages. + - one way this could be further mitigated is requiring owner to sign all messages. + - owner could periodically sign message history as merkle dag. +- selectively drop any content or service messages from owner, including actions altering member roster. +- selectively drop messages for some of members. + +cannot: +- technically, redirect newly joining member to a different group. +- substitute group profile. +- impersonate owner, send any member message that requires signature. + +**Compromised chat relay (in situation where not all relays are compromised/colluding)** + +can: +- in case number of compromised relays is same as number of uncompromised ones, compromised relay(s) can drop messages or send arbitrary unsigned messages, misleading members from identifying which relays are compromised. +- ignore "message from channel" directive from owner, revealing which owner sent message. + - this can be revealed to owner by members out-of-band. +- fabricate new members, possibly inflating counts/costs for owner (depends on implementation). + - it can be identified that these imaginary members don't connect to other relays. + +**Member** + +can: +- infer which owner sent message as "message from channel", if group has a single owner. + - owner client should prohibit this option if group has a single owner. + +**Any client** + +can: +- connect to group unlimited number of times, inflating real counts/costs. + +## TODO list + +- Chat commands for creating group with relays. +- Protocol events processing. +- Recovery for both owner and relay when adding relay to group. +- On each subscription retrieve group link data for all groups, actualize connections for present relay links. +- Agent `prepareConnectionToJoin` api to return link that will be created. +- Asynchronous version of agent `setConnShortLink` api, correlation in chat. +- Agent to support adding relays to link (it has stub `relays :: [ConnShortLink 'CMContact]`). +- New connection entity for secondary member-in-relayed-group connection - priority/messages connections. +- Differentiate connection usage by priority in chat logic (receiving messages vs sending requests to relay). +- Finalize model - statuses, schema. +- UI for relay management (user level, similar to list of servers). +- UI for creating group with relays. +- UI for managing relays in group. +- Relay status updates events on adding relays for UI integration. +- Relay removal. +- Relay periodic checks for monitoring relay link presence. diff --git a/docs/rfcs/2026-01-08-relays-new-member-connection.md b/docs/rfcs/2026-01-08-relays-new-member-connection.md new file mode 100644 index 0000000000..471f4ed53f --- /dev/null +++ b/docs/rfcs/2026-01-08-relays-new-member-connection.md @@ -0,0 +1,99 @@ +# Connection of new member to chat relays + +## Problem + +Naive implementation of new member connection to chat relays can lead to partial failures (some relays fail to connect), or requires recovery or clean up. + +After group record is prepared from short link, naive flow is as follows (APIConnectPreparedGroup): + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: + -> Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Join connection (sync joinConnection) +``` + +Orthogonal smaller problem: + +If new member chooses to connect to group incognito, same incognito profile should be sent to all group relays. + +## Solution + +### Join Connection step + +"Join connection" is the main step, let's consider it first. + +#### Option 1: Synchronous approach with catches + recovery + +Keep all relay connections synchronous, catch on failure to continue for remaining relays, recovery for failed relays. All relays failing would mean full command failure, offer user retry. + +For partial failures it would require to track which relays succeeded/failed, then trigger recovery, basically recreating what asynchronous command processing already does. + +#### Option 2: First relay sync, then async + +Connect to first relay synchronously, connect to remaining asynchronously (using joinConnectionAsync). + +Choice of "first" relay is arbitrary and we may be choosing the one with worse network. + +Mixed (double) implementation - for "first" and remaining relays. + +#### Option 3: All relays async + +In this case agent already handles connection reliability, downside is no immediate failure visible to user on temporary network errors for all relays (for example, client is offline). + +UI already handles "connecting..." state, so async path doesn't hurt UX much other than in mentioned case. UI stays in "connecting..." until at least one relay connection succeeds. + +If all relay connections permanently fail, update state for UI - requires permanent error handling for connection creation on continuation (agent responses in Subscriber). Track relay connection states to detect "all failed", possibly on connection status, TBC at implementation. + +Pros: +- Simple flow: loop through relays, start async connections. +- Async agent commands provide recovery. + +### Link fetches + +We considered handling retries for Join step, but no retry mechanism for link fetch. If it's synchronous and fails for a given relay, it would result in permanent failure to connect to relay, without additional recovery logic. + +#### Option 1: Asynchronous command with continuation + +New agent asynchronous command + complexity in chat Subscriber logic. Seems overkill. + +#### Option 2: Per-relay "relay connection" worker + +An additional state machine, possibly based on relay member records as work items. Also overkill. + +#### Option 3: Make all link fetches synchronously before proceeding + +To avoid adding background recovery mechanisms for link fetching per relay, we could fetch all links data synchronously, and only then connect to relays asynchronously. + +In case any relay link fetch fails, user would be given option to retry. (Whole operation fails and is retried) + +Group link fetch is also synchronous (retrieve list of relay links), and also leads to immediate user retry. + +### On the incognito profile issue + +This should be addressed regardless of which approach to connection we choose. The incognito profile should be: + +1. Created once before starting any relay connections; +2. Passed to all relays on connection attempts. + +In case of synchronous approach and re-use of existing logic, it means `connectViaContact` should accept an optional profile (not just flag). + +### Overall proposed connection flow + +``` +User clicks "Connect" + -> Fetch relay links from group link (sync getConnShortLink) + -> For each relay: Fetch ConnectionRequestUri from relay link (sync getConnShortLink) + -> Once all links are resolved, proceed - create incognito profile ONCE for all relays, if needed + -> For each relay: Start async connection attempt (joinConnectionAsync) + -> Agent handles connection retries internally + -> Subscriber handles JOINED events and errors for each relay + - At least one relay JOINED -> group becomes functional + - All relays permanently fail -> show failure to user +``` + +Link fetches being synchronous in conjunction with asynchronous relay connections allows for similar UI reactivity to current single-connection flows: +- Network failures during link fetches require user retry; +- Connection attempts are retried by agent on network failures; +- Link fetches passing ensures client is not offline when starting async connection attempts (unless user goes offline in-between, but window is very small, and connections would be retried anyway). diff --git a/docs/rfcs/2026-01-23-member-keys-plan.md b/docs/rfcs/2026-01-23-member-keys-plan.md new file mode 100644 index 0000000000..b278cf0c32 --- /dev/null +++ b/docs/rfcs/2026-01-23-member-keys-plan.md @@ -0,0 +1,652 @@ +# Implementation Plan: Member Keys and Signatures for Simplex Chat + +## Overview + +Add cryptographic signatures to Simplex Chat messages to prevent relay impersonation and roster manipulation in public groups with chat relays. + +## Design Approach + +Following **RFC Option 2: Multi-stage encoding** (recommended in docs/rfcs/2025-04-14-signing-messages.md): +- Encoded JSON body (non-deterministic key ordering OK) +- Conversation binding (group root key + sender member ID for groups) +- Array of (key reference, signature) tuples + +## Key Files to Modify + +### Core Types +- `src/Simplex/Chat/Types.hs` - Add `MemberKey` type, add `memberKey` to `MemberInfo` +- `src/Simplex/Chat/Protocol.hs` - Add member keys to `XMember`, `XGrpLinkMem`; signed message envelope, encoding/decoding + +### Protocol Handling +- `src/Simplex/Chat/Library/Commands.hs` - Sign messages when sending +- `src/Simplex/Chat/Library/Subscriber.hs` - Verify signatures when receiving +- `src/Simplex/Chat/Library/Internal.hs` - Chat-level signature utilities (working with Member profiles, messages) + +### Agent API (simplexmq repo) - New Functions +- `../simplexmq/src/Simplex/Messaging/Agent.hs`: + - `prepareConnectionLink` - NEW: commits to server, generates link address + root key locally (no network) + - `createConnectionWithPreparedLink` - NEW: accepts server + root key, creates queue (single network call) +- `../simplexmq/src/Simplex/Messaging/Agent/Client.hs` - Implement new functions + +### Database +- New migration: `src/Simplex/Chat/Store/SQLite/Migrations/M20260124_member_keys.hs` +- New migration: `src/Simplex/Chat/Store/Postgres/Migrations/M20260124_member_keys.hs` +- `src/Simplex/Chat/Store/Profiles.hs` - Store/retrieve member keys + +## New Types + +### 1. Member Key Type (Types.hs) + +```haskell +newtype MemberKey = MemberKey C.PublicKeyEd25519 + deriving (Eq, Show) + +-- IMPORTANT: memberKey is NOT in Profile - profiles can be updated independently +-- Member keys are fixed at join time and sent via member announcement messages + +-- Add memberKey to MemberInfo (used in XGrpMemNew, XGrpMemIntro, XGrpMemFwd) +data MemberInfo = MemberInfo + { memberId :: MemberId, + memberRole :: GroupMemberRole, + v :: Maybe ChatVersionRange, + profile :: Profile, + memberKey :: Maybe MemberKey -- NEW: member's signing key + } + deriving (Eq, Show) +``` + +### 2. Protocol Messages with Member Keys (Protocol.hs) + +Member keys are communicated via member identification/announcement messages, NOT profile updates: + +```haskell +-- Member self-identification when joining group +-- newMemberKey is required (not Maybe) - every new member must have a key +XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey} -> ChatMsgEvent 'Json + +-- Member joining via group link +XGrpLinkMem :: Profile -> Maybe MemberKey -> ChatMsgEvent 'Json + +-- Member announcements use MemberInfo which now includes memberKey +-- XGrpMemNew, XGrpMemIntro, XGrpMemFwd all use MemberInfo + +-- Profile updates do NOT include memberKey - key is fixed at join time +XGrpMemInfo :: MemberId -> Profile -> ChatMsgEvent 'Json -- unchanged +``` + +**Key points:** +- `XMember.newMemberKey` is required (not Maybe) - joining member must provide key +- `XGrpLinkMem` has `Maybe MemberKey` for backward compatibility +- `MemberInfo.memberKey` is `Maybe` for backward compatibility with existing members +- Profile updates (`XGrpMemInfo`) don't include key - it's fixed at join time + +### 3. Member Key Storage + +- Private key stored in `groups.member_priv_key` (current user's signing key for this group) +- Public key stored in `group_members.member_pub_key` (for all members) +- NOT stored in profiles table - member keys are per-group, not per-profile + +### 4. Signed Message Types (Protocol.hs) + +Types as implemented in Protocol.hs: + +```haskell +-- Key reference tag — indicates which key to use for verification. +-- KRMember means "use the contextual member's key" (sender or forwarded author). +-- Can be extended to support profile identity keys (e.g., secp256k1 for Nostr). +data KeyRef = KRMember + deriving (Eq, Show) + +-- Conversation binding for signature scope +data ChatBinding + = CBDirect {securityCode :: ByteString} + | CBGroup {groupRootKey :: C.PublicKeyEd25519, senderMemberId :: MemberId} + deriving (Eq, Show) + +-- Signature with key reference +data MsgSignature = MsgSignature KeyRef C.ASignature + deriving (Show) + +-- Signatures with chat binding +data MsgSignatures = MsgSignatures + { chatBinding :: ChatBinding, + signatures :: NonEmpty MsgSignature + } + +-- Field order matches wire format: forward data (> prefix), then sig data (/ prefix), then message ({ prefix) +data ParsedMsg = ParsedMsg (Maybe MsgForwardData) (Maybe MsgSigData) AChatMessage + +data MsgSigData = MsgSigData + { signatures :: MsgSignatures, + signedBody :: ByteString -- exact bytes that were signed + } + +data MsgForwardData = MsgForwardData + { fwdMemberId :: MemberId, + fwdMemberName :: ContactName, -- may be empty + fwdBrokerTs :: UTCTime + } +``` + +**Key insight:** The binary batch format preserves the exact bytes of each element via length-prefix framing, enabling signature verification even after the message has been parsed. This is critical for forwarded messages. + +### 5. Key Resolution and Validation + +```haskell +-- Key resolution: lookup member's public key from GroupMember record +resolveKeyRef :: GroupInfo -> KeyRef -> Either String C.APublicVerifyKey +resolveKeyRef gInfo (KRMember mid) = + case findMemberByMemberId mid gInfo >>= memberKey of + Just (MemberKey k) -> Right $ C.APublicVerifyKey C.SEd25519 k + Nothing -> Left $ "unknown member key: " <> show mid + +-- findMemberByMemberId looks up GroupMember by MemberId in GroupInfo +-- memberKey is stored in GroupMember record (from group_members.member_pub_key) + +-- Owner validation: verify member's key matches OwnerAuth chain +-- Called when processing roster-modifying messages from owners +validateOwnerMember :: GroupInfo -> MemberId -> MemberKey -> Either String () +validateOwnerMember gInfo memberId memberKey = do + case findOwnerAuth memberId (groupOwners gInfo) of + Nothing -> Left "member is not an owner" + Just OwnerAuth {ownerId, ownerKey} -> do + when (ownerId /= memberId) $ + Left "owner ID mismatch" + case memberKey of + MemberKey k | k == ownerKey -> Right () + _ -> Left "owner key doesn't match member key" +``` + +### Owner Verification Strategy (future multi-owner support) + +**Question:** How to validate that a member is a legitimate owner? + +**Option A: Request link data from server** +- Fetch current `UserContactData.owners` from SMP server +- Expensive: network roundtrip for each verification + +**Option B: Store OwnerAuth chain locally, verify via signatures** ✓ +- When joining group: receive OwnerAuth chain (from link data or group info) +- When new owner added: receive signed OwnerAuth (signed by root or existing owner) +- Verify locally using signature chain - no network needed +- Store chain in `group_owners` table + +**Current implementation (single owner):** +- Group creator is sole owner +- OwnerAuth created at group creation, stored in link data +- Members receive owner info when joining +- No multi-owner support yet (deferred) + +### 6. Message Batching Analysis + +Analysis of current batching behavior (determines new format requirements): + +**Q1: Can there be multiple compressed parts in one wire message?** + +**NO** - only ONE compressed block is ever created. +- `compressedBatchMsgBody_` (Protocol.hs:712) creates singleton list: `(L.:| []) . compress1` +- Called only in Internal.hs:1901 (connection info) and Internal.hs:1941 (message body) +- Decoder supports `NonEmpty Compressed` for forward compatibility, but encoding always produces 1 block + +**Q2: Can messages from multiple members be batched together?** + +**YES** - in both relay and non-relay groups: +- Relay groups: Delivery.hs:168-184 - `getNextDeliveryTasks` does NOT filter by sender +- Non-relay groups: `sendHistory` (Internal.hs:1171-1184) batches history items from multiple senders + +**Q3: Can forwarded and non-forwarded messages be batched together?** + +**YES** - in `sendHistory` (Internal.hs:1176-1184): +- `XMsgNew` (welcome/description) appended to `XGrpMsgForward` events +- Both sent together via `sendGroupMemberMessages` + +### 7. Wire Format (Protocol.hs) + +#### Current Format (JSON-based batching) + +```abnf +; Current wire format +wireMessage = compressedMsg / jsonMsg +compressedMsg = %s"X" compressedBlock ; single compressed block +jsonMsg = singleJson / jsonArray +singleJson = %s"{" *OCTET ; single JSON object +jsonArray = %s"[" *OCTET ; JSON array of messages +``` + +JSON array batching uses `[msg1,msg2,...]` format - simple but cannot preserve exact bytes for signatures. + +#### New Format (Binary batching for signatures) + +For relay-based groups where signatures are required, use binary batching that preserves exact message bytes: + +```abnf +; Extended wire format (parser accepts all formats) +wireMessage = compressedMsg / binaryBatch / jsonMsg + +; New binary batch format - preserves exact bytes for signature verification +binaryBatch = %s"=" elementCount *batchElement +elementCount = 1*1 OCTET ; 1-255 elements +batchElement = elementLen elementBody +elementLen = 2*2 OCTET ; 16-bit big-endian length +elementBody = signedElement / forwardElement / plainElement + +; Signed element - signatures followed by JSON body +signedElement = %s"/" msgSignatures jsonBody +jsonBody = *OCTET ; JSON bytes (length from elementLen) + +; Forward element - relay forwarding with preserved bytes (relay groups only) +; originalBytes is a nested element (signed or plain, but NOT another forward) +forwardElement = %s">" forwardMeta originalElement +forwardMeta = senderMemberId senderMemberName brokerTs +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalElement = signedElement / plainElement + +; Plain message element - starts with '{' (JSON object) +plainElement = jsonBody + +; Signature data (no '/' prefix — the element prefix serves that role) +msgSignatures = chatBinding sigCount *msgSignature +chatBinding = directBinding / groupBinding +directBinding = %s"D" securityCode +securityCode = shortString +groupBinding = %s"G" groupRootKey senderMemberId +groupRootKey = 32*32 OCTET ; Ed25519 public key +senderMemberId = shortString + +sigCount = 1*1 OCTET ; 1-255 signatures +msgSignature = keyRef sigBytes +keyRef = memberKeyRef +memberKeyRef = %s"M" ; use contextual member's key (sender or forwarded author) +sigBytes = 64*64 OCTET ; Ed25519 signature + +shortString = length *OCTET +length = 1*1 OCTET + +; Compressed format unchanged - compression wraps the batch +compressedMsg = %s"X" compressedBlock +; After decompression: binaryBatch / jsonMsg +``` + +**Overhead comparison:** +- JSON array: `[` + `]` + `,` between = n+1 bytes for n elements +- Binary batch: `=` + count + 2-byte length per element = 1 + 1 + 2n = 2 + 2n bytes +- Difference: ~1 extra byte per element - acceptable for signature support + +**Format selection:** +- Relay-based groups: Use binary batch (`=` prefix) - preserves bytes for signatures +- Non-relay groups: Use JSON array (`[...]`) - backward compatible, no signatures needed +- Old groups with old members: Use JSON array - full backward compatibility + +**Parser behavior (`parseChatMessages`):** +- `'='` prefix → binary batch (new format) +- `'{'` prefix → single JSON object +- `'['` prefix → JSON array +- `'X'` prefix → compressed (decompress, then re-parse) +- All formats accepted regardless of version for forward/backward compatibility + +**Batcher behavior (`Messages/Batch.hs`):** +- Accept `BatchMode` parameter: `BMJson` or `BMBinary` +- `BMJson`: Current JSON array encoding +- `BMBinary`: Binary format with length prefixes, preserves exact bytes + +```haskell +data BatchMode = BMJson | BMBinary + +batchMessages :: BatchMode -> Int -> [Either ChatError SndMessage] -> [Either ChatError MsgBatch] +-- batchDeliveryTasks1 hardcodes BMBinary (relay groups only) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +``` + +**Key insight:** The binary batch format allows: +1. Each element's exact bytes preserved (length-prefixed, not re-encoded) +2. Mixed signed/unsigned elements in same batch +3. Forwarded messages preserve original sender's signature +4. Relay adds no signature - just wraps in forwarding envelope + +**Forwarding in binary batch (relay groups):** + +For relay-based groups, forwarding is NOT via `XGrpMsgForward` ChatMsgEvent (which would re-encode the inner message). Instead, forwarding uses a **binary batch element format** (`forwardElement` in the ABNF above) that preserves exact bytes: + +```abnf +; Forward element details (defined in batchElement above) +forwardElement = %s">" forwardMeta originalBytes +forwardMeta = senderMemberId senderMemberName brokerTs +senderMemberId = shortString +senderMemberName = shortString ; may be empty +brokerTs = 8*8 OCTET ; UTC timestamp, big-endian microseconds +originalBytes = *OCTET ; original signed message bytes (verbatim) +``` + +Forward elements only appear inside binary batches — there is no standalone forward envelope at the wire level. + +**Flow:** + +1. **Sender** creates signed message: + ``` + /<{"event":"x.msg.new",...}> + ``` + +2. **Relay** receives, parses to validate, stores original bytes in `msg_body` + +3. **Relay** forwards as binary batch element(s): + ``` + =( ">" )* + ``` + +4. **Recipient** parses binary batch, extracts `originalBytes` from forward elements, verifies sender's signature + +**Key difference from current approach:** +- Current: `XGrpMsgForward` nests **parsed** `ChatMessage 'Json` → re-encoded on send → bytes change +- New: Forward element contains **original element bytes** (`/` or `{`) → never re-encoded → signature remains valid +- Forward nesting is guarded: `elementP` rejects nested forward elements (`>` inside `>`) + +**Backward compatibility:** +- Old groups (non-relay): Continue using `XGrpMsgForward` ChatMsgEvent (JSON array batching) +- New relay groups: Use binary batch with forward elements (`>` prefix inside `=` batch) +- `XGrpMsgForward` JSON call site passes `Nothing` for `msgSig_` (no signature data available in JSON path) +- Parser accepts both formats + +**Key resolution:** +- `'M'` (member key ref): Use the contextual member's public key from `group_members.member_pub_key` — the sender (direct messages) or forwarded author (forward elements) + +## Messages Requiring Signatures + +### Owner/Admin Signatures (roster changes) +- `XGrpRelayInv` - Owner inviting relay (relay validates) +- `XGrpMemNew` - Adding new member +- `XGrpMemRole` - Changing member role +- `XGrpMemDel` - Removing member +- `XGrpInfo` - Updating group profile +- `XGrpPrefs` - Updating group preferences +- `XGrpDel` - Deleting group + +### Content messages — NOT signed +- `XMsgNew` and other content messages are not signed to preserve deniability. Relay manipulation of content is detectable post-hoc via cross-relay consistency. + +## Database Migration + +```sql +-- SQLite migration M20260124_member_keys.hs + +-- Group-level keys (current user's keys for this group) +ALTER TABLE groups ADD COLUMN shared_group_id BLOB; -- saved in link fixed data as entity ID +ALTER TABLE groups ADD COLUMN root_priv_key BLOB; -- root private key (only if user is the owner and group creator) +ALTER TABLE groups ADD COLUMN root_pub_key BLOB; -- needed for all members of public groups to verify ownership chains +ALTER TABLE groups ADD COLUMN member_priv_key BLOB; -- current user's member private key for this group + +-- Member public keys (for all members, including current user) +-- Public key is sent via MemberInfo/XMember and stored for signature verification +ALTER TABLE group_members ADD COLUMN member_pub_key BLOB; -- public key (all members) + +-- Note: root_priv_key is the root key from group link (immutable group identity), only for owner/creator +-- Note: root_pub_key is needed for all members of public groups to verify ownership chains +-- Note: member_priv_key is the current user's signing key for this group (unique per group) +-- Note: member_pub_key is received via MemberInfo (XGrpMemNew, etc.) or XMember message +``` + +## Root Key Management (Analysis Required) + +Currently, root key is generated in Agent (`ShortLinkCreds.linkPrivSigKey`) and stored in agent schema (`rcv_queues.link_priv_sig_key`). + +For Chat to sign owner messages, we need access to either: +- The root key (for initial owner) +- The owner key (for subsequent owners in chain) + +**Current Problem: Two-Step Group Creation (2 roundtrips)** + +Current flow in Commands.hs: +1. Chat creates connection → server roundtrip → gets link +2. Chat updates group profile to include link +3. Chat updates link data → another server roundtrip + +Problems: +- Double requests increase latency +- Risk of failing halfway (needs recovery management) +- Can't include signed owner key in initial link data + +**Solution: New Agent API with Prepare + Create Pattern** + +Two new Agent functions: + +```haskell +-- Prepared link data returned by prepare step (NO network, NO database) +-- Contains everything needed to: (a) construct the short link, (b) create the connection later +data PreparedConnLink c = PreparedConnLink + { pclServer :: SMPServerWithAuth, -- Committed server from config + pclNonce :: C.CbNonce, -- Nonce (corrId) - determines sender ID + pclRootKeyPair :: C.KeyPairEd25519, -- Root signing key for link + pclE2eKeyPair :: C.KeyPairX25519, -- E2E DH key for queue address + pclFixedLinkData :: FixedLinkData c, -- Contains connReq (with ratchet params for invitations) + pclLinkKey :: LinkKey, -- Derived from FixedLinkData: sha3_256(encoded fixedData) + pclPrivSigKey :: C.PrivateKeyEd25519 -- For signing link data (same as snd of pclRootKeyPair) + } + +-- 1. prepareConnectionLink: Generates all link parameters locally (NO network, NO database) +-- Returns PreparedConnLink + the actual short link that can be used in addresses +prepareConnectionLink :: ConnectionModeI c + => AgentClient -> UserId -> SConnectionMode c -> Maybe CRClientData -> CR.InitialKeys + -> AM (PreparedConnLink c, ConnShortLink c) +-- Does: +-- - Selects server from config (getSMPServer) +-- - Generates nonce, derives sender ID: sha3_384(corrId)[:24] +-- - Generates root key pair (Ed25519) for signing +-- - Generates e2e DH key pair (X25519) for queue address +-- - For invitations: generates E2E ratchet params +-- - Builds ConnectionRequestUri (contains queue address + ratchet params) +-- - Builds FixedLinkData (contains connReq + rootKey + agentVRange) +-- - Derives linkKey = sha3_256(encoded fixedData) +-- - Constructs ConnShortLink (CSLContact or CSLInvitation) with linkKey +-- Returns (PreparedConnLink, ConnShortLink) - both can be roundtripped, nothing saved + +-- 2. createConnectionWithPreparedLink: Creates connection using prepared link +-- Single network call to create queue with pre-determined sender ID +createConnectionWithPreparedLink :: ConnectionModeI c => + AgentClient -> NetworkRequestMode -> UserId -> Bool -> Bool -> + PreparedConnLink c -> UserConnLinkData c -> SubscriptionMode -> + AM (ConnId, (CreatedConnLink c, Maybe ClientServiceId)) +-- Accepts: +-- - PreparedConnLink from prepare step (contains all crypto material) +-- - UserConnLinkData with signed OwnerAuth array (mutable part) +-- Does: +-- - Uses pclNonce to get deterministic sender ID +-- - Creates connection record (newConnNoQueues) +-- - Creates queue on server with prepared nonce → same sender ID +-- - Encrypts & uploads link data (fixed + user data) +-- Returns same as createConnection +``` + +**Key insights (from RFC 2025-03-16-smp-queues.md):** +- Sender ID = `sha3_384(nonce)[:24]` - derived locally from correlation ID (nonce) +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` = `sha3_256(encoded fixedData)` - derived from fixed data hash +- For **contact addresses**: `(link_id, enc_key) = HKDF(link_key, 56)` - fully deterministic +- For **1-time invitations**: `link_id` is server-generated, `enc_key = HKDF(link_key, 32)` +- Public groups use contact mode → short link address fully known at prepare step +- Everything can be roundtripped - no database needed for prepare step + +**New Flow (single roundtrip):** + +```haskell +-- In Chat (Commands.hs) when creating public group: +createPublicGroupWithRelays :: ... -> CM GroupInfo +createPublicGroupWithRelays ... = do + -- 1. Prepare link parameters (NO network, NO database) + -- Returns PreparedConnLink + the short link for use in group address + (preparedLink@PreparedConnLink {pclRootKeyPair = (rootPubKey, rootPrivKey)}, shortLink) <- + prepareConnectionLink c userId SCMContact clientData pqInitKeys + + -- 2. Generate owner's member key pair + (memberPubKey, memberPrivKey) <- liftIO $ atomically $ C.generateKeyPair g + + -- 3. Create signed OwnerAuth (Chat signs with root key) + let ownerAuth = OwnerAuth + { ownerId = memberId, + ownerKey = memberPubKey, + authOwnerSig = C.sign' rootPrivKey (memberId <> C.encodePubKey memberPubKey) + } + + -- 4. Create UserConnLinkData with owners array + let userLinkData = UserContactLinkData $ UserContactData { owners = [ownerAuth], direct = True } + + -- 5. Create connection with prepared link (SINGLE network call) + (connId, (createdLink, _)) <- createConnectionWithPreparedLink c NRMNormal userId + enableNtfs checkNotices preparedLink userLinkData SMSubscribe + + -- 6. Store keys in groups table + updateGroupKeys groupId rootPubKey rootPrivKey memberPrivKey + -- groups.root_pub_key = rootPubKey (for all members of public groups) + -- groups.root_priv_key = rootPrivKey (only for owner/creator) + -- groups.member_priv_key = memberPrivKey (current user's signing key) + -- group_members.member_pub_key = memberPubKey (for current user's membership) + + -- Note: shortLink can be used immediately in group profile/address + -- The link address is determined at step 1, not step 5 +``` + +**Key Points:** +- `prepareConnectionLink` generates all link parameters locally (no network, no DB) +- Returns `(PreparedConnLink, ConnShortLink)` - short link address is known immediately +- Sender ID is deterministic: `sha3_384(nonce)[:24]` - derived locally +- `FixedLinkData` contains `ConnectionRequestUri` (includes ratchet params for invitations) +- `LinkKey` derived from `FixedLinkData`, short link address derived from `LinkKey` +- Chat uses root key to sign owner's member key → OwnerAuth +- `createConnectionWithPreparedLink` makes single network roundtrip with complete link data +- `groups` table: `root_priv_key` (owner only), `root_pub_key` (all members), `member_priv_key` (current user) +- `group_members` table: `member_pub_key` (all members) + +## Current Public Group Creation (to be refactored) + +Review `src/Simplex/Chat/Library/Commands.hs` - current two-step process: +1. `APICreateGroup` / `createPreparedGroup` - creates group with connection +2. Server roundtrip to create link +3. Update profile with link +4. Update link data (another roundtrip) + +This needs refactoring to use new Agent API for single-roundtrip creation. + +## Implementation Steps + +### Phase 0: Agent API Changes (simplexmq) +1. Add `prepareConnectionLink` function - commits to server, generates link + root key locally +2. Add `createConnectionWithPreparedLink` function - accepts server + root key, single network call +3. Update Agent store to handle new flow (connection record without queue record) + +### Phase 1: Types and Encoding +1. Add `MemberKey` type and JSON encoding in Types.hs +2. Add `memberKey :: Maybe MemberKey` field to `MemberInfo` type +3. Add `newMemberKey :: MemberKey` to `XMember` message (required, not Maybe) +4. Add `Maybe MemberKey` parameter to `XGrpLinkMem` message +5. Types already added to Protocol.hs: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `ParsedMsg`, `MsgSigData`, `MsgForwardData` +6. Encoding instances added: `KeyRef`, `ChatBinding`, `MsgSignature`, `MsgSignatures`, `MsgSigData`, `MsgForwardData` +7. Binary batch element parser (`elementP`) handles `/`/`>`/`{` prefixes with attoparsec +8. Update `parseChatMessages` to accept both JSON array and binary batch formats +9. Add `BatchMode` parameter to batching functions in Messages/Batch.hs + +### Phase 2: Key Generation and Storage +1. Add database migration for `member_pub_key` in group_members, `member_priv_key` in groups +2. Generate Ed25519 key pair when joining/creating group +3. Store private key in groups.member_priv_key (current user's key for this group) +4. Store public key in group_members.member_pub_key (for all members) +5. Include public key in XMember/XGrpLinkMem/MemberInfo when sending + +### Phase 3: Signing Messages +1. Add `signChatMessage` function in Internal.hs +2. Modify `sendGroupMessage` to sign roster-modifying messages +3. Add owner key to group link when creating public group +4. Sign `XGrpRelayInv` with owner key + +### Phase 4: Signature Verification +1. `verifySig` added in Subscriber.hs — verifies against member's stored public key, checks member ID match +2. `processAChatMsg` verifies direct messages; `xGrpMsgForward` verifies forwarded messages after author resolution +3. `xGrpMsgForward` extended with `Maybe GroupChatScopeInfo` and `Maybe MsgSigData` — eliminated `processForward` duplication +4. Bad signature creates `RGEMsgBadSignature` chat item for the user +5. Add relay validation for `XGrpRelayInv` in Subscriber.hs + +### Phase 5: Version Gating +1. Add new chat version (e.g., `memberSignaturesVersion = VersionChat 17`) +2. Gate signature features behind version check +3. Accept unsigned messages from older clients +4. Send signed messages only to clients supporting new version + +## Signature Verification Logic + +Current implementation (`verifySig` in Subscriber.hs) — minimal first step: + +```haskell +verifySig :: GroupMember -> Maybe MsgSigData -> Bool +verifySig GroupMember {memberPubKey = Just pubKey} (Just MsgSigData {signatures = MsgSignatures {signatures}, signedBody}) = + all verifyOne (L.toList signatures) + where + verifyOne (MsgSignature KRMember sig) = + C.verify (C.APublicVerifyKey C.SEd25519 pubKey) sig signedBody +verifySig _ _ = True +``` + +Verification is called in two places: +- `processAChatMsg`: verifies direct messages from the sender member +- `xGrpMsgForward`: verifies forwarded messages after resolving the author from `MsgForwardData.fwdMemberId` + +Future full verification should additionally: +1. Validate `ChatBinding` matches group (root key, sender member ID) +2. Reject unsigned messages for message types that require signatures + +## Owner Key Integration with Group Link (Separate Key Model) + +When creating a public group: +1. Generate group root key (Ed25519 key pair) - stored in group link's immutable FixedLinkData +2. Generate owner's member key (Ed25519 key pair) - stored in groups.member_priv_key and group_members.member_pub_key +3. Create OwnerAuth entry: `OwnerAuth { ownerId = memberId, ownerKey = memberKey, authOwnerSig = sig(memberId || memberKey, rootKey) }` +4. Add OwnerAuth to group link's mutable UserContactData.owners list + +This model: +- Root key is immutable (defines group identity) +- Owner key is in OwnerAuth chain (supports ownership transfer) +- Member keys are per-group, stored in groups/group_members tables (NOT in profiles) +- New owners can be added by existing owners signing their authorization + +```haskell +-- When creating public group +createPublicGroup :: ... -> CM GroupInfo +createPublicGroup ... = do + -- 1. Generate root key for group identity + (rootPubKey, rootPrivKey) <- generateKeyPair Ed25519 + + -- 2. Generate owner's member key for this group + (memberPubKey, memberPrivKey) <- generateKeyPair Ed25519 + + -- 3. Create owner authorization signed by root + let ownerAuth = OwnerAuth + { ownerId = memberId membership, + ownerKey = memberPubKey, + authOwnerSig = sign rootPrivKey (memberId <> encodePubKey memberPubKey) + } + + -- 4. Store keys: root_priv_key and member_priv_key in groups table + -- member_pub_key in group_members table + -- 5. Add ownerAuth to link data + ... +``` + +## Testing Considerations + +1. **Unit tests**: Encoding/decoding round-trips for signed messages +2. **Integration tests**: Message signing and verification flow +3. **Compatibility tests**: Old clients receiving signed messages +4. **Relay tests**: Signature validation in relay invitation flow +5. **Key rotation tests**: Profile updates with new member key + +## Backward Compatibility + +- **Hard fail mode**: Messages requiring signatures (roster changes) MUST be signed. Unsigned/invalid = rejected. +- Version-gated: Add `memberSignaturesVersion = VersionChat 17` +- New clients: Send signed roster messages, reject unsigned roster messages from new clients +- Old clients: Cannot send roster messages to new-version groups (version negotiation prevents this) +- Migration path: Existing groups without signatures continue working; new public groups require signatures + +## Design Decisions (Confirmed) + +1. **Message signing scope**: Only roster-modifying messages (XGrpRelayInv, XGrpMemNew, XGrpMemRole, XGrpMemDel, XGrpInfo, XGrpPrefs, XGrpDel). Regular content messages (XMsgNew) are NOT signed — signing them would destroy deniability by creating non-repudiable proof of authorship. Content manipulation by relays is detectable post-hoc via cross-relay consistency, which is sufficient because content delivery is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted), so they must be authenticated at processing time before taking effect — post-detection is too late. + +2. **Signature failure handling**: Hard fail for all signed message types. Reject any message that should be signed but isn't or has invalid signature. + +3. **Key model**: Separate keys - root key is fixed in group link, owner is authorized via OwnerAuth chain. Supports ownership transfer without breaking group identity. Matches simplexmq pattern. diff --git a/docs/rfcs/2026-02-10-member-support-voice.md b/docs/rfcs/2026-02-10-member-support-voice.md new file mode 100644 index 0000000000..52285d1514 --- /dev/null +++ b/docs/rfcs/2026-02-10-member-support-voice.md @@ -0,0 +1,212 @@ +# Voice messages in member support scope + +## Table of contents + +1. Executive summary +2. Problem +3. High-level design +4. Detailed implementation plan + +## 1. Executive summary + +Allow voice messages from host/admin during the approval phase (member pending) regardless of group voice settings, gated behind chat protocol version 17. This enables the directory bot to send voice captchas in groups that prohibit voice messages. Old clients that don't support this exemption will receive text/image captchas instead. + +## 2. Problem + +The directory bot sends voice captchas to joining members via the member support scope (`GCSMemberSupport`). However, `prohibitedGroupContent` (Internal.hs:338) blocks voice messages when the group disables voice — with no scope exemption: + +```haskell +| isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +Other content types (files, reports, simplex links) already have `isNothing scopeInfo` guards that exempt them in member support scope. Voice does not. + +This means voice captchas fail in the majority of real groups that prohibit voice messages. The check runs on both sender side (Commands.hs:3856) and recipient side (Subscriber.hs:1738), so both the bot and the joining member reject voice in these groups. + +## 3. High-level design + +1. **Protocol version 17** (`memberSupportVoiceVersion`): gates the `prohibitedGroupContent` exemption for host voice during the approval phase. + +2. **Core library change** (Internal.hs): exempt voice in `prohibitedGroupContent` when sender is admin+ (host) AND the member is in the approval phase (pending status). Voice is NOT generally allowed in member support scope — only during approval, only from host. + +3. **Directory bot change** (Service.hs): check member's protocol version and group voice settings before offering or sending voice captcha. Fall back to text/image captcha for old clients in voice-disabled groups. + +## 4. Detailed implementation plan + +### 4.1. Protocol.hs — add version 17 + +**File:** `src/Simplex/Chat/Protocol.hs` + +Add to version history comment (after line 79): + +``` +-- 17 - allow host voice messages during member approval regardless of group voice setting (2026-02-10) +``` + +Update `currentChatVersion` (line 85): + +```haskell +currentChatVersion = VersionChat 17 +``` + +Add version constant (after `shortLinkDataVersion`, line 146): + +```haskell +-- support host voice messages during member approval regardless of group voice setting +memberSupportVoiceVersion :: VersionChat +memberSupportVoiceVersion = VersionChat 17 +``` + +### 4.2. Internal.hs — exempt host voice during approval phase + +**File:** `src/Simplex/Chat/Library/Internal.hs` + +Change function header (line 337) to bind sender's role and full membership: + +```haskell +prohibitedGroupContent gInfo@GroupInfo {membership = mem@GroupMember {memberRole = userRole}} m@GroupMember {memberRole = senderRole} scopeInfo mc ft file_ sent +``` + +Change line 338 from: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice +``` + +to: + +```haskell + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) && not hostApprovalVoice = Just GFVoice +``` + +Add to the `where` clause: + +```haskell + hostApprovalVoice = senderRole >= GRAdmin && inApprovalPhase + inApprovalPhase = case scopeInfo of + Just (GCSIMemberSupport (Just scopeMem)) -> memberPending scopeMem + Just (GCSIMemberSupport Nothing) -> memberPending mem + Nothing -> False +``` + +Note: `memberPending` returns True for both `GSMemPendingApproval` and `GSMemPendingReview`. The exemption applies to both phases — the member hasn't been fully admitted in either state. + +**Why two cases for `inApprovalPhase`:** + +- **Sender side** (bot sending via Commands.hs:3856): `scopeInfo = GCSIMemberSupport (Just pendingMember)` — the scope contains the pending member being supported. `memberPending pendingMember` checks their status. +- **Receiver side** (member receiving via Subscriber.hs:1738): `scopeInfo = GCSIMemberSupport Nothing` — `Nothing` means the member's own support conversation (constructed by `mkGroupSupportChatInfo` in Internal.hs:1535). `memberPending mem` checks the local user's (receiving member's) status. + +**Behavior matrix:** + +| Scenario | `hostApprovalVoice` | Voice allowed? | +|----------|---------------------|----------------| +| Host → pending member, voice disabled | True | Yes (new) | +| Host → approved member in support, voice disabled | False (`memberPending` = False) | No | +| Pending member → host, voice disabled | False (`senderRole` < GRAdmin) | No | +| Anyone outside support scope, voice disabled | False (`inApprovalPhase` = False) | No | +| Any sender, voice enabled | N/A (`groupFeatureMemberAllowed` = True) | Yes (existing) | + +**Version gating:** Old clients (< v17) don't have this exemption. On the sender side this is handled by the bot (4.3). On the recipient side: + +- Old recipient + voice-disabled group: recipient rejects the voice message (shows "Voice messages: received, prohibited") +- This is why the bot must check the member's version before sending voice + +### 4.3. Service.hs — version-aware voice captcha logic + +**File:** `apps/simplex-directory-service/src/Directory/Service.hs` + +#### 4.3.1. Add import + +Add `memberSupportVoiceVersion` to the `Protocol` import: + +```haskell +import Simplex.Chat.Protocol (MsgContent (..), memberSupportVoiceVersion) +``` + +#### 4.3.2. Add helper predicate + +Add a helper in the `directoryService` `where` block (same scope as `sendMemberCaptcha`, `sendVoiceCaptcha`, etc., where `opts` is in scope): + +```haskell +canSendVoiceCaptcha :: GroupInfo -> GroupMember -> Bool +canSendVoiceCaptcha gInfo m = + isJust (voiceCaptchaGenerator opts) + && (groupFeatureUserAllowed SGFVoice gInfo || supportsVersion m memberSupportVoiceVersion) +``` + +Logic: +- Voice captcha generator must be configured +- AND either the group allows voice for the bot/host (any client version works — old clients accept voice from permitted senders) OR the member's client supports v17 (exemption applies on receive side) + +Note: `groupFeatureUserAllowed` checks if the bot (group owner) is permitted to send voice. This is what the recipient's `prohibitedGroupContent` checks — it validates the *sender's* permission (`m` parameter = sender's GroupMember), not the recipient's. Using `groupFeatureMemberAllowed SGFVoice m gInfo` (joining member) would be wrong: it would incorrectly block voice captcha in groups with role-based voice settings (e.g., "admins only"). + +#### 4.3.3. Update `dePendingMember` hint text (line 572) + +Change from: + +```haskell +<> if isJust (voiceCaptchaGenerator opts) then "\nSend /audio to receive a voice captcha." else "" +``` + +to: + +```haskell +<> if canSendVoiceCaptcha g m then "\nSend /audio to receive a voice captcha." else "" +``` + +This hides the `/audio` hint when voice captcha cannot be delivered. + +#### 4.3.4. Update `dePendingMemberMsg` `/audio` handling (lines 644-649) + +When a member sends `/audio`, check `canSendVoiceCaptcha` before switching mode. If voice captcha is not possible, reply with an upgrade message: + +```haskell +| isAudioCmd -> + if canSendVoiceCaptcha g m + then case captchaMode of + CMText -> do + atomically $ TM.insert gmId pc {captchaMode = CMAudio} $ pendingCaptchas env + sendVoiceCaptcha sendRef (T.unpack captchaText) + CMAudio -> + sendComposedMessages_ cc sendRef [(Just ciId, MCText audioAlreadyEnabled)] + else sendComposedMessages_ cc sendRef [(Just ciId, MCText voiceCaptchaUnavailable)] +``` + +#### 4.3.5. Add message constant + +```haskell +voiceCaptchaUnavailable :: Text +voiceCaptchaUnavailable = "Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." +``` + +#### 4.3.6. Update `dePendingMemberMsg` no-captcha `/audio` path (lines 640-642) + +Same check for the case when no pending captcha exists yet: + +```haskell +Nothing -> + if isAudioCmd && canSendVoiceCaptcha g m + then sendMemberCaptcha g m (Just ciId) noCaptcha 0 CMAudio + else if isAudioCmd + then sendComposedMessages_ cc (SRGroup groupId $ Just $ GCSMemberSupport (Just gmId)) [(Just ciId, MCText voiceCaptchaUnavailable)] + else let mode = CMText + in sendMemberCaptcha g m (Just ciId) noCaptcha 0 mode +``` + +### 4.4. Tests + +**File:** `tests/Bots/DirectoryTests.hs` + +Update existing audio captcha tests to cover: +1. Group with voice enabled + any client version: `/audio` works (existing behavior) +2. Group with voice disabled + member version >= 17: `/audio` works +3. Group with voice disabled + member version < 17: `/audio` shows unavailable message, hint is hidden + +### 4.5. Changes summary + +| File | Change | Lines affected | +|------|--------|----------------| +| `Protocol.hs` | Add v17 constant, bump `currentChatVersion` | ~4 lines added | +| `Internal.hs` | Exempt host voice during approval phase | ~6 lines modified/added | +| `Service.hs` | Version-aware voice captcha logic | ~15 lines modified/added | +| `DirectoryTests.hs` | Test coverage for version gating | TBD | diff --git a/docs/rfcs/2026-03-28-group-identity-binding.md b/docs/rfcs/2026-03-28-group-identity-binding.md new file mode 100644 index 0000000000..afc4ed965c --- /dev/null +++ b/docs/rfcs/2026-03-28-group-identity-binding.md @@ -0,0 +1,68 @@ +# Group identity and signature binding + +## Problem + +Group message signatures bind to a group identity via a prefix: + +``` +signedBytes = smpEncode (CBGroup, groupIdentity, memberId) <> messageBody +``` + +Using `groupRootKey` as identity is unstable: the root key is derived from the link's key pair, so link rotation (relay replacement, key compromise recovery) changes it, breaking existing bindings. + +Using an arbitrary entity ID is stable but not self-authenticating: any owner could copy another group's ID. + +## Design + +Use the **hash of the genesis root key** as group identity: + +``` +groupEntityId = sha256(genesisRootPubKey) +``` + +- Set at creation, never changes. +- Self-authenticating: derived from a key pair only the creator held. +- Stored as `linkEntityId` in the short link, and in the group profile distributed to all members. +- Used in the signature binding prefix instead of root key. + +### Why no validation now + +Current clients do not validate that `linkEntityId == sha256(rootKey)` on join. This is unconventional — normally, an unvalidated binding is pointless. Here it is deliberate forward-compatible design, not deferred work: + +- **Forward compatibility for joiners**: future link rotation will cause `rootKey` and `linkEntityId` to diverge. Current clients don't know how to verify a rotation chain, so they must accept diverged values. If we validated now, current clients could not join future rotated groups. Mobile clients have slow upgrade cycles and we have no mechanism to force upgrades, so we aim for at least 2-3 months backward compatibility for new features (1 year for existing). Validating now would force a breaking change on rotation. + +- **Forward compatibility for groups**: all groups created now have the correct binding (`entityId = sha256(rootKey)`). When a future protocol version introduces rotation and enforces validation, these groups are already compliant. Deferring the entity ID until then would mean some groups have IDs and some don't — a backward-compatibility problem. + +The cloning risk (copied entity ID in a malicious group) is acceptable now: groups are small, invite links come from trusted sources, and history merging on re-join is itself a future feature. By the time channels are large enough for cloning to matter, validation will be enforced. + +### Key hierarchy context + +The root key is a **bootstrap key**: it signs `OwnerAuth` entries to certify owners (see [simplexmq owner chain](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2025-04-04-short-links-for-groups.md#multiple-owners-managing-queue-data)), then need not be used again. Owner keys sign admin messages, group updates, and future rotation statements. This conceals the creator's identity — all owners are indistinguishable. + +Using the genesis root key *hash* as identity aligns with this: after rotation, the root key changes but the identity persists, bridged by owner-signed rotation statements. + +### What IS validated now + +- **Link vs profile consistency**: joiners validate that `linkEntityId` from the link matches `sharedGroupId` in the group profile. This prevents a directory or listing from substituting a different link for a group — the link is bound to the profile. This check remains valid after rotation (both preserve the original entity ID). + +- **Profile update immutability**: `sharedGroupId` in the group profile must not change. Clients reject `XGrpInfo` updates that modify it. + +### What is NOT validated now + +- **`linkEntityId == sha256(rootKey)`**: not checked on join. See "Why no validation now" above. + +## Changes + +### Done + +1. **Agent API** (`simplexmq`): `prepareConnectionLink` takes caller-provided root key pair and entity ID instead of generating the key internally. Caller controls both. + +2. **Link creation** (`Commands.hs`): owner generates root key pair, computes `sharedGroupId = sha256(rootPubKey)`, passes both to `prepareConnectionLink`. The entity ID is baked into signed `FixedLinkData`. + +### Remaining + +3. **Group profile**: add `sharedGroupId` field to `GroupProfile`, set from `linkEntityId` at genesis, immutable. Reject `XGrpInfo` updates that change it. + +4. **Joiner validation**: confirm `linkEntityId` from link matches `sharedGroupId` from group profile. + +5. **Signature binding**: change prefix from `smpEncode (CBGroup, groupRootPubKey, memberId)` to `smpEncode (CBGroup, sharedGroupId, memberId)` in both `groupMsgSigning` (signing) and `withVerifiedMsg` (verification). diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index a135b286c2..8d05eb460c 100644 --- a/packages/simplex-chat-client/types/typescript/package.json +++ b/packages/simplex-chat-client/types/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@simplex-chat/types", - "version": "0.3.0", + "version": "0.4.0", "description": "TypeScript types for SimpleX Chat bot libraries", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 66f4f6ec5f..d5c3046e3a 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -341,6 +341,37 @@ export namespace APINewGroup { } } +// Create public group. +// Network usage: interactive. +export interface APINewPublicGroup { + userId: number // int64 + incognito: boolean + relayIds: number[] // int64, non-empty + groupProfile: T.GroupProfile +} + +export namespace APINewPublicGroup { + export type Response = CR.PublicGroupCreated | CR.ChatCmdError + + export function cmdString(self: APINewPublicGroup): string { + return '/_public group ' + self.userId + (self.incognito ? ' incognito=on' : '') + ' ' + self.relayIds.join(',') + ' ' + JSON.stringify(self.groupProfile) + } +} + +// Get group relays. +// Network usage: no. +export interface APIGetGroupRelays { + groupId: number // int64 +} + +export namespace APIGetGroupRelays { + export type Response = CR.GroupRelays | CR.ChatCmdError + + export function cmdString(self: APIGetGroupRelays): string { + return '/_get relays #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { @@ -557,6 +588,51 @@ export namespace APIDeleteChat { } } +// Set group custom data. +// Network usage: no. +export interface APISetGroupCustomData { + groupId: number // int64 + customData?: object +} + +export namespace APISetGroupCustomData { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetGroupCustomData): string { + return '/_set custom #' + self.groupId + (self.customData ? ' ' + JSON.stringify(self.customData) : '') + } +} + +// Set contact custom data. +// Network usage: no. +export interface APISetContactCustomData { + contactId: number // int64 + customData?: object +} + +export namespace APISetContactCustomData { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetContactCustomData): string { + return '/_set custom @' + self.contactId + (self.customData ? ' ' + JSON.stringify(self.customData) : '') + } +} + +// Set auto-accept member contacts. +// Network usage: no. +export interface APISetUserAutoAcceptMemberContacts { + userId: number // int64 + onOff: boolean +} + +export namespace APISetUserAutoAcceptMemberContacts { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetUserAutoAcceptMemberContacts): string { + return '/_set accept member contacts ' + self.userId + ' ' + (self.onOff ? 'on' : 'off') + } +} + // User profile commands // Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/packages/simplex-chat-client/types/typescript/src/events.ts b/packages/simplex-chat-client/types/typescript/src/events.ts index cb6ba85c8b..cc19305913 100644 --- a/packages/simplex-chat-client/types/typescript/src/events.ts +++ b/packages/simplex-chat-client/types/typescript/src/events.ts @@ -29,6 +29,8 @@ export type ChatEvent = | CEvt.MemberAcceptedByOther | CEvt.MemberBlockedForAll | CEvt.GroupMemberUpdated + | CEvt.GroupLinkDataUpdated + | CEvt.GroupRelayUpdated | CEvt.RcvFileDescrReady | CEvt.RcvFileComplete | CEvt.SndFileCompleteXFTP @@ -80,6 +82,8 @@ export namespace CEvt { | "memberAcceptedByOther" | "memberBlockedForAll" | "groupMemberUpdated" + | "groupLinkDataUpdated" + | "groupRelayUpdated" | "rcvFileDescrReady" | "rcvFileComplete" | "sndFileCompleteXFTP" @@ -213,6 +217,7 @@ export namespace CEvt { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface JoinedGroupMember extends Interface { @@ -230,6 +235,7 @@ export namespace CEvt { member: T.GroupMember fromRole: T.GroupMemberRole toRole: T.GroupMemberRole + msgSigned?: T.MsgSigStatus } export interface DeletedMember extends Interface { @@ -239,6 +245,7 @@ export namespace CEvt { byMember: T.GroupMember deletedMember: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface LeftMember extends Interface { @@ -246,6 +253,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface DeletedMemberUser extends Interface { @@ -254,6 +262,7 @@ export namespace CEvt { groupInfo: T.GroupInfo member: T.GroupMember withMessages: boolean + msgSigned?: T.MsgSigStatus } export interface GroupDeleted extends Interface { @@ -261,6 +270,7 @@ export namespace CEvt { user: T.User groupInfo: T.GroupInfo member: T.GroupMember + msgSigned?: T.MsgSigStatus } export interface ConnectedToGroupMember extends Interface { @@ -286,6 +296,7 @@ export namespace CEvt { byMember: T.GroupMember member: T.GroupMember blocked: boolean + msgSigned?: T.MsgSigStatus } export interface GroupMemberUpdated extends Interface { @@ -296,6 +307,23 @@ export namespace CEvt { toMember: T.GroupMember } + export interface GroupLinkDataUpdated extends Interface { + type: "groupLinkDataUpdated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + relaysChanged: boolean + } + + export interface GroupRelayUpdated extends Interface { + type: "groupRelayUpdated" + user: T.User + groupInfo: T.GroupInfo + member: T.GroupMember + groupRelay: T.GroupRelay + } + export interface RcvFileDescrReady extends Interface { type: "rcvFileDescrReady" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 684aeec7af..8d4f68c000 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -27,6 +27,8 @@ export type ChatResponse = | CR.GroupLinkCreated | CR.GroupLinkDeleted | CR.GroupCreated + | CR.PublicGroupCreated + | CR.GroupRelays | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -78,6 +80,8 @@ export namespace CR { | "groupLinkCreated" | "groupLinkDeleted" | "groupCreated" + | "publicGroupCreated" + | "groupRelays" | "groupMembers" | "groupUpdated" | "groupsList" @@ -217,6 +221,7 @@ export namespace CR { type: "groupDeletedUser" user: T.User groupInfo: T.GroupInfo + msgSigned: boolean } export interface GroupLink extends Interface { @@ -245,6 +250,21 @@ export namespace CR { groupInfo: T.GroupInfo } + export interface PublicGroupCreated extends Interface { + type: "publicGroupCreated" + user: T.User + groupInfo: T.GroupInfo + groupLink: T.GroupLink + groupRelays: T.GroupRelay[] + } + + export interface GroupRelays extends Interface { + type: "groupRelays" + user: T.User + groupInfo: T.GroupInfo + groupRelays: T.GroupRelay[] + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User @@ -257,6 +277,7 @@ export namespace CR { fromGroup: T.GroupInfo toGroup: T.GroupInfo member_?: T.GroupMember + msgSigned: boolean } export interface GroupsList extends Interface { @@ -291,6 +312,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] blocked: boolean + msgSigned: boolean } export interface MembersRoleUser extends Interface { @@ -299,6 +321,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] toRole: T.GroupMemberRole + msgSigned: boolean } export interface NewChatItems extends Interface { @@ -392,6 +415,7 @@ export namespace CR { groupInfo: T.GroupInfo members: T.GroupMember[] withMessages: boolean + msgSigned: boolean } export interface UserProfileUpdated extends Interface { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index bd03d7d72b..34b34ccbd5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -554,11 +554,19 @@ export type CIDirection = | CIDirection.DirectRcv | CIDirection.GroupSnd | CIDirection.GroupRcv + | CIDirection.ChannelRcv | CIDirection.LocalSnd | CIDirection.LocalRcv export namespace CIDirection { - export type Tag = "directSnd" | "directRcv" | "groupSnd" | "groupRcv" | "localSnd" | "localRcv" + export type Tag = + | "directSnd" + | "directRcv" + | "groupSnd" + | "groupRcv" + | "channelRcv" + | "localSnd" + | "localRcv" interface Interface { type: Tag @@ -581,6 +589,10 @@ export namespace CIDirection { groupMember: GroupMember } + export interface ChannelRcv extends Interface { + type: "channelRcv" + } + export interface LocalSnd extends Interface { type: "localSnd" } @@ -783,6 +795,7 @@ export interface CIMeta { editable: boolean forwardedByMember?: number // int64 showGroupAsSender: boolean + msgSigned?: MsgSigStatus createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp } @@ -970,6 +983,7 @@ export type ChatErrorType = | ChatErrorType.UserUnknown | ChatErrorType.ActiveUserExists | ChatErrorType.UserExists + | ChatErrorType.ChatRelayExists | ChatErrorType.DifferentActiveUser | ChatErrorType.CantDeleteActiveUser | ChatErrorType.CantDeleteLastUser @@ -1034,6 +1048,7 @@ export type ChatErrorType = | ChatErrorType.ConnectionIncognitoChangeProhibited | ChatErrorType.ConnectionUserChangeProhibited | ChatErrorType.PeerChatVRangeIncompatible + | ChatErrorType.RelayTestError | ChatErrorType.InternalError | ChatErrorType.Exception @@ -1046,6 +1061,7 @@ export namespace ChatErrorType { | "userUnknown" | "activeUserExists" | "userExists" + | "chatRelayExists" | "differentActiveUser" | "cantDeleteActiveUser" | "cantDeleteLastUser" @@ -1110,6 +1126,7 @@ export namespace ChatErrorType { | "connectionIncognitoChangeProhibited" | "connectionUserChangeProhibited" | "peerChatVRangeIncompatible" + | "relayTestError" | "internalError" | "exception" @@ -1149,6 +1166,10 @@ export namespace ChatErrorType { contactName: string } + export interface ChatRelayExists extends Interface { + type: "chatRelayExists" + } + export interface DifferentActiveUser extends Interface { type: "differentActiveUser" commandUserId: number // int64 @@ -1460,6 +1481,11 @@ export namespace ChatErrorType { type: "peerChatVRangeIncompatible" } + export interface RelayTestError extends Interface { + type: "relayTestError" + message: string + } + export interface InternalError extends Interface { type: "internalError" message: string @@ -1695,15 +1721,69 @@ export interface ComposedMessage { mentions: {[key: string]: number} // string : int64 } -export enum ConnStatus { - New = "new", - Prepared = "prepared", - Joined = "joined", - Requested = "requested", - Accepted = "accepted", - Snd_ready = "snd-ready", - Ready = "ready", - Deleted = "deleted", +export type ConnStatus = + | ConnStatus.New + | ConnStatus.Prepared + | ConnStatus.Joined + | ConnStatus.Requested + | ConnStatus.Accepted + | ConnStatus.SndReady + | ConnStatus.Ready + | ConnStatus.Deleted + | ConnStatus.Failed + +export namespace ConnStatus { + export type Tag = + | "new" + | "prepared" + | "joined" + | "requested" + | "accepted" + | "sndReady" + | "ready" + | "deleted" + | "failed" + + interface Interface { + type: Tag + } + + export interface New extends Interface { + type: "new" + } + + export interface Prepared extends Interface { + type: "prepared" + } + + export interface Joined extends Interface { + type: "joined" + } + + export interface Requested extends Interface { + type: "requested" + } + + export interface Accepted extends Interface { + type: "accepted" + } + + export interface SndReady extends Interface { + type: "sndReady" + } + + export interface Ready extends Interface { + type: "ready" + } + + export interface Deleted extends Interface { + type: "deleted" + } + + export interface Failed extends Interface { + type: "failed" + connError: string + } } export enum ConnType { @@ -2227,6 +2307,7 @@ export type Format = | Format.StrikeThrough | Format.Snippet | Format.Secret + | Format.Small | Format.Colored | Format.Uri | Format.HyperLink @@ -2243,6 +2324,7 @@ export namespace Format { | "strikeThrough" | "snippet" | "secret" + | "small" | "colored" | "uri" | "hyperLink" @@ -2276,6 +2358,10 @@ export namespace Format { type: "secret" } + export interface Small extends Interface { + type: "small" + } + export interface Colored extends Interface { type: "colored" color: Color @@ -2416,6 +2502,7 @@ export enum GroupFeatureEnabled { export interface GroupInfo { groupId: number // int64 useRelays: boolean + relayOwnStatus?: RelayStatus localDisplayName: string groupProfile: GroupProfile localAlias: string @@ -2435,6 +2522,13 @@ export interface GroupInfo { groupSummary: GroupSummary membersRequireAttention: number // int viaGroupLinkUri?: string + groupKeys?: GroupKeys +} + +export interface GroupKeys { + publicGroupId: string + groupRootKey: GroupRootKey + memberPrivKey: string } export interface GroupLink { @@ -2462,6 +2556,7 @@ export namespace GroupLinkPlan { export interface Ok extends Interface { type: "ok" + groupSLinkInfo_?: GroupShortLinkInfo groupSLinkData_?: GroupShortLinkData } @@ -2506,6 +2601,8 @@ export interface GroupMember { createdAt: string // ISO-8601 timestamp updatedAt: string // ISO-8601 timestamp supportChat?: GroupSupportChat + memberPubKey?: string + relayLink?: string } export interface GroupMemberAdmission { @@ -2526,6 +2623,7 @@ export interface GroupMemberRef { } export enum GroupMemberRole { + Relay = "relay", Observer = "observer", Author = "author", Member = "member", @@ -2580,16 +2678,53 @@ export interface GroupProfile { shortDescr?: string description?: string image?: string + publicGroup?: PublicGroupProfile groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission } +export interface GroupRelay { + groupRelayId: number // int64 + groupMemberId: number // int64 + userChatRelay: UserChatRelay + relayStatus: RelayStatus + relayLink?: string +} + +export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public + +export namespace GroupRootKey { + export type Tag = "private" | "public" + + interface Interface { + type: Tag + } + + export interface Private extends Interface { + type: "private" + rootPrivKey: string + } + + export interface Public extends Interface { + type: "public" + rootPubKey: string + } +} + export interface GroupShortLinkData { groupProfile: GroupProfile + publicGroupData?: PublicGroupData +} + +export interface GroupShortLinkInfo { + direct: boolean + groupRelays: string[] + publicGroupId?: string } export interface GroupSummary { currentMembers: number // int64 + publicMemberCount?: number // int64 } export interface GroupSupportChat { @@ -2600,6 +2735,10 @@ export interface GroupSupportChat { lastMsgFromMemberTs?: string // ISO-8601 timestamp } +export enum GroupType { + Channel = "channel", +} + export enum HandshakeError { PARSE = "PARSE", IDENTITY = "IDENTITY", @@ -2902,6 +3041,11 @@ export enum MsgReceiptStatus { BadMsgHash = "badMsgHash", } +export enum MsgSigStatus { + Verified = "verified", + SignedNoKey = "signedNoKey", +} + export type NetworkError = | NetworkError.ConnectError | NetworkError.TLSError @@ -2954,6 +3098,7 @@ export namespace NetworkError { export interface NewUser { profile?: Profile pastTimestamp: boolean + userChatRelay: boolean } export interface NoteFolder { @@ -3077,6 +3222,16 @@ export namespace ProxyError { } } +export interface PublicGroupData { + publicMemberCount: number // int64 +} + +export interface PublicGroupProfile { + groupType: GroupType + groupLink: string + publicGroupId: string +} + export type RCErrorType = | RCErrorType.Internal | RCErrorType.Identity @@ -3332,6 +3487,7 @@ export type RcvGroupEvent = | RcvGroupEvent.MemberCreatedContact | RcvGroupEvent.MemberProfileUpdated | RcvGroupEvent.NewMemberPendingReview + | RcvGroupEvent.MsgBadSignature export namespace RcvGroupEvent { export type Tag = @@ -3351,6 +3507,7 @@ export namespace RcvGroupEvent { | "memberCreatedContact" | "memberProfileUpdated" | "newMemberPendingReview" + | "msgBadSignature" interface Interface { type: Tag @@ -3435,6 +3592,24 @@ export namespace RcvGroupEvent { export interface NewMemberPendingReview extends Interface { type: "newMemberPendingReview" } + + export interface MsgBadSignature extends Interface { + type: "msgBadSignature" + } +} + +export interface RelayProfile { + displayName: string + fullName: string + shortDescr?: string + image?: string +} + +export enum RelayStatus { + New = "new", + Invited = "invited", + Accepted = "accepted", + Active = "active", } export enum ReportReason { @@ -3717,6 +3892,7 @@ export namespace SrvError { export type StoreError = | StoreError.DuplicateName | StoreError.UserNotFound + | StoreError.RelayUserNotFound | StoreError.UserNotFoundByName | StoreError.UserNotFoundByContactId | StoreError.UserNotFoundByGroupId @@ -3744,6 +3920,7 @@ export type StoreError = | StoreError.InvalidMemberRelationUpdate | StoreError.GroupWithoutUser | StoreError.DuplicateGroupMember + | StoreError.DuplicateMemberId | StoreError.GroupAlreadyJoined | StoreError.GroupInvitationNotFound | StoreError.NoteFolderAlreadyExists @@ -3792,6 +3969,9 @@ export type StoreError = | StoreError.ProhibitedDeleteUser | StoreError.OperatorNotFound | StoreError.UsageConditionsNotFound + | StoreError.UserChatRelayNotFound + | StoreError.GroupRelayNotFound + | StoreError.GroupRelayNotFoundByMemberId | StoreError.InvalidQuote | StoreError.InvalidMention | StoreError.InvalidDeliveryTask @@ -3804,6 +3984,7 @@ export namespace StoreError { export type Tag = | "duplicateName" | "userNotFound" + | "relayUserNotFound" | "userNotFoundByName" | "userNotFoundByContactId" | "userNotFoundByGroupId" @@ -3831,6 +4012,7 @@ export namespace StoreError { | "invalidMemberRelationUpdate" | "groupWithoutUser" | "duplicateGroupMember" + | "duplicateMemberId" | "groupAlreadyJoined" | "groupInvitationNotFound" | "noteFolderAlreadyExists" @@ -3879,6 +4061,9 @@ export namespace StoreError { | "prohibitedDeleteUser" | "operatorNotFound" | "usageConditionsNotFound" + | "userChatRelayNotFound" + | "groupRelayNotFound" + | "groupRelayNotFoundByMemberId" | "invalidQuote" | "invalidMention" | "invalidDeliveryTask" @@ -3900,6 +4085,10 @@ export namespace StoreError { userId: number // int64 } + export interface RelayUserNotFound extends Interface { + type: "relayUserNotFound" + } + export interface UserNotFoundByName extends Interface { type: "userNotFoundByName" contactName: string @@ -4030,6 +4219,10 @@ export namespace StoreError { type: "duplicateGroupMember" } + export interface DuplicateMemberId extends Interface { + type: "duplicateMemberId" + } + export interface GroupAlreadyJoined extends Interface { type: "groupAlreadyJoined" } @@ -4266,6 +4459,21 @@ export namespace StoreError { type: "usageConditionsNotFound" } + export interface UserChatRelayNotFound extends Interface { + type: "userChatRelayNotFound" + chatRelayId: number // int64 + } + + export interface GroupRelayNotFound extends Interface { + type: "groupRelayNotFound" + groupRelayId: number // int64 + } + + export interface GroupRelayNotFoundByMemberId extends Interface { + type: "groupRelayNotFoundByMemberId" + groupMemberId: number // int64 + } + export interface InvalidQuote extends Interface { type: "invalidQuote" } @@ -4441,6 +4649,18 @@ export interface User { autoAcceptMemberContacts: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp uiThemes?: UIThemeEntityOverrides + userChatRelay: boolean +} + +export interface UserChatRelay { + chatRelayId: number // int64 + address: string + relayProfile: RelayProfile + domains: string[] + preset: boolean + tested?: boolean + enabled: boolean + deleted: boolean } export interface UserContact { diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 498d502edd..657c6e05f2 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.3.0", + "@simplex-chat/types": "^0.4.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index dc87055f87..8bc56db41c 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -747,6 +747,47 @@ export class ChatApi { throw new ChatCommandError("error deleting chat", r) } + /** + * Set group custom data. + * Network usage: no. + */ + async apiSetGroupCustomData(groupId: number, customData?: object): Promise { + const r = await this.sendChatCmd(CC.APISetGroupCustomData.cmdString({groupId, customData})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting group custom data", r) + } + + /** + * Set contact custom data. + * Network usage: no. + */ + async apiSetContactCustomData(contactId: number, customData?: object): Promise { + const r = await this.sendChatCmd(CC.APISetContactCustomData.cmdString({contactId, customData})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting contact custom data", r) + } + + /** + * Set auto-accept member contacts. + * Network usage: no. + */ + async apiSetAutoAcceptMemberContacts(userId: number, onOff: boolean): Promise { + const r = await this.sendChatCmd(CC.APISetUserAutoAcceptMemberContacts.cmdString({userId, onOff})) + if (r.type === "cmdOk") return + throw new ChatCommandError("error setting auto-accept member contacts", r) + } + + /** + * Get chat items. + * Network usage: no. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async apiGetChat(chatType: T.ChatType, chatId: number, count: number): Promise { + const r: any = await this.sendChatCmd(`/_get chat ${T.ChatType.cmdString(chatType)}${chatId} count=${count}`) + if (r.type === "apiChat") return r.chat + throw new ChatCommandError("error getting chat", r) + } + /** * Get active user profile * Network usage: no. @@ -772,7 +813,7 @@ export class ChatApi { * Network usage: no. */ async apiCreateActiveUser(profile?: T.Profile): Promise { - const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false}})) + const r = await this.sendChatCmd(CC.CreateActiveUser.cmdString({newUser: {profile, pastTimestamp: false, userChatRelay: false}})) if (r.type === "activeUser") return r.user throw new ChatCommandError("unexpected response", r) } @@ -831,4 +872,34 @@ export class ChatApi { const r = await this.sendChatCmd(CC.APISetContactPrefs.cmdString({contactId, preferences})) if (r.type !== "contactPrefsUpdated") throw new ChatCommandError("error setting contact prefs", r) } + + /** + * Create a direct message contact with a group member. + * Returns the created contact. + * Network usage: interactive. + */ + async apiCreateMemberContact(groupId: number, groupMemberId: number): Promise { + const r: any = await this.sendChatCmd(`/_create member contact #${groupId} ${groupMemberId}`) + if (r.type === "newMemberContact") return r.contact + throw new ChatCommandError("error creating member contact", r) + } + + /** + * Send a direct message invitation to a group member contact. + * The contact must have been created with {@link apiCreateMemberContact}. + * Network usage: interactive. + */ + async apiSendMemberContactInvitation(contactId: number, message?: T.MsgContent | string): Promise { + let cmd = `/_invite member contact @${contactId}` + if (message !== undefined) { + if (typeof message === "string") { + cmd += ` text ${message}` + } else { + cmd += ` json ${JSON.stringify(message)}` + } + } + const r: any = await this.sendChatCmd(cmd) + if (r.type === "newMemberContactSentInv") return r.contact + throw new ChatCommandError("error sending member contact invitation", r) + } } diff --git a/packages/simplex-chat-nodejs/tests/api.test.ts b/packages/simplex-chat-nodejs/tests/api.test.ts index 52153ecfed..7bc1a89b86 100644 --- a/packages/simplex-chat-nodejs/tests/api.test.ts +++ b/packages/simplex-chat-nodejs/tests/api.test.ts @@ -64,4 +64,89 @@ describe("API tests (use preset servers)", () => { expect(servers[0] !== servers[1]).toBe(true) expect(eventCount > 0).toBe(true) }, 30000) + + it("should create member contact and send invitation", async () => { + // create 3 users and start chat controllers + const alice = await api.ChatApi.init(alicePath) + const bob = await api.ChatApi.init(bobPath) + const carolPath = path.join(tmpDir, "carol") + const carol = await api.ChatApi.init(carolPath) + const aliceUser = await alice.apiCreateActiveUser({displayName: "alice", fullName: ""}) + await bob.apiCreateActiveUser({displayName: "bob", fullName: ""}) + await carol.apiCreateActiveUser({displayName: "carol", fullName: ""}) + await alice.startChat() + await bob.startChat() + await carol.startChat() + // connect alice <-> bob + const aliceLink1 = await alice.apiCreateLink(aliceUser.userId) + await expect(bob.apiConnectActiveUser(aliceLink1)).resolves.toBe(api.ConnReqType.Invitation) + const [bobContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await bob.wait("contactConnected")).contact + ]) + // connect alice <-> carol + const aliceLink2 = await alice.apiCreateLink(aliceUser.userId) + await expect(carol.apiConnectActiveUser(aliceLink2)).resolves.toBe(api.ConnReqType.Invitation) + const [carolContact] = await Promise.all([ + (await alice.wait("contactConnected")).contact, + (await carol.wait("contactConnected")).contact + ]) + // create group with direct messages enabled + const group = await alice.apiNewGroup(aliceUser.userId, { + displayName: "test-group", + fullName: "", + groupPreferences: { + directMessages: {enable: T.GroupFeatureEnabled.On}, + }, + }) + const groupId = group.groupId + // add bob to the group + const bobInvP = bob.wait("receivedGroupInvitation", 15000) + await alice.apiAddMember(groupId, bobContact.contactId, T.GroupMemberRole.Member) + const bobInvEvt = await bobInvP + expect(bobInvEvt).toBeDefined() + const aliceBobConnP = alice.wait("connectedToGroupMember", 15000) + const bobAliceConnP = bob.wait("connectedToGroupMember", 15000) + await bob.apiJoinGroup(bobInvEvt!.groupInfo.groupId) + await Promise.all([aliceBobConnP, bobAliceConnP]) + // add carol to the group + const carolInvP = carol.wait("receivedGroupInvitation", 30000) + await alice.apiAddMember(groupId, carolContact.contactId, T.GroupMemberRole.Member) + const carolInvEvt = await carolInvP + expect(carolInvEvt).toBeDefined() + // wait for carol to connect to both alice and bob (and vice versa) + const bobCarolConnP = bob.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + const carolAliceConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "alice", 30000) + const carolBobConnP = carol.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "bob", 30000) + const aliceCarolConnP = alice.wait("connectedToGroupMember", + (evt: CEvt.ConnectedToGroupMember) => evt.member.memberProfile.displayName === "carol", 30000) + await carol.apiJoinGroup(carolInvEvt!.groupInfo.groupId) + await Promise.all([bobCarolConnP, carolAliceConnP, carolBobConnP, aliceCarolConnP]) + // find carol's memberId from bob's perspective + const members = await bob.apiListMembers(groupId) + const carolMember = members.find(m => m.memberProfile.displayName === "carol") + expect(carolMember).toBeDefined() + // test apiCreateMemberContact + const dmContact = await bob.apiCreateMemberContact(groupId, carolMember!.groupMemberId) + expect(dmContact).toBeDefined() + expect(dmContact.contactId).toBeDefined() + // test apiSendMemberContactInvitation + const carolDmP = carol.wait("newMemberContactReceivedInv" as CEvt.Tag, 30000) + const invContact = await bob.apiSendMemberContactInvitation(dmContact.contactId, "hello from bob") + expect(invContact).toBeDefined() + // carol should receive the member contact invitation + const carolDmEvt = await carolDmP + expect(carolDmEvt).toBeDefined() + expect((carolDmEvt as any).contact).toBeDefined() + // cleanup + await alice.stopChat() + await bob.stopChat() + await carol.stopChat() + await alice.close() + await bob.close() + await carol.close() + }, 90000) }) diff --git a/plans/2026-02-17-ios-channels-product-plan.md b/plans/2026-02-17-ios-channels-product-plan.md new file mode 100644 index 0000000000..de448ec27e --- /dev/null +++ b/plans/2026-02-17-ios-channels-product-plan.md @@ -0,0 +1,506 @@ +# Channels on iOS — Product Plan + +## Contents +1. [Overview](#1-overview) +2. [Screens](#2-screens) + - 2.1 [Chat List](#21-chat-list) + - 2.2 [Channel Messages & Compose](#22-channel-messages--compose) + - 2.3 [Channel Creation](#23-channel-creation) + - 2.4 [Channel Info](#24-channel-info) + - 2.5 [Chat Relay Management (Network & Servers)](#25-chat-relay-management-network--servers) + - 2.6 [Joining a Channel](#26-joining-a-channel) +3. [Implementation Order](#3-implementation-order) + +--- + +## 1. Overview + +### What +Channels are one-to-many broadcast groups where messages flow **owner → chat relays → subscribers**. Unlike regular groups (N-to-N connections), channels use chat relay infrastructure to scale delivery — an owner sends once, chat relays fan out to all subscribers. + +Technically, a channel is a group with `useRelays = true`. All subscribers are observers (read-only). The owner posts as the channel identity. + +### Why +Regular SimpleX groups require direct connections between all members. While there is no hard technical limit, in practice large groups of even several hundred members become very inefficient — group state desynchronizes, delivery becomes inefficient and unreliable, and the experience degrades. Channels solve the broadcast use case: organizations, projects, and individuals publishing to large audiences while preserving SimpleX's privacy model (no user identifiers, relay-mediated delivery). + +### For Whom + +**Channel owners** — creators who want to broadcast to a large audience. They create channels, configure chat relays, post content. Their problem: no way to efficiently reach many people on SimpleX because large groups work badly in practice. + +**Channel subscribers** — readers who want to follow public content. They join via link and receive messages through chat relays. Their problem: can't follow public channels/announcements on SimpleX. + +--- + +## 2. Screens + +### 2.1 Chat List + +New icon (`antenna.radiowaves.left.and.right`) to differentiate channels. + +``` +┌────────────────────────────────────────┐ +│ [👥] Team Chat 3:42 PM │ +│ alice: Hey everyone... ● 1 │ +├────────────────────────────────────────┤ +│ [📡] SimpleX News 3:38 PM │ +│ Latest update about... ● 3 │ +├────────────────────────────────────────┤ +│ [👤] Bob 2:15 PM │ +│ See you tomorrow ✓✓ │ +└────────────────────────────────────────┘ +``` + +Chat header uses channel icon when no profile image, same as groups: + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +└────────────────────────────────────────┘ +``` + +--- + +### 2.2 Channel Messages & Compose + +Messages render with channel avatar + channel name as sender (via existing `showGroupAsSender` path). Consecutive messages group without repeating avatar/name. + +**Subscriber view** — compose disabled with "you are subscriber" label (vs. "you are observer" in groups): + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡] SimpleX News │ +│ ┌──────────────────────────────────┐ │ +│ │ We're excited to announce v7.0! │ │ +│ │ New channel feature allows... │ │ +│ │ 3:42 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Check out the blog post: │ │ +│ │ simplex.chat/blog/v7 │ │ +│ │ 3:45 PM │ │ +│ └──────────────────────────────────┘ │ +│ │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +**Owner view** — compose field shows "Broadcast" placeholder. Always sends `asGroup=true` (MVP). Backend also supports sending "as member" (like in regular groups), but this will not be available in MVP UI. + +``` +├────────────────────────────────────────┤ +│ ┌───────────────────────────────┐ │ +│ 📎 │ Broadcast ➤ │ │ +│ └───────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +**Note**: If all chat relays are removed or stop serving the channel, this won't be visible in the UI in MVP. + +--- + +### 2.3 Channel Creation + +Entry point: "Create channel" in New Chat menu, after "Create group". + +``` +┌────────────────────────────────────────┐ +│ New message │ +├────────────────────────────────────────┤ +│ 🔗 Create 1-time link > │ +│ 📷 Scan / Paste link > │ +│ 👥 Create group > │ +│ 📡 Create channel > │ +├────────────────────────────────────────┤ +│ 📦 Archived contacts > │ +└────────────────────────────────────────┘ +``` + +#### Step 1 — Channel profile + +``` +┌────────────────────────────────────────┐ +│ Cancel Create channel │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Enter channel name... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Configure relays... > │ +│ │ +│ Your profile will be shared with │ +│ chat relays and subscribers. │ +│ Random relays will be selected from │ +│ the list of enabled chat relays. │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Create channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +"Configure relays..." opens Network & Servers view (full settings view) where the user can enable/disable chat relays globally. + +There is no explicit relay selection — the app randomly selects from enabled chat relays, same as for SMP/XFTP servers. + +> **API note**: Currently `apiNewPublicGroup` takes an explicit list of chat relay IDs. Either the API should be reworked to select relays automatically (consistent with SMP/XFTP server selection), or the UI should randomly select from enabled relays and pass the IDs. + +"Create channel" disabled when name is invalid or no relays enabled. + +#### Step 2 — Relay connection progress + +After tapping "Create channel", chat relays are selected automatically and `apiNewPublicGroup` sends relay invitations. Progress shown as a progress bar with label. + +``` +┌────────────────────────────────────────┐ +│ Creating channel... │ +├────────────────────────────────────────┤ +│ [ 📷 ] │ +│ SimpleX News │ +│ │ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ 1/3 relays connected │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ Channel link │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand relay list: + +``` +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ 1/3 relays connected │ +│ relay1.simplex.im ✓ Active │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +``` + +"Channel link" button enabled when ≥1 relay is active. If tapped while relays are still connecting, warning alert: "Not all relays have connected yet. Channel will start working with N relays. Proceed?" — Proceed / Wait. + +#### Step 3 — Channel link + +Shown after tapping "Channel link" or auto-transition when all relays active. Standard `GroupLinkView` with QR code + share (same as group creation). + +``` +┌────────────────────────────────────────┐ +│ Back Channel link Continue │ +├────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ │ │ +│ │ [ QR CODE ] │ │ +│ │ │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────┐ │ +│ │ https://simplex.chat/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ ^ Share link │ +└────────────────────────────────────────┘ +``` + +#### Failure modes (inline on Step 2) + +- **API call fails** (sync — relay invitation send failed): Alert "Error creating channel" + error detail. Retry / Cancel. +- **Partial relay error** (async — some relays don't connect): Progress shows "2/3 relays connected, 1 failed". Expanded view: failed relay with red ● Error. "Channel link" enabled — channel works with fewer relays. +- **All relays error** (async): Progress shows "0/3 relays connected, 3 failed" in red. Alert with Retry / Cancel. + +--- + +### 2.4 Channel Info + +Extends `GroupChatInfoView` with conditional sections for `useRelays = true`. + +**Design rationale:** Owners/subscribers lists live in a sub-view (not inline) to match patterns familiar from other messengers and reduce main info screen clutter. + +#### Owner view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News Edit │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners & subscribers > │ +├────────────────────────────────────────┤ +│ Edit channel profile > │ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Delete channel │ +└────────────────────────────────────────┘ +``` + +No "Leave channel" for single (last) owner. + +Post-MVP: "Chats with subscribers" navigation link in section 1 for subscriber support. + +TBC: share link button in action buttons row. + +#### Subscriber view + +``` +┌────────────────────────────────────────┐ +│ Done SimpleX News │ +├────────────────────────────────────────┤ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ Set chat name... │ +├────────────────────────────────────────┤ +│ 🔍 Search │ 🔇 Mute │ +├────────────────────────────────────────┤ +│ Channel link > │ +│ Owners > │ +├────────────────────────────────────────┤ +│ Welcome message > │ +├────────────────────────────────────────┤ +│ Chat theme > │ +│ Delete messages after > │ +├────────────────────────────────────────┤ +│ Chat relays > │ +│ Clear chat │ +│ Leave channel │ +└────────────────────────────────────────┘ +``` + +Differences from owner view: +- **Owners & subscribers**: replaced with **Owners** +- **Edit channel profile**: hidden +- **Delete channel**: replaced with **Leave channel** + +#### Owners & subscribers sub-view + +Separate sub-view following familiar channel UI patterns from other messengers to increase adoption. + +**Owner's view** ("Owners & subscribers"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners & subscribers │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice (you) > │ +├────────────────────────────────────────┤ +│ 150 SUBSCRIBERS │ +│ bob > │ +│ charlie > │ +│ ... │ +└────────────────────────────────────────┘ +``` + +**Subscriber's view** ("Owners"): + +``` +┌────────────────────────────────────────┐ +│ < Back Owners │ +├────────────────────────────────────────┤ +│ OWNERS │ +│ alice > │ +└────────────────────────────────────────┘ +``` + +> **Protocol note**: Correct subscriber and owner lists with counts must be implemented for MVP. This requires protocol changes to support relay-reported subscriber counts and subscriber list synchronization. See launch plan §3.3. + +#### Chat relays sub-view + +``` +┌────────────────────────────────────────┐ +│ < Back Chat relays │ +├────────────────────────────────────────┤ +│ relay1.simplex.im ● Active │ +│ relay2.simplex.im ● Active │ +│ relay3.simplex.im ● Active │ +│ │ +│ Chat relays forward messages to │ +│ channel subscribers. │ +└────────────────────────────────────────┘ +``` + +Read-only for MVP. In future, owner will be able to manage (add, remove) relays from this view. + +Relay statuses differ by role: +- **Owner**: based on `RelayStatus` — New, Invited, Accepted, Active +- **Subscriber**: based on connection state — Connecting, Connected, Error (TBC: new type or inferred from connection status) + +--- + +### 2.5 Chat Relay Management (Network & Servers) + +Chat relays follow the same placement pattern as SMP/XFTP servers: preset relays appear inside each operator page, custom relays appear in "Your servers" page. + +#### Operator page (e.g. SimpleX Chat) + +New "Chat relays" section added after "Operator" section, before message and file server sections: + +``` +┌────────────────────────────────────────┐ +│ < Back SimpleX Chat servers │ +├────────────────────────────────────────┤ +│ OPERATOR │ +│ ... │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ relay1.simplex.im ✓ │ +│ relay2.simplex.im ✓ │ +│ relay3.simplex.im ✓ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ (message server sections) │ +│ (file server sections) │ +├────────────────────────────────────────┤ +│ Test servers │ +└────────────────────────────────────────┘ +``` + +#### Your servers page + +New "Chat relays" section before "Message servers": + +``` +┌────────────────────────────────────────┐ +│ < Back Your servers │ +├────────────────────────────────────────┤ +│ CHAT RELAYS │ +│ myrelay.example.com ✗ │ +│ │ +│ Chat relays forward messages in │ +│ channels you create. │ +├────────────────────────────────────────┤ +│ MESSAGE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ MEDIA & FILE SERVERS │ +│ ... │ +├────────────────────────────────────────┤ +│ Add server... │ +│ Test servers │ +│ How to use your servers > │ +└────────────────────────────────────────┘ +``` + +#### Relay detail view + +Follows `ProtocolServerView` pattern. Preset: read-only address + test + enable toggle. Custom: editable address + test + enable + delete. TBC editable name (present in backend). + +``` +┌────────────────────────────────────────┐ +│ < Back relay1.simplex.im │ +├────────────────────────────────────────┤ +│ RELAY ADDRESS │ +│ ┌──────────────────────────────────┐ │ +│ │ https://relay1.simplex.im/... │ │ +│ └──────────────────────────────────┘ │ +│ │ +│ Test relay ✓ │ +│ Use for new channels [ON] │ +├────────────────────────────────────────┤ +│ Delete relay │ +└────────────────────────────────────────┘ +``` + +If all relays are disabled: footer warning "No chat relays enabled. Channels require at least one relay." + +--- + +### 2.6 Joining a Channel + +User taps channel link → pre-join view. + +#### Pre-join + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ [📡 avatar] │ +│ SimpleX News │ +│ │ +│ 3 relays ▶ │ +│ ┌──────────────────────────────────┐ │ +│ │ Join channel │ │ +│ └──────────────────────────────────┘ │ +└────────────────────────────────────────┘ +``` + +Relay count visible (from link data). Tapping "3 relays" expands to show relay hostnames. + +**Why:** Subscriber can decide whether to join based on which relays are used. + +#### Connecting + +After "Join channel", relay connections proceed. Progress bar shown above "you are subscriber" — channel already functions with even a single relay connected. + +``` +┌────────────────────────────────────────┐ +│ < [📡] SimpleX News ··· │ +├────────────────────────────────────────┤ +│ │ +│ (chat area — welcome message etc.) │ +│ │ +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ Connecting... 1/3 relays │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +Tap progress label to expand: + +``` +├────────────────────────────────────────┤ +│ [████████████░░░░░░░░░░░░░░░░░░░░░] │ +│ ▼ Connecting... 1/3 relays │ +│ relay1.simplex.im ✓ Connected │ +│ relay2.simplex.im Connecting │ +│ relay3.simplex.im Connecting │ +├────────────────────────────────────────┤ +│ you are subscriber │ +└────────────────────────────────────────┘ +``` + +All connected → progress bar disappears. + +#### Failure modes (inline) +- **Sync failure** (all relays fail on connect call): Alert "Failed to join channel" + Retry / Cancel. +- **Partial failure**: "2/3 relays connected, 1 failed". Channel works. Expanded view shows failed relay with red indicator. +- **All relays fail async**: Red error bar "Channel not connected". TBC: programmatic retry, or only failure indication. + +--- + +## 3. Implementation Order + +| # | Screen | Backend Dependency | Complexity | +|---|--------|--------------------|------------| +| 1 | Chat List — channel icon | None | Low | +| 2 | Channel Messages — `CIChannelRcv` rendering | None | Low | +| 3 | Owner Compose — "Broadcast" placeholder + `asGroup` | None | Low | +| 4 | Channel Info — extended `GroupChatInfoView` | Subscriber/owner lists: protocol changes (§3.3) | Medium | +| 5 | Chat Relay Management — Network & Servers | `APITestChatRelay` (launch plan §2.5) | Medium | +| 6 | Channel Creation — 3-step flow | Relay state events (launch plan §3.2) | High | +| 7 | Join Channel — progress bar + relay states | Relay state events (launch plan §3.2) | Medium | + +Items 1–3 have no backend blockers and can start immediately. Item 4 requires protocol changes for subscriber/owner lists and counts. Items 5–7 depend on backend work. diff --git a/plans/2026-03-05-members-conn-errors.md b/plans/2026-03-05-members-conn-errors.md new file mode 100644 index 0000000000..b63923771a --- /dev/null +++ b/plans/2026-03-05-members-conn-errors.md @@ -0,0 +1,316 @@ +# Save Permanent Connection Errors for Group Members + +## Context + +When a group member's connection handshake fails with a permanent error (e.g., `CONN NOT_ACCEPTED`, `SMP AUTH`, `AGENT A_VERSION`), the ERR event is logged to the UI event stream and discarded. The member record stays stuck in a "connecting" `GroupMemberStatus` (like `memIntroduced`, `memAccepted`) forever. Users see perpetual "connecting" with no explanation and no way to know whether to wait or re-invite. + +**Root cause**: `agentMsgConnStatus` (Subscriber.hs:376) only maps success events (`CONF`, `INFO`, `JOINED`, `CON`) to status transitions. The ERR handler for group members (Subscriber.hs:1054-1056) only logs to UI and completes the command — no status or error is persisted. + +## Solution Summary + +Add `ConnError {connError :: Text}` constructor to `ConnStatus`. Error text is encoded in the `conn_status TEXT` column as `"error "` via `TextEncoding`, and in JSON via `sumTypeJSON` (following `GSSError`/`CIFileStatus` pattern). No new DB column, no migration. When a non-temporary ERR arrives before connection is ready, transition to `ConnError` and notify UI. Messages are not queued for errored connections. + +## Technical Design + +### Error classification + +Use `temporaryOrHostError` from `Simplex.Messaging.Agent.Client` (simplexmq Client.hs:1486, exported at line 60): +- Returns `True` for NETWORK, TIMEOUT, HOST, TEVersion, INACTIVE, CRITICAL-with-restart → **do not save** +- Returns `False` for AUTH, CONN errors, VERSION, INTERNAL, etc. → **save as permanent error** + +Guard: only save when connection is not `ConnReady` and not already `ConnError`. Post-handshake errors (when `connStatus == ConnReady`) are handled by existing `processConnMERR` (AUTH counters, QUOTA counters). + +### Data flow + +``` +Agent ERR event + → Subscriber.hs processGroupMessage ERR handler + → guard: connStatus is not ConnReady, not ConnError, not temporaryOrHostError + → DB: UPDATE connections SET conn_status = 'error ' + → emit: CEvtGroupMemberUpdated user gInfo m m' + → iOS: upsertGroupMember updates model → UI re-renders +``` + +### DB encoding + +`conn_status TEXT NOT NULL` already exists. `ConnError` encodes as `"error " <> errText` using `TextEncoding` (same as `GSSError`). No migration needed — new text values are valid in the existing column. + +### JSON encoding + +Replace manual `ToJSON`/`FromJSON` instances with `$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus)`. This follows the `GroupSndStatus`/`CIFileStatus` pattern — `sumTypeJSON` is already imported in Types.hs (line 60). + +JSON format (platform-dependent via `sumTypeJSON`): +- iOS: `{"error": {"connError": "SMP AUTH"}}` (ObjectWithSingleField) +- Android/Desktop: `{"type": "error", "connError": "SMP AUTH"}` (TaggedObject) +- Nullary cases: `{"ready": {}}` / `{"type": "ready"}` (not plain `"ready"` strings) + +Note: `ConnSndReady` JSON tag changes from `"snd-ready"` to `"sndReady"` (`dropPrefix "Conn"` applies `fstToLower`). This is safe — JSON is core→UI within same build. Swift auto-synthesis matches on `sndReady` case name. + +### Clear on recovery + +When CON event arrives, `agentMsgConnStatus` returns `Just ConnReady`, `updateConnStatus` overwrites `conn_status` to `"ready"`. Error is implicitly cleared — no special cleanup needed. + +### ConnStatus state machine update + +``` +Existing transitions (unchanged): + ConnNew → ConnRequested → ConnAccepted → ConnSndReady → ConnReady + ConnNew → ConnJoined → ConnSndReady → ConnReady + ConnPrepared → ConnJoined → ConnSndReady → ConnReady + Any → ConnDeleted + +New transitions: + Any pre-ready state → ConnError (on permanent ERR) + ConnError → ConnReady (on successful CON — recovery) + ConnError → ConnDeleted (on connection deletion) +``` + +### Pattern match safety audit + +Traced every ConnStatus pattern match across Haskell (10 files), Swift (6 files), Kotlin (3 files). + +**Must update (exhaustive matches):** + +| Location | Change | +|---|---| +| Types.hs textEncode/textDecode (~1703) | Add ConnError encoding/decoding | +| Types.hs ToJSON/FromJSON (~1696) | Replace with `sumTypeJSON` TH splice | +| Swift ConnStatus.initiated | Add `case .error: return nil` | +| Kotlin ConnStatus.initiated | Add `Error -> null` (follow-up) | + +**Must update (behavioral):** + +| Location | Current behavior | Fix | +|---|---|---| +| Internal.hs memberSendAction (line 2041) | ConnError falls to `otherwise -> pendingOrForwarded` — messages queued for permanently errored connections | Add pattern guard `ConnError {} <- connStatus -> Nothing` | + +**Verified safe — no changes needed:** + +| Pattern | Sites | Why safe | +|---|---|---| +| `== ConnReady` / `== ConnSndReady` | 12 sites (connReady, Contact.ready, GroupMember.ready, sndReady, readyMemberConn, xftpSndFileTransfer) | ConnError ≠ these → excluded from "ready" paths | +| `== ConnPrepared` | 8 sites (joinPreparedConn, nextConnectPrepared, isContactCard, contactRequestPlan) | ConnError ≠ ConnPrepared → doesn't trigger join/prepare logic | +| `== ConnNew` | 4 sites (contactConnInitiated, nextAcceptContactRequest, APIPrepareContact) | ConnError ≠ ConnNew → doesn't trigger new-connection logic | +| `!= ConnDeleted` (DB WHERE) | 6 sites (getConnectionEntity, *ConnsToSub) | ConnError ≠ ConnDeleted → errored connections remain findable and subscribable (correct — enables recovery via CON). **Add TODO comments** at each site to consider whether ConnError connections should be excluded. | +| `updateConnectionStatusFromTo` | 3 sites | Compares current to specific `fromStatus` — ConnError won't accidentally match | +| `readyMemberConn` (Internal.hs:2078) | 1 site | `connStatus == ConnReady \|\| == ConnSndReady` — ConnError → `otherwise = Nothing` (correct) | +| `connDisabled`/`connInactive` | 6 sites | Derived from error counters, not connStatus | +| `agentMsgConnStatus` | 1 site | Only produces ConnSndReady/ConnRequested/ConnReady — no ConnError output | + +## Implementation Plan + +### 1. Haskell: ConnStatus type + +**File: `src/Simplex/Chat/Types.hs`** + +**ConnStatus** (~line 1673): Add constructor after `ConnDeleted`: +```haskell + | ConnError {connError :: Text} +``` +Record syntax for `sumTypeJSON` field name in JSON. `deriving (Eq, Show, Read)` unchanged. + +**TextEncoding instance** (~line 1703) — for DB storage: +```haskell + textEncode = \case + ... + ConnError err -> "error " <> err + textDecode s + | Just err <- T.stripPrefix "error " s = Just (ConnError err) + | otherwise = case s of + "new" -> Just ConnNew + ... (existing cases unchanged) + _ -> Nothing +``` + +Note: `textDecode` changes from `\case` to named parameter `s` to support `stripPrefix` guard. + +**JSON instances** (~lines 1696-1701): Replace manual instances with TH splice: +```haskell +-- Remove: +-- instance FromJSON ConnStatus where parseJSON = textParseJSON "ConnStatus" +-- instance ToJSON ConnStatus where toJSON = J.String . textEncode; toEncoding = JE.text . textEncode +-- Add: +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "Conn") ''ConnStatus) +``` + +`sumTypeJSON` and `dropPrefix` already imported (line 60). `FromField`/`ToField` instances unchanged — still use `TextEncoding` for DB. + +**`connReady`** (line 1597): No change — `== ConnReady || == ConnSndReady`, `ConnError _` naturally returns `False`. + +### 2. Haskell: Subscriber.hs — save error on permanent ERR + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +Extend existing import (line 74): +```haskell +import Simplex.Messaging.Agent.Client (temporaryOrHostError, getAgentWorker, ...) +``` + +Update ERR handler in `processGroupMessage` (line 1054-1056). Current: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () +``` + +New: +```haskell +ERR err -> do + eToView $ ChatErrorAgent err (AgentConnId agentConnId) (Just connEntity) + when (corrId /= "") $ withCompletedCommand conn agentMsg $ \_cmdData -> pure () + let Connection {connStatus = cs} = conn + case cs of + ConnReady -> pure () + ConnError _ -> pure () + _ | temporaryOrHostError err -> pure () + | otherwise -> do + let errText = tshow err + withStore' $ \db -> updateConnectionStatus db conn (ConnError errText) + let conn' = conn {connStatus = ConnError errText} + m' = m {activeConn = Just conn'} + toView $ CEvtGroupMemberUpdated user gInfo m m' +``` + +Note: `let Connection {connStatus = cs} = conn` destructures via pattern binding, avoiding ambiguous `connStatus conn` field selector under `DuplicateRecordFields`. + +No new store function — reuses existing `updateConnectionStatus` (Direct.hs:937) which calls `updateConnectionStatus_` → `textEncode` → stores `"error SMP AUTH"` in `conn_status`. + +### 3. Haskell: memberSendAction — don't queue for errored connections + +**File: `src/Simplex/Chat/Library/Internal.hs`** + +Update `memberSendAction` (line 2040-2044). Current: +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +Add pattern guard after first guard (can't use `==` with associated data): +```haskell + Just conn@Connection {connStatus} + | connDisabled conn || connStatus == ConnDeleted || memberStatus == GSMemRejected -> Nothing + | ConnError {} <- connStatus -> Nothing + | connInactive conn -> Just MSAPending + | connStatus == ConnSndReady || connStatus == ConnReady -> sendBatchedOrSeparate conn + | otherwise -> pendingOrForwarded +``` + +### 4. Swift: ConnStatus enum + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** (~line 2301) + +Change from `String`-backed raw value enum to enum with associated value. Auto-synthesized `Decodable` handles `sumTypeJSON` format (same as `GroupSndStatus`, `CIFileStatus`): + +```swift +public enum ConnStatus: Decodable, Hashable { + case new + case prepared + case joined + case requested + case accepted + case sndReady + case ready + case deleted + case error(connError: String) + + var initiated: Bool? { + switch self { + case .new: return true + case .prepared: return false + case .joined: return false + case .requested: return true + case .accepted: return true + case .sndReady: return nil + case .ready: return nil + case .deleted: return nil + case .error: return nil + } + } +} +``` + +No custom `init(from:)` needed. `Hashable`/`Equatable` auto-synthesized. Existing equality checks like `connStatus == .ready` still compile (nullary cases). + +### 5. Swift: Connection computed property + +**File: `apps/ios/SimpleXChat/ChatTypes.swift`** + +Add computed property to `Connection` struct (~line 2092, after `connStatus`): +```swift +public var connError: String? { + if case let .error(err) = connStatus { return err } + return nil +} +``` + +### 6. Swift: Member list status + +**File: `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift`** + +Update `memberConnStatus` function (~line 457). Insert error check FIRST (before `connDisabled`/`connInactive`): +```swift +private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { + return "disabled" + } else if member.activeConn?.connInactive ?? false { + return "inactive" + } else { + return member.memberStatus.shortText + } +} +``` + +**File: `apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift`** + +Update `memberStatus` function (line 198). Insert error check FIRST (before `connDisabled` at line 199): +```swift + if case .error = member.activeConn?.connStatus { + return "connection error" + } else if member.activeConn?.connDisabled ?? false { +``` + +### 7. Swift: Member info error display + +**File: `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`** + +Add error display section after the `connStats` section (~line 190): +```swift +if let connError = member.activeConn?.connError { + Section(header: Text("Connection error").foregroundColor(theme.colors.secondary)) { + Text(connError) + .foregroundColor(theme.colors.secondary) + .font(.callout) + .textSelection(.enabled) + } +} +``` + +## Files Changed Summary + +| Layer | File | Change | +|-------|------|--------| +| Core | `Types.hs` | Add `ConnError {connError :: Text}` to ConnStatus, update TextEncoding, replace JSON with `sumTypeJSON` TH splice | +| Logic | `Subscriber.hs` | Import `temporaryOrHostError`, handle permanent ERR for group members | +| Logic | `Internal.hs` | Add `ConnError` guard to `memberSendAction` → return `Nothing` | +| iOS | `ChatTypes.swift` | ConnStatus: auto-synthesized Decodable with `.error(connError:)`, Connection: `connError` computed property | +| iOS | `GroupChatInfoView.swift` | Show "connection error" in `memberConnStatus` (first check) | +| iOS | `MemberSupportView.swift` | Show "connection error" in `memberStatus` (first check) | +| iOS | `GroupMemberInfoView.swift` | Show error description section | + +## Verification + +1. **Build Haskell**: `cabal build --ghc-options -O0` +2. **Build iOS**: Verify Swift compiles — existing `connStatus == .ready` comparisons still work (nullary cases) +3. **JSON format**: Verify `sumTypeJSON` output matches Swift auto-synthesis expectations (nullary: `{"ready": {}}`, error: `{"error": {"connError": "..."}}`) +4. **Backward compat**: New `"error ..."` values in `conn_status` only appear after code update. Old code cannot parse them (downgrade risk, same as any new enum value). +5. **Recovery**: CON event → `updateConnectionStatus_ ConnReady` → overwrites `"error ..."` with `"ready"` in DB +6. **memberSendAction**: Verify messages are NOT queued for ConnError connections + +## Out of Scope (immediate follow-up) + +**Kotlin/Android/Desktop**: `ConnStatus` enum in `ChatModel.kt:2640` needs custom serializer for `sumTypeJSON` format (TaggedObject: `{"type": "error", "connError": "..."}`) + `Connection` needs `connError` computed property + member status UI. Must be updated before Android/Desktop builds from this commit. Existing bug at `GroupChatInfoView.kt:883` (`connDisabled` checked twice, should be `connInactive` on second check). diff --git a/plans/2026-03-13-message-keys-forwarding.md b/plans/2026-03-13-message-keys-forwarding.md new file mode 100644 index 0000000000..d495b30201 --- /dev/null +++ b/plans/2026-03-13-message-keys-forwarding.md @@ -0,0 +1,473 @@ +# Plan: Signed Message Storage, Forwarding, and Verification + +## Context + +The protocol types for signatures exist (`MsgSignatures`, `MsgSigData`, `ChatBinding`), the parser handles `/`/`>`/`{` element prefixes, and `verifySig` checks signatures. What's missing: + +1. **Signing when sending** — members sign their messages before sending to the relay +2. **Signature storage** — persisting signatures alongside message content +3. **Signature forwarding** — relay preserves and forwards original signatures intact +4. **Binding correctness** — bindings aren't covered by signatures or validated +5. **Required signatures** — admin events must require valid signatures in relay groups +6. **Visibility** — expose signature verification status in chat items + +## Design + +### A. Binding: Reconstructed, Not Sent + +`CBGroup {groupRootKey, senderMemberId}` — both known to verifier from context. Replace with single-byte binding tag on wire. + +Wire: ` ()*` + +Signed payload (constructed by signer and verifier, not on wire): +``` +smpEncode 'G' <> smpEncode (groupRootKey, senderMemberId) <> jsonBody +``` + +The binding tag is separate from the binding-specific prefix. SMP tuple encoding is concatenation, so `smpEncode ('G', k, m) = smpEncode 'G' <> smpEncode (k, m)` — same bytes either way. + +### B. Signing Context — Data, Not Function + +A generic record carries key material and binding data for signing: + +```haskell +data MsgSigning = MsgSigning + { sigBindingTag :: BindingTag + , sigPrefix :: ByteString -- binding-specific, e.g. smpEncode (rootKey, memberId) + , sigPrivKey :: C.PrivateKeyEd25519 + } +``` + +`sigBindingTag` goes into `MsgSignatures` on the wire (tells verifier which binding to reconstruct). `sigPrefix` is the binding-specific bytes. The signing function combines: `smpEncode sigBindingTag <> sigPrefix <> jsonBody`. + +Group-specific constructor: +```haskell +groupMsgSigning :: GroupKeys -> GroupMember -> MsgSigning +groupMsgSigning GroupKeys {groupRootKey, memberPrivKey} GroupMember {memberId} = + MsgSigning BTGroup (smpEncode (groupRootPubKey groupRootKey, memberId)) memberPrivKey +``` + +For contacts in the future — different constructor, different binding tag, same `MsgSigning` record and same `createSndMessages` path. + +### C. Per-Event Signing Decision — Caller, Not Policy + +The decision of whether to sign each event lives with the caller, not inside `createSndMessages`. The caller provides `Maybe MsgSigning` per event: + +```haskell +createSndMessages :: (MsgEncodingI e, Traversable t) + => t (ConnOrGroupId, ChatMsgEvent e, Maybe MsgSigning) + -> CM' (t (Either ChatError SndMessage)) +``` + +In `sendGroupMessages_`: +```haskell +let signing evt = case groupKeys gInfo of + Just gk | requiresSignature (toCMEventTag evt) -> Just (groupMsgSigning gk (membership gInfo)) + _ -> Nothing + idsEvts = L.map (\evt -> (GroupId groupId, evt, signing evt)) events +``` + +`requiresSignature` is group policy — only roster-modifying events (`XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict`). Content is never signed (deniability). When contact signing is added, a different caller uses a different predicate — `createSndMessages` is mechanical. + +### D. Signature Storage — Persisted for History + +Signatures are persisted in `msg_sigs BLOB` column alongside `msg_body` in the same INSERT. One DB operation. + +**Why persist (not ephemeral):** History delivery needs original signatures. In relay groups, history is forwarded with signatures preserved. In non-relay groups (if signing is extended), own sent signatures must survive for delivery to new members. Persisting from the start avoids losing generality. + +`msg_body` remains unchanged (JSON, backward compatible). Content and authentication are orthogonal. + +### E. Signing Scope — Deniability vs Authentication + +Only roster-modifying messages are signed. Content messages (`XMsgNew` etc.) are NEVER signed. + +1. **Deniability** — signing content creates non-repudiable proof of authorship. Anyone with the message bytes could prove who wrote it. Antithetical to SimpleX's privacy model. + +2. **Threat model** — relay manipulation of content is detectable post-hoc via cross-relay consistency (multiple independent relays). Sufficient because content is not irreversible. Roster/profile changes are disruptive and irreversible (member removed, role changed, group deleted) — must be authenticated at processing time. + +### F. Symmetric Encoding + +```haskell +encodeMsgElement :: Maybe MsgSignatures -> ByteString -> ByteString +encodeMsgElement Nothing body = body +encodeMsgElement (Just sigs) body = "/" <> smpEncode sigs <> body +``` + +Dual of `elementP`'s `'/'`/`'{'` cases. Used by both send batcher (`batchMessages`) and forward batcher (`batchDeliveryTasks1`). No signing logic in any batcher — only structural encoding. + +### E. Delivery Tasks: `msgBody` not `chatMessage` + +`MessageDeliveryTask` carries `msgBody :: ByteString` (raw JSON from `msg_body`) + `msgSignatures_ :: Maybe MsgSignatures` — NOT `chatMessage :: ChatMessage 'Json`. + +**Why `msgBody` is sufficient:** +- All delivery task processing is structural — encode, batch, send. Content decisions happen at task CREATION time (in `processEvent`), not delivery time. +- `DJRelayRemoved` currently wraps `chatMessage` in JSON `XGrpMsgForward` — but should use binary encoding instead (same `>element` format as normal batching, just single-element). Binary encoding only needs raw bytes + signatures, not parsed ChatMessage. +- More general — works for any future message type without coupling to JSON. +- Eliminates a parse+re-encode cycle (raw bytes → ChatMessage → chatMsgToBody → bytes). + +### F. DJRelayRemoved: Binary Encoding + +Current: wraps chatMessage in JSON `XGrpMsgForward` event. New: produces binary batch with single `>/` element, same as normal forwarding. The receiver already handles binary forwarded elements through `elementP` → `xGrpMsgForward`. + +### G. Verification with Binding + +```haskell +verifySig gInfo GroupMember {memberPubKey = Just pk, memberId} + (Just MsgSigData {signatures = MsgSignatures {bindingTag = BTGroup, signatures}, signedBody}) + | Just gk <- groupKeys gInfo = + let binding = smpEncode ('G', groupRootPubKey (groupRootKey gk), memberId) + in all (\(MsgSignature KRMember sig) -> C.verify pk sig (binding <> signedBody)) signatures +verifySig _ _ _ = True +``` + +### H. Signature Enforcement + +**Must be signed** (reject if unsigned in relay groups with keys): +- `XGrpDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpMemDel`, `XGrpMemRole`, `XGrpMemRestrict` + +**Not signed** (deniability — see §E): +- `XMsgNew` and all other content events + +**Conditionally signed:** +- `XGrpMemNew` — not always signed because members/subscribers can join via chat relays. Signed when owners/admins add members directly. Enforcement is context-dependent (checks sender role, not just event tag). + +**Channel posts** (`FwdChannel`): validate if signed, strip before forwarding. + +### I. Expose in UI + +Two display paths in CLI: + +**Path 1: Chat item history** (also used by mobile UI) +- `CIMeta.msgSigned :: Bool` — set during chat item creation +- Flow: `VerifiedMsg` → `isJust signedMsg_` → `RcvMessage.msgSigned` → `createNewRcvChatItem` → `createNewChatItem_` (INSERT with `msg_signed`) → SELECT reads `msg_signed` → `mkCIMeta` → View.hs +- Migration: `ALTER TABLE chat_items ADD COLUMN msg_signed` (in `chat_relays` migration) +- Note: `RcvMessage` is a goner (see pending refactor). In future, `msgSigned` flows from `VerifiedMsg` directly. + +**Path 2: Immediate CLI events** (ChatEvent/ChatResponse) +- Receive events: add `Bool` to ChatEvent constructors that correspond to signed events + - `CEvtMemberRole` — XGrpMemRole + - `CEvtMemberBlockedForAll` — XGrpMemRestrict + - `CEvtDeletedMemberUser` — XGrpMemDel (self) + - `CEvtDeletedMember` — XGrpMemDel (other) + - `CEvtGroupDeleted` — XGrpDel + - `CEvtGroupUpdated` — XGrpInfo / XGrpPrefs +- Send responses: add `Bool` to ChatResponse constructors for send-side + - `CRMembersRoleUser` — APIMembersRole + - `CRMembersBlockedForAllUser` — APIBlockMembersForAll + - `CRUserDeletedMembers` — APIRemoveMembers + - `CRGroupDeletedUser` — APIDeleteChat (group) + - `CRGroupUpdated` — APIUpdateGroupProfile +- Source: receive `msgSigned` from `RcvMessage`; send from `useRelays' gInfo` +- View.hs: append " (signed)" to event text when Bool is True + +**Correlation: `requiresSignature` events ↔ CLI display** + +| Event | Receive ChatEvent | Send ChatResponse | +|-------|-------------------|-------------------| +| XGrpDel | CEvtGroupDeleted | CRGroupDeletedUser | +| XGrpInfo | CEvtGroupUpdated | CRGroupUpdated | +| XGrpPrefs | CEvtGroupUpdated | CRGroupUpdated | +| XGrpMemDel | CEvtDeletedMember[User] | CRUserDeletedMembers | +| XGrpMemRole | CEvtMemberRole | CRMembersRoleUser | +| XGrpMemRestrict | CEvtMemberBlockedForAll | CRMembersBlockedForAllUser | + +### J. Pending Refactor: Remove RcvMessage + +`RcvMessage` carries redundant fields (`msgBody`, `authorMember` never read; `chatMsgEvent`, `sharedMsgId_` derivable from `verifiedMsg`). Plan: +1. Remove `RcvMessage` type +2. `NewRcvMessage` = `verifiedMsg` + `brokerTs` + `forwardedByMember` (drop `chatMsgEvent`) +3. `createNewRcvMessage` returns just `msgId` +4. Consumers extract what they need from `verifiedMsg` already in scope + +## Implementation Steps + +### Step 1: Foundation — Types + Encoding + Storage Schema ✅ + +- `ChatBinding = CBGroup` with `Encoding` instance (was `BindingTag`) +- `MsgSignatures { chatBinding :: ChatBinding, signatures :: NonEmpty MsgSignature }` +- `MsgSigning { bindingTag, bindingData, keyRef, privKey }` — generic signing context record +- `encodeBatchElement` in `Batch.hs` (moved from Protocol.hs) +- `requiresSignature :: CMEventTag e -> Bool` +- Migration: `ALTER TABLE messages ADD COLUMN msg_sigs BLOB` +- `SndMessage` gains `msgSignatures_ :: Maybe MsgSignatures` +- `createNewRcvMessage`: already accepts and stores `Maybe MsgSignatures` + +### Step 2: Sign on Send + Verify with Binding ✅ + +- `groupMsgSigning :: GroupInfo -> ChatMsgEvent e -> Maybe MsgSigning` in Internal.hs — takes GroupInfo, decides per-event +- `createSndMessages` takes `(ConnOrGroupId, Maybe MsgSigning, ChatMsgEvent e)` triples +- `createNewSndMessage` accepts `Maybe MsgSigning`, signs inline, stores `msg_sigs` in same INSERT +- `batchMessages` encodes elements via `encodeBatchElement` (two parallel lists, encode once per message) +- `verifySig` in Subscriber.hs reconstructs binding prefix from `GroupInfo` + `memberId`, verifies with `C.verify` +- Removed dead code: `signGroupMessages`, `updateSndMsgSignatures`, `groupSignFn`, `signMsgBody` + +### Step 3: Store, Forward, Verify — End-to-End + +Steps 3-5 from the original plan are one flow. They must ship together because the e2e test — member A signs → relay stores → relay forwards → member B verifies — is the only meaningful test. + +#### Critical invariant: original bytes must be preserved + +JSON round-trip through aeson doesn't preserve key ordering. Currently `msg_body` is stored via `chatMsgToBody chatMsg` (re-encoded from parsed `ChatMessage`). These bytes may differ from what the sender signed. For signature verification after forwarding, the relay must store the **original** bytes in `msg_body`. + +When `elementP` parses a signed element (`/`), `A.match msgP` captures the exact JSON bytes as `signedBody` in `MsgSigData`. This is what must be stored as `msg_body` for signed messages. + +For unsigned messages, `chatMsgToBody chatMsg` is fine — no signature to preserve. + +#### E2E Flow + +``` +Member A Relay Member B +───────── ───── ──────── +sign(roster event) + ↓ +/ ──────────→ receive + parse (elementP) + msgSig_ has signedBody (exact bytes) + verify (withVerifiedSig) + store signedBody as msg_body ──(a) + store MsgSignatures as msg_sigs + ↓ + read msg_body + msg_sigs from DB ──(b) + >/ ──────→ receive + parse + elementP: > → / → json + msgSig_ has signedBody + verify (withVerifiedSig) + store signedBody + sigs ──(c) +``` + +#### (a) Relay receives signed message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +processAChatMsg(line 920) — has msgSig_ (with signedBody), chatMsg + │ passes chatMsg only, msgSig_ not threaded + ▼ +processEvent(line 941) — has chatMsg only + │ body = chatMsgToBody chatMsg ← RE-ENCODES, loses original bytes + ▼ +saveGroupRcvMsg(Internal.hs:2218) — params: user, groupId, member, conn, msgMeta, body, chatMsg + │ no signature parameter + ▼ +createNewMessageAndRcvMsgDelivery(Store/Messages.hs:262) — no signature parameter + │ passes Nothing for msgSignatures_ + ▼ +createNewRcvMessage(Store/Messages.hs:294) — HAS Maybe MsgSignatures param, receives Nothing + │ + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (6 functions):** + +1. **`processAChatMsg`** (Subscriber.hs:920→934): pass `msgSig_` to `processEvent` + - Current: `processEvent gInfo' m' chatMsg` + - New: `processEvent gInfo' m' chatMsg msgSig_` + +2. **`processEvent`** (Subscriber.hs:941): accept `Maybe MsgSigData`, use `signedBody` when signed + - Current sig: `GroupInfo -> GroupMember -> ChatMessage e -> CM (Maybe NewMessageDeliveryTask)` + - New sig: `GroupInfo -> GroupMember -> ChatMessage e -> Maybe MsgSigData -> CM (Maybe NewMessageDeliveryTask)` + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` (where `signatures :: MsgSigData -> MsgSignatures`) + - Pass both `body` and `sigs_` to `saveGroupRcvMsg` + +3. **`saveGroupRcvMsg`** (Internal.hs:2218): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> CM (...)` + - New sig: `User -> GroupId -> GroupMember -> Connection -> MsgMeta -> MsgBody -> ChatMessage e -> Maybe MsgSignatures -> CM (...)` + - Pass to `createNewMessageAndRcvMsgDelivery` + - 1 caller: Subscriber.hs:944 + +4. **`createNewMessageAndRcvMsgDelivery`** (Store/Messages.hs:262): add `Maybe MsgSignatures` parameter + - Current sig: `DB.Connection -> ConnOrGroupId -> NewRcvMessage e -> Maybe SharedMsgId -> RcvMsgDelivery -> Maybe GroupMemberId -> ExceptT StoreError IO RcvMessage` + - New: add `Maybe MsgSignatures` after `Maybe SharedMsgId` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 2 callers: `saveGroupRcvMsg` (Internal.hs:2226) and `saveDirectRcvMSG` (Internal.hs:2215) + - `saveDirectRcvMSG` passes `Nothing` (direct messages not signed yet) + +5. **`createNewRcvMessage`** (Store/Messages.hs:294): no change — already has `Maybe MsgSignatures` param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (b) Relay reads delivery tasks → forwards with preserved signatures + +**Current call chain** (Store/Delivery.hs → Delivery.hs → Batch.hs): + +``` +getMsgDeliveryTask_(Store/Delivery.hs:130) + │ SQL: SELECT ... msg.msg_body ... ← no msg_sigs + │ Row type: ... ChatMessage 'Json ... ← parsed via FromField, RE-ENCODES on read + ▼ +MessageDeliveryTask { chatMessage :: ChatMessage 'Json } (Delivery.hs:128) + ▼ +batchDeliveryTasks1(Batch.hs:73) + │ destructures: MessageDeliveryTask {taskId, fwdSender, brokerTs, chatMessage} + ▼ +encodeFwdElement(Batch.hs:96) — takes GrpMsgForward -> ChatMessage 'Json -> ByteString + │ ">" <> smpEncode fwd <> chatMsgToBody chatMessage ← RE-ENCODES AGAIN + ▼ +Wire: > ← signature would fail +``` + +**Changes (5 functions/types):** + +6. **`MessageDeliveryTask`** (Delivery.hs:128): replace `chatMessage` field + - Current: `chatMessage :: ChatMessage 'Json` + - New: `msgBody :: ByteString, msgSignatures_ :: Maybe MsgSignatures` + - `chatMessage` used only in 2 places: `batchDeliveryTasks1` (Batch.hs:86) and `DJRelayRemoved` (Subscriber.hs:3375) — both just encode, no content inspection + +7. **`MessageDeliveryTaskRow`** (Store/Delivery.hs:128): change column type + - Current: `... ChatMessage 'Json, BoolInt` + - New: `... DB.Binary, Maybe MsgSignatures, BoolInt` + +8. **`getMsgDeliveryTask_`** (Store/Delivery.hs:130): add `msg.msg_sigs` to SELECT + - Current SQL: `msg.msg_body, t.message_from_channel` + - New SQL: `msg.msg_body, msg.msg_sigs, t.message_from_channel` + - `toTask`: destructure `DB.Binary` as raw bytes, `Maybe MsgSignatures` from `msg_sigs` + +9. **`encodeFwdElement`** (Batch.hs:96): take raw bytes + signatures + - Current sig: `GrpMsgForward -> ChatMessage 'Json -> ByteString` + - New sig: `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` + - Body: `">" <> smpEncode fwd <> encodeBatchElement sigs_ msgBody` + +10. **`batchDeliveryTasks1`** (Batch.hs:73): use new task fields + - Current: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task` + - New: `MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task` + - Current: `msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage` + - New: `fwdBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} msgSignatures_ msgBody` + +After change: +``` +Wire: >/ ← signature valid +``` + +#### (c) Member receives forwarded message → stores with original bytes + +**Current call chain** (Subscriber.hs → Internal.hs → Store/Messages.hs): + +``` +xGrpMsgForward(Subscriber.hs:3159) — has chatMsg + msgSig_ (with signedBody) + ▼ +processForwardedMsg(Subscriber.hs:3172) — closure, has chatMsg, msgSig_ in scope but not used + │ body = chatMsgToBody chatMsg ← RE-ENCODES + ▼ +saveGroupFwdRcvMsg(Internal.hs:2237) — no signature parameter + │ passes Nothing to createNewRcvMessage + ▼ +createNewRcvMessage(Store/Messages.hs:294) — receives Nothing + ▼ +INSERT INTO messages ... msg_body=RE-ENCODED, msg_sigs=Nothing +``` + +**Changes (3 functions):** + +11. **`processForwardedMsg`** (Subscriber.hs:3172): use `signedBody` when signed, pass sigs + - `msgSig_` is in scope from `xGrpMsgForward` closure + - Current: `let body = chatMsgToBody chatMsg` + - New: `let body = maybe (chatMsgToBody chatMsg) signedBody msgSig_` + - Extract: `let sigs_ = signatures <$> msgSig_` + - Pass `sigs_` to `saveGroupFwdRcvMsg` + +12. **`saveGroupFwdRcvMsg`** (Internal.hs:2237): add `Maybe MsgSignatures` parameter + - Current sig: `User -> GroupInfo -> GroupMember -> Maybe GroupMember -> MsgBody -> ChatMessage e -> UTCTime -> CM (Maybe RcvMessage)` + - New: add `Maybe MsgSignatures` after `UTCTime` + - Current: passes `Nothing` to `createNewRcvMessage` + - New: passes the received `Maybe MsgSignatures` + - 1 caller: Subscriber.hs:3175 + +13. **`createNewRcvMessage`**: no change — already has param + +After change: +``` +INSERT INTO messages ... msg_body=ORIGINAL_BYTES, msg_sigs=MsgSignatures +``` + +#### (d) DJRelayRemoved — binary encoding + +**Current** (Subscriber.hs:3371-3382): +```haskell +let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, chatMessage} = task + fwdEvt = XGrpMsgForward GrpMsgForward {fwdSender, fwdBrokerTs} chatMessage ← JSON wrapping + cm = ChatMessage {chatVRange = vr, msgId = Nothing, chatMsgEvent = fwdEvt} + body = chatMsgToBody cm ← RE-ENCODES +createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body +``` + +**Change** (1 function, same location): + +14. **DJRelayRemoved handler** (Subscriber.hs:3374): use binary encoding + ```haskell + let MessageDeliveryTask {senderGMId, fwdSender, brokerTs = fwdBrokerTs, msgBody, msgSignatures_} = task + fwd = GrpMsgForward {fwdSender, fwdBrokerTs} + body = encodeBinaryBatch [encodeFwdElement fwd msgSignatures_ msgBody] + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + ``` + Receiver handles via `elementP` → same path as batched forwarding. + +#### (e) Enforcement — required signatures + +**Current**: `withVerifiedSig` (Subscriber.hs:3203) calls `verifySig` which returns `True` for `Nothing` (unsigned). All unsigned messages pass. + +**Change** (1 function): + +15. **`withVerifiedSig`** (Subscriber.hs:3203): add unsigned rejection + - Needs the event tag to check `requiresSignature` + - Current sig: `GroupInfo -> Maybe GroupChatScopeInfo -> GroupMember -> Maybe MsgSigData -> UTCTime -> CM a -> CM (Maybe a)` + - New: add `CMEventTag e` parameter, or pass from caller + - Logic: if `isNothing msgSig_` AND `groupKeys gInfo` is `Just` AND `requiresSignature tag` → reject + +#### (f) Channel stripping + +**Current** (Subscriber.hs:3169): `FwdChannel -> processForwardedMsg Nothing` — skips `withVerifiedSig` entirely. + +**Change** (in `xGrpMsgForward`): + +16. For `FwdChannel`: validate signature if present (call `verifySig`), then call `processForwardedMsg` with `msgSig_` replaced by `Nothing` — strips signatures before storage. Channel posts are anonymous; storing the author's signature would leak identity. + +#### Summary: 16 function changes + +| # | Function | File | Change | +|---|----------|------|--------| +| 1 | `processAChatMsg` | Subscriber.hs:920 | Pass `msgSig_` to `processEvent` | +| 2 | `processEvent` | Subscriber.hs:941 | Accept `Maybe MsgSigData`, use `signedBody` as body when signed | +| 3 | `saveGroupRcvMsg` | Internal.hs:2218 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 4 | `createNewMessageAndRcvMsgDelivery` | Store/Messages.hs:262 | Add `Maybe MsgSignatures` parameter (2 callers: group passes sigs, direct passes Nothing) | +| 5 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 6 | `MessageDeliveryTask` | Delivery.hs:128 | `msgBody :: ByteString` + `msgSignatures_` instead of `chatMessage` | +| 7 | `MessageDeliveryTaskRow` | Store/Delivery.hs:128 | `DB.Binary` + `Maybe MsgSignatures` instead of `ChatMessage 'Json` | +| 8 | `getMsgDeliveryTask_` | Store/Delivery.hs:130 | Add `msg.msg_sigs` to SELECT, read `msg_body` as raw bytes | +| 9 | `encodeFwdElement` | Batch.hs:96 | `GrpMsgForward -> Maybe MsgSignatures -> ByteString -> ByteString` | +| 10 | `batchDeliveryTasks1` | Batch.hs:73 | Use task's `msgBody` + `msgSignatures_` | +| 11 | `processForwardedMsg` | Subscriber.hs:3172 | Use `signedBody` as body when signed, pass sigs | +| 12 | `saveGroupFwdRcvMsg` | Internal.hs:2237 | Add `Maybe MsgSignatures` parameter (1 caller) | +| 13 | `createNewRcvMessage` | Store/Messages.hs:294 | No change — already has param | +| 14 | DJRelayRemoved handler | Subscriber.hs:3374 | Binary encoding with `encodeFwdElement` | +| 15 | `withVerifiedSig` | Subscriber.hs:3203 | Reject unsigned messages when `requiresSignature` in relay group with keys | +| 16 | `xGrpMsgForward` FwdChannel | Subscriber.hs:3169 | Validate sig if present, strip before storage | + +#### Test + +E2E test in relay group with keys: +1. Member A sends `XGrpMemRole` (requires signature) → signed in DB on A +2. Relay receives → verifies → stores `signedBody` as `msg_body` + `MsgSignatures` as `msg_sigs` +3. Relay reads `msg_body` + `msg_sigs` from DB → `>/` on wire +4. Member B receives → `elementP` parses >→/→json → `signedBody` has original bytes → verifies → stores +5. Unsigned `XGrpDel` from member without keys → rejected by enforcement +6. Channel post with signature → signature stripped before storage + +## Files + +| File | Step | Changes | +|------|------|---------| +| `Protocol.hs` | 1,2 | `ChatBinding`, `MsgSignatures` encoding, `MsgSigning`, `requiresSignature` | +| `Messages.hs` | 1 | `SndMessage` + `msgSignatures_` | +| `Store/Messages.hs` | 1,2,3 | `createNewSndMessage` signs + stores; `createNewRcvMessage` already has sig param; `createNewMessageAndRcvMsgDelivery` add sig param | +| Migration | 1 | `msg_sigs` column | +| `Internal.hs` | 2,3 | `groupMsgSigning`; `createSndMessages` per-event signing; `saveGroupRcvMsg` + `saveGroupFwdRcvMsg` add sig params | +| `Batch.hs` | 2,3 | `encodeBatchElement` in `batchMessages`; `encodeFwdElement` takes sigs + raw bytes; `batchDeliveryTasks1` uses raw task fields | +| `Subscriber.hs` | 2,3 | `verifySig` with binding; `processAChatMsg`→`processEvent` thread `msgSig_`; `processForwardedMsg` use `signedBody`; `withVerifiedSig` enforcement; channel strip; DJRelayRemoved binary | +| `Delivery.hs` | 3 | `MessageDeliveryTask`: `msgBody` + `msgSignatures_` instead of `chatMessage` | +| `Store/Delivery.hs` | 3 | `MessageDeliveryTaskRow` + `getMsgDeliveryTask_`: read `msg_sigs` + raw `msg_body` | diff --git a/plans/2026-03-21-text-size-markdown.md b/plans/2026-03-21-text-size-markdown.md new file mode 100644 index 0000000000..15389a2c1b --- /dev/null +++ b/plans/2026-03-21-text-size-markdown.md @@ -0,0 +1,66 @@ +# Small Text Markdown + +Add `!- text!` syntax for small gray text — legal disclaimers, secondary commentary, LLM reasoning, etc. + +## Syntax + +`!- text!` — renders as small gray text. Uses the `!` style prefix family, `-` for "reduced." + +On old clients: `!- fine print!` shows as-is (old `coloredP` fails on `-`, falls to `wordP`). Readable. + +## Changes + +### Haskell — `src/Simplex/Chat/Markdown.hs` + +1. **`Format`**: add `Small` constructor (no fields). + +2. **`coloredP` parser**: before trying `colorP`, check for `-` followed by space. If matched, produce `Small`. Otherwise fall through to existing color parsing. + +3. **`markdownText`**: add `Small` case, reconstruct as `!- text!`. + +4. **JSON serialization**: TH-derived `ToJSON`/`FromJSON` via existing `sumTypeJSON fstToLower`. Produces `{"small": {}}`. Old Haskell `FromJSON Format` falls to `Unknown` via `<|> pure (Unknown v)`. + +### Haskell — `src/Simplex/Chat/Styled.hs` + +5. **`sgr`**: add `Small` case — map to `FaintIntensity` for terminal rendering. + +### Haskell — `tests/MarkdownTests.hs` + +6. Tests for: + - `!- text!` parses as `Small` + - `!- text!` with leading/trailing spaces in content → no format (same rule as other formats) + - Existing color syntax unchanged + - `markdownText` round-trip + +### iOS — `apps/ios/SimpleXChat/ChatTypes.swift` + +7. **`Format`** enum: add `case small`. + +### iOS — `apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift` + +8. **`messageText`**: render `Small` with smaller `UIFont` point size + gray color. + +### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt` + +9. **`Format`**: add `@Serializable @SerialName("small") class Small: Format()`. +10. **`Format.style`**: `SpanStyle` with smaller font size + gray color. + +### Android — `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt` + +11. **`MarkdownText`**: add `is Format.Small` case — same pattern as `Bold`/`Italic` (apply style, append text). + +## Backward Compatibility + +### Local (old app receiving message with new syntax) +- Old app's bundled Haskell parses raw message text. Old `coloredP` doesn't know `-`, fails, falls to `wordP`. Text shows as `!- fine print!` — plain text with delimiters. + +### Remote desktop (old desktop, new mobile) +- New mobile Haskell parses `!- text!` as `Small`, serializes to JSON `{"small": {}}`. +- Old desktop Haskell re-parses JSON via `J.parseJSON` (`Remote/Protocol.hs:184`). Old `FromJSON Format` doesn't know `"small"` → `<|> pure (Unknown v)`. +- `Unknown` re-serializes to `{"type": "unknown", "json": ...}` → Kotlin `Format.Unknown` (`ignoreUnknownKeys` drops extra fields). Text renders without formatting. + +## Order of Implementation + +1. Haskell types + parser + tests +2. iOS types + rendering +3. Android types + rendering diff --git a/plans/2026-03-29-desktop-text-selection.md b/plans/2026-03-29-desktop-text-selection.md new file mode 100644 index 0000000000..888000ab4c --- /dev/null +++ b/plans/2026-03-29-desktop-text-selection.md @@ -0,0 +1,432 @@ +# Desktop Text Selection Plan + +## Goal +Cross-message text selection on desktop (Compose Multiplatform): +1. Click+drag to select message text, with auto-scroll +2. Only message text is selectable (no timestamps, names, quotes, dates — like Telegram web) +3. Ctrl+C and copy button +4. Selection persists across scroll + +## Architecture + +### Selection State + +Selection is two endpoints in the item list: + +```kotlin +data class SelectionRange( + val startIndex: Int, // anchor — where drag began, immutable during drag + val startOffset: Int, // character offset within anchor item + val endIndex: Int, // focus — where pointer is now + val endOffset: Int // character offset within focus item +) +``` + +```kotlin +enum class SelectionState { Idle, Selecting, Selected } +``` + +SelectionManager holds: +```kotlin +var selectionState: SelectionState // mutableStateOf +var range: SelectionRange? // mutableStateOf, null in Idle +var focusWindowY by mutableStateOf(0f) // pointer Y in window coords +var focusWindowX by mutableStateOf(0f) // pointer X in window coords +``` + +No captured map. No eager text extraction. +Indices are stable across scroll. Text extracted at copy time from live data. + +### State Machine + +``` + drag threshold + Idle ─────────────────→ Selecting + ↑ │ + │ click │ pointer up + │ ▼ + ←──────────────────── Selected +``` + +### Pointer Handler (on LazyColumn Modifier) + +`SelectionHandler` composable (BoxScope extension) returns a Modifier for +LazyColumnWithScrollBar. Contains `pointerInput`, `onGloballyPositioned`, +`focusRequester`, `focusable`, `onKeyEvent`. + +On every pointer move during Selecting: +1. Updates `focusWindowY/X` +2. Uses `listState.layoutInfo.visibleItemsInfo` to find item at pointer Y → updates `range.endIndex` + +Index resolution uses LazyListState directly — no map, no registration. + +### Pointer Handler Behavior Per State + +Non-press events (hover, scroll) skipped: `return@awaitEachGesture`. +State captured at gesture start (`wasSelected`). + +**Idle**: Down not consumed. Links/menus work. Drag threshold → Selecting. +**Selecting**: Pointer move → update focusWindowY/X, resolve endIndex via listState. + Pointer up → Selected. +**Selected**: Down consumed (prevents link activation). Click → Idle. Drag → new Selecting. + +### Anchor Char Offset Resolution + +The anchor item knows it's the anchor: `range.startIndex == myIndex`. +Resolves char offset ONCE at selection start via LaunchedEffect: + +```kotlin +val isAnchor = remember(myIndex) { + derivedStateOf { manager.range?.startIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +LaunchedEffect(isAnchor.value) { + if (!isAnchor.value) return@LaunchedEffect + val bounds = boundsState.value ?: return@LaunchedEffect + val layout = layoutResultState.value ?: return@LaunchedEffect + val offset = layout.getOffsetForPosition( + Offset(manager.focusWindowX - bounds.left, manager.focusWindowY - bounds.top) + ) + manager.setAnchorOffset(offset) +} +``` + +Fires once. No ongoing effect. + +### Focus Char Offset Resolution + +The focus item knows it's the focus: `range.endIndex == myIndex`. +Resolves char offset on every pointer move via snapshotFlow: + +```kotlin +val isFocus = remember(myIndex) { + derivedStateOf { manager.range?.endIndex == myIndex && manager.selectionState == SelectionState.Selecting } +} +if (isFocus.value) { + LaunchedEffect(Unit) { + snapshotFlow { manager.focusWindowY to manager.focusWindowX } + .collect { (py, px) -> + val bounds = boundsState.value ?: return@collect + val layout = layoutResultState.value ?: return@collect + val offset = layout.getOffsetForPosition(Offset(px - bounds.left, py - bounds.top)) + manager.updateFocusOffset(offset) + } + } +} +``` + +- Starts when item becomes focus, cancels when focus moves to different item +- snapshotFlow fires on pointer move, but only in ONE item +- Uses item's own local TextLayoutResult — no shared map + +### Highlight Rendering (Per Item) + +Each item computes highlight via derivedStateOf: + +```kotlin +val highlightRange = remember(myIndex) { + derivedStateOf { highlightedRange(manager.range, myIndex) } +} +``` + +`highlightedRange` is a standalone function: +```kotlin +fun highlightedRange(range: SelectionRange?, index: Int): IntRange? { + val r = range ?: return null + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + if (index < lo || index > hi) return null + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return when { + index == lo && index == hi -> minOf(startOff, endOff) until maxOf(startOff, endOff) + index == lo -> startOff until Int.MAX_VALUE // clamped by MarkdownText + index == hi -> 0 until endOff + else -> 0 until Int.MAX_VALUE // clamped by MarkdownText + } +} +``` + +derivedStateOf only triggers recomposition when the RESULT changes for this item. +Middle items don't recompose as range extends. Only boundary items recompose. + +### Highlight Drawing + +`getPathForRange(range.first, range.last + 1)` in `drawBehind` on BasicText. +`range.last + 1` because IntRange.last is inclusive, getPathForRange end is exclusive. + +Gated on `selectionRange != null`: +- When null (Android, or desktop without selection): original `Text()` used, no drawBehind. +- When non-null: `SelectableText` (BasicText + drawBehind + onTextLayout) or + `ClickableText` with added drawBehind. + +### Reserve Space Exclusion + +MarkdownText's `buildAnnotatedString` appends invisible reserve text after message +content. A local `var selectableEnd` is set to `this.length` inside `buildAnnotatedString` +right before reserve is appended. Used to clamp `selectionRange` before passing +downstream to rendering: + +```kotlin +var selectableEnd = 0 +val annotatedText = buildAnnotatedString { + // ... content ... + selectableEnd = this.length + // ... typing indicator, reserve ... +} +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +// pass clampedRange to ClickableText/SelectableText +``` + +`selectableEnd` is local to MarkdownText. Not passed upstream. +`highlightedRange` uses `Int.MAX_VALUE` for open-ended ranges; +MarkdownText resolves them to the actual content boundary. + +### Copy + +#### `displayText` function + +Non-composable function placed right next to MarkdownText in TextItemView.kt. +Computes the displayed text from `formattedText`, handling only the few Format +types that change the displayed string. All other formats use `ft.text` unchanged. +Used only at copy time. + +```kotlin +// Must be coordinated with MarkdownText — same text transformations for: +// Mention, HyperLink, SimplexLink, Command +fun displayText( + ci: ChatItem, + linkMode: SimplexLinkMode, + sendCommandMsg: Boolean +): String { + val formattedText = ci.formattedText + if (formattedText == null) return ci.text + return formattedText.joinToString("") { ft -> + when (ft.format) { + is Format.Mention -> { /* resolve display name from ci.mentions */ } + is Format.HyperLink -> ft.format.showText ?: ft.text + is Format.SimplexLink -> { /* showText or description + viaHosts */ } + is Format.Command -> if (sendCommandMsg) "/${ft.format.commandStr}" else ft.text + else -> ft.text + } + } +} +``` + +MarkdownText gets a corresponding comment noting these transformations must match. + +#### Copy text extraction + +On SelectionManager: +```kotlin +fun getSelectedText(items: List, linkMode: SimplexLinkMode): String { + val r = range ?: return "" + val lo = minOf(r.startIndex, r.endIndex) + val hi = maxOf(r.startIndex, r.endIndex) + val forward = r.startIndex <= r.endIndex + val startOff = if (forward) r.startOffset else r.endOffset + val endOff = if (forward) r.endOffset else r.startOffset + return (lo..hi).mapNotNull { idx -> + val ci = items.getOrNull(idx)?.newest()?.item ?: return@mapNotNull null + val text = displayText(ci, linkMode, sendCommandMsg = false) + when { + idx == lo && idx == hi -> text.substring( + startOff.coerceAtMost(text.length), + endOff.coerceAtMost(text.length) + ) + idx == lo -> text.substring(startOff.coerceAtMost(text.length)) + idx == hi -> text.substring(0, endOff.coerceAtMost(text.length)) + else -> text + } + }.joinToString("\n") +} +``` + +### Auto-Scroll + +Direction-aware: only the edge you're dragging toward. +After `scrollBy()`, re-resolve index from `listState.layoutInfo.visibleItemsInfo` +with same pointer Y. Different item may be under pointer → endIndex updates. +Indices don't shift on scroll. Focus item's snapshotFlow handles new charOffset. + +### Mouse Wheel During Drag + +Scroll event passes through to LazyColumn (not consumed by handler). +`snapshotFlow` on scroll offset fires → re-resolve index from listState → update endIndex. + +### Ctrl+C / Cmd+C + +`onKeyEvent` on LazyColumn modifier (inside SelectionHandler's returned Modifier). +Focus requested on selection start. When user taps compose box, focus moves there — +Ctrl+C goes to compose box handler. Copy button works regardless of focus. +Checks `isCtrlPressed || isMetaPressed`. + +### Copy Button + +Emitted by SelectionHandler in BoxScope. Visible in Selected state. +Copies without clearing. Click in chat clears selection. + +### Eviction Prevention + +`ChatItemsLoader.kt`: `allowedTrimming = !selectionActive` during selection. + +### Platform Gate + +All selection code gated on `appPlatform.isDesktop`. + +### Swipe-to-Reply + +Disabled on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier`. + +### RTL Text + +`getOffsetForPosition` and `getPathForRange` are bidi-aware. No direction assumptions. + +--- + +## Effects Summary + +### Idle State +Zero effects. Items don't check anything. `range` is null. + +### Selecting State + +| What | Scope | Fires when | +|------|-------|-----------| +| Pointer event handling | LazyColumn pointerInput (total: 1) | Every pointer event | +| Index resolution | Pointer handler via listState (total: 1) | Every pointer move + scroll | +| Anchor char offset | Anchor item LaunchedEffect (1 item) | Once at selection start | +| Focus char offset | Focus item snapshotFlow (1 item) | Every pointer move | +| Highlight derivedStateOf | Per item (passive) | Only when result changes (~2 items) | +| Auto-scroll | Coroutine in pointer handler (total: 0 or 1) | Near edge during drag | +| Scroll re-evaluation | snapshotFlow on scroll offset (total: 1) | On scroll during drag | + +### Selected State +Zero effects. Frozen range. Items render highlight from derivedStateOf (no recomposition +unless range changes, which it doesn't in Selected state). + +--- + +## Changes From Master + +### NEW: TextSelection.kt + +New file: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/TextSelection.kt` + +Contains: +- `SelectionRange(startIndex, startOffset, endIndex, endOffset)` data class +- `SelectionState` enum (Idle, Selecting, Selected) +- `SelectionManager` — holds `selectionState`, `range`, `focusWindowY/X` (mutableStateOf), + methods: `startSelection`, `setAnchorOffset`, `updateFocusIndex`, `updateFocusOffset`, + `endSelection`, `clearSelection`, `getSelectedText(items, linkMode)` +- `highlightedRange(range, index)` standalone function +- `LocalSelectionManager` CompositionLocal +- `SelectionHandler` composable (BoxScope extension, returns Modifier for LazyColumn): + pointer input with state machine, auto-scroll, focus management, Ctrl+C/Cmd+C, copy button +- `SelectionCopyButton` composable +- `resolveIndexAtY` helper for pointer → item index via listState + +### TextItemView.kt + +**Add `displayText` function** right next to MarkdownText, with comment that it +must be coordinated with MarkdownText's text transformations. Takes `ChatItem`, +`linkMode`, `sendCommandMsg`. Used only by `getSelectedText` at copy time. + +**Add comment to MarkdownText** noting `displayText` must match its text transformations. + +**Add 2 parameters to MarkdownText**: +- `selectionRange: IntRange? = null` +- `onTextLayoutResult: ((TextLayoutResult) -> Unit)? = null` + +**Inside MarkdownText** — local `var selectableEnd` set in both `buildAnnotatedString` +blocks (1 line each, right before typing indicator / reserve). Clamp selectionRange: +```kotlin +val clampedRange = selectionRange?.let { it.first until minOf(it.last, selectableEnd) } +``` + +**Rendering** — gated on `clampedRange != null`: +- `Text()` call sites (2): `if (clampedRange != null) SelectableText(...) else Text(...)` + Original `Text(...)` call unchanged. +- `ClickableText` call: add `selectionRange = clampedRange`, + add `onTextLayout = { onTextLayoutResult?.invoke(it) }` + +**Add `selectionRange` parameter to `ClickableText`**, add `drawBehind` highlight +with `getPathForRange(range.first, range.last + 1)` before BasicText. + +**Add `SelectableText` private composable** — BasicText + drawBehind highlight + +onTextLayout. Used only when `selectionRange != null`. On Android, never reached. + +**MarkdownText is NOT restructured.** No code moved, no branches regrouped. + +### FramedItemView.kt — CIMarkdownText + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0 && !ci.meta.isLive`): +- `boundsState: MutableState` — from `onGloballyPositioned` on the Box +- `layoutResultState: MutableState` — from `onTextLayoutResult` +- `isAnchor` derivedStateOf + LaunchedEffect (resolves anchor offset once) +- `isFocus` derivedStateOf + LaunchedEffect with snapshotFlow (resolves focus offset) +- `highlightRange` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) }` + +**MarkdownText call**: add `selectionRange = highlightRange`, +`onTextLayoutResult = { layoutResultState.value = it }` + +### EmojiItemView.kt + +**Add `selectionIndex: Int = -1` parameter.** + +**Add** (gated on `selectionManager != null && selectionIndex >= 0`): +- `isAnchor`/`isFocus` LaunchedEffects (full-selection only: offset 0 / emojiText.length) +- `isSelected` via `derivedStateOf { highlightedRange(manager.range, selectionIndex) != null }` +- Highlight via `Modifier.background(SelectionHighlightColor)` when selected + +### ChatView.kt + +- Create `SelectionManager`, provide via `LocalSelectionManager` +- `SelectionHandler` returns Modifier applied to LazyColumnWithScrollBar +- Pass `selectionIndex` from `itemsIndexed` through the call chain: + `ChatViewListItem` → `ChatItemViewShortHand` → `ChatItemView` (item/) → + `FramedItemView` → `CIMarkdownText`. Each gets `selectionIndex: Int = -1` param. +- Same for EmojiItemView path +- Gate SwipeToDismiss on desktop: `if (appPlatform.isDesktop) Modifier else swipeableModifier` +- Sync `selectionState != Idle` to `chatState.selectionActive` via LaunchedEffect + +### ChatItemsLoader.kt + +- `removeDuplicatesAndModifySplitsOnBeforePagination`: add `selectionActive: Boolean = false` param +- `allowedTrimming = !selectionActive` +- Call site passes `chatState.selectionActive` + +### ChatItemsMerger.kt + +- `ActiveChatState`: add `@Volatile var selectionActive: Boolean = false` + +### ChatModel.kt — no change + +### MarkdownHelpView.kt — no change + +--- + +## Testing + +1. Single message partial character selection +2. Multi-message selection with highlights +3. Direction reversal past anchor +4. Selection shrinks on reverse (items unhighlight) +5. Selection persists after drag end and across scroll +6. Auto-scroll extends selection correctly +7. Auto-scroll loads items from DB +8. Mouse wheel during drag extends selection +9. Items scrolling out and back in retain highlight +10. Click on links works (Idle state) +11. Click in chat clears selection (Selected state) +12. Right-click behavior +13. Ctrl+C / Cmd+C copies selected text +14. Copy button works +15. Highlight stops before invisible reserve space +16. Copy produces clean text +17. RTL text +18. Emoji-only messages +19. Live messages excluded +20. Edited messages during selection diff --git a/plans/2026-03-29-initial-open-last-unread-block.md b/plans/2026-03-29-initial-open-last-unread-block.md new file mode 100644 index 0000000000..034b57118b --- /dev/null +++ b/plans/2026-03-29-initial-open-last-unread-block.md @@ -0,0 +1,199 @@ +# Initial chat open: jump to last unread block + +## Problem + +When opening a chat with unread messages, the app always scrolls to the oldest unread message (`minUnreadItemId`). For casual group members with hundreds of unreads, this forces them to scroll through the entire backlog to reach new messages. + +The bottom circle scrolls to the latest messages without marking all as read (by design — moderators use this to reply quickly, then return to the top circle to read sequentially). But the next time the chat opens, it jumps back to the oldest unread. + +Users want the initial open to skip old unreads and land on the "new" ones — messages that arrived after their last interaction. + +## Design + +Change `CPInitial` to use a different pivot for `getDirectChatAround'` / `getGroupChatAround'`. + +Currently the pivot is `minUnreadItemId` (absolute first unread). Instead, try `maxViewedItemId` (last non-unread item in sort order) first. If not found, fall back to `minUnreadItemId`. The `getAround'` function is unchanged — it always loads `CRBefore`/`CRAfter` around the pivot and includes it. + +**maxViewedItemId**: the last item in sort order that is not `CISRcvNew`. +- "Viewed" means received read or sent — any `item_status != CISRcvNew`. + +### Why include works for both pivots + +`getDirectChatAround'` always includes the pivot in the result. When the pivot is maxViewed (a read item), including it adds one extra read item — harmless. The client scrolls to the first unread in the loaded items, which is the first item in `afterCIs` (the new unreads after the gap). The include/exclude distinction is unnecessary. + +### Sort order + +- Groups: `(item_ts, chat_item_id)` +- Direct chats: `(created_at, chat_item_id)` + +### Cases + +Items in display order (left = oldest/top, right = newest/bottom). U = unread (`CISRcvNew`), R = not unread (read, sent, event). + +**Case 1: Unreads contiguous from bottom, no gap** +``` +R...R U...U + ↑ maxViewed (last R) used as pivot +``` +`afterCIs` = the unreads. Same as current behavior. + +**Case 2: Gap, then new unreads at bottom** +``` +R...R U...U R...R U...U + ↑ maxViewed used as pivot +``` +`afterCIs` = new unreads only. Skips old unreads. This is the desired improvement. + +**Case 3: Gap at bottom, no new unreads** +``` +R...R U...U R...R + ↑ maxViewed used as pivot +``` +`afterCIs` = empty. Items loaded are the latest. Old unreads reachable via top circle. + +**Case 4: All unread** +``` +U...U +``` +maxViewed = NULL. Fall back to `minUnreadItemId` as pivot. Current behavior. + +**Case 5: No unreads** + +`maxViewedItemId` returns some item but `minUnreadItemId` returns `Nothing` — no unreads exist. Handled by stats showing zero unreads. `getAround'` loads items around maxViewed, which are the latest items. + +Actually: maxViewed is always found when items exist (every chat has at least sent items or read items unless case 4). So the flow is: maxViewed found → load around it → stats show 0 unreads → client shows latest items. + +### No UI changes needed + +Only `CPInitial` backend logic changes. The top circle, unread counter, unread separator, and all pagination continue to use `minUnreadItemId` as before. + +### Out-of-order delivery + +A late-arriving group message with old `item_ts` but recent `created_at` sorts into the old unread block in display order and gets skipped on initial open. This is acceptable — the top circle still reaches it. + +### Notes (local chat) + +Notes (`getLocalChatInitial_`) have unread handling in code but it's dead — all items are sent, `CISRcvNew` never occurs. No change needed. + +### Open concern + +In case 2, `CRBefore(maxViewed)` loads items before the gap, which may include old unreads. The client's scroll logic finds the first unread in loaded items (`lastIndex(where: hasUnread)` in reversed list), which could be an old unread from `beforeCIs` rather than a new unread from `afterCIs`. To be validated during testing — if problematic, may need client-side adjustment or limiting `beforeCIs` count. + +## Implementation + +### Files to modify + +`src/Simplex/Chat/Store/Messages.hs` — all changes are here. + +### New functions + +#### Direct chats + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getContactMaxViewedItemId_ :: DB.Connection -> User -> Contact -> IO (Maybe ChatItemId) +``` + +Query: +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND contact_id = ? AND item_status != ? +ORDER BY created_at DESC, chat_item_id DESC +LIMIT 1 +``` + +#### Groups + +```haskell +-- max viewed item: received read or sent (any item_status != CISRcvNew) +getGroupMaxViewedItemId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) +``` + +Mirrors `queryUnreadGroupItems` structure but with `item_status != ?` instead of `item_status = ?`. Handles the same 4-case scope/content filter dispatch. New function `queryViewedGroupItems`. + +Query (for the no-scope, no-content-filter case): +```sql +SELECT chat_item_id +FROM chat_items +WHERE user_id = ? AND group_id = ? + AND group_scope_tag IS NULL AND group_scope_group_member_id IS NULL + AND item_status != ? +ORDER BY item_ts DESC, chat_item_id DESC +LIMIT 1 +``` + +### Modified functions + +#### `getDirectChatInitial_` + +Current: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMinUnreadId_ db user ct) >>= \case + Just minUnreadItemId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter minUnreadItemId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +New — only the pivot source changes, rest stays the same: +```haskell +getDirectChatInitial_ db user ct contentFilter count = do + liftIO (getContactMaxViewedItemId_ db user ct >>= maybe (getContactMinUnreadId_ db user ct) (pure . Just)) >>= \case + Just pivotId -> do + unreadCount <- liftIO $ getContactUnreadCount_ db user ct + minUnreadItemId <- fromMaybe 0 <$> liftIO (getContactMinUnreadId_ db user ct) + let stats = emptyChatStats {unreadCount, minUnreadItemId} + getDirectChatAround' db user ct contentFilter pivotId count "" stats + Nothing -> (,Just $ NavigationInfo 0 0) <$> getDirectChatLast_ db user ct contentFilter count "" +``` + +#### `getGroupChatInitial_` + +Same minimal change — only the pivot source: +```haskell +getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do + (getGroupMaxViewedItemId_ db user g scopeInfo_ contentFilter >>= maybe (getGroupMinUnreadId_ db user g scopeInfo_ contentFilter) (pure . Just)) >>= \case + Just pivotId -> do + stats <- getGroupStats_ db user g scopeInfo_ + getGroupChatAround' db user g scopeInfo_ contentFilter pivotId count "" stats + Nothing -> do + stats <- liftIO $ getStats 0 (0, 0) + (,Just $ NavigationInfo 0 0) <$> getGroupChatLast_ db user g scopeInfo_ contentFilter count "" stats + where + getStats minUnreadItemId (unreadCount, unreadMentions) = do + reportsCount <- getGroupReportsCount_ db user g False + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} +``` + +### Summary of all affected functions + +| Function | Change | +|----------|--------| +| `getDirectChatInitial_` | Try maxViewed first, fall back to minUnread, same `getAround'` call | +| `getGroupChatInitial_` | Same | +| **New:** `getContactMaxViewedItemId_` | MAX non-unread by (created_at DESC, id DESC) | +| **New:** `getGroupMaxViewedItemId_` | MAX non-unread with scope/filter dispatch | +| **New:** `queryViewedGroupItems` | Like `queryUnreadGroupItems` but `item_status != ?` | + +Nothing else changes. `getDirectChatAround'`, `getGroupChatAround'`, `getDirectChatAround_`, `getGroupChatAround_`, `getContactMinUnreadId_`, `getGroupMinUnreadId_`, `getContactStats_`, `getGroupStats_`, `getChatItemIDs`, `NavigationInfo` computation, mark-read operations, UI code — all unchanged. + +### Performance + +- `maxViewedItemId` query: scans backward from the largest sort key, skipping unread items. Fast when there are recent read/sent items (the common case). Worst case: all items are unread — returns `Nothing` and falls back to minUnread. + +- All other queries are existing code, same performance. + +- Queries run only during `CPInitial` (chat open). No writes. + +### Testing + +1. Open chat with unreads, no prior interaction → same as current (case 1/4) +2. Open chat, jump to bottom (marks bottom screen read), close, reopen → lands on new unreads after the gap (case 2) +3. Open chat, jump to bottom, reply, close, new messages arrive, reopen → lands on new messages (case 2) +4. Open chat, jump to bottom, close, no new messages, reopen → loads around last viewed item at bottom (case 3) +5. Open chat, read from top (marks first screen read), close, reopen → lands on next unread after first screen, same as current (case 1) +6. Group with scope (member support chat) → same behavior with scope filter applied +7. Group with content filter (reports) → same behavior with content filter applied diff --git a/plans/2026-04-01-agent-sign-for-address.md b/plans/2026-04-01-agent-sign-for-address.md new file mode 100644 index 0000000000..c648574399 --- /dev/null +++ b/plans/2026-04-01-agent-sign-for-address.md @@ -0,0 +1,61 @@ +# Plan: Agent API — getConnLinkPrivKey + +**Date: 2026-04-01** + +## Context + +The chat relay test (`APITestChatRelay`) requires the relay to sign a challenge with its address private key (`ShortLinkCreds.linkPrivSigKey`). This key is stored in the agent's database on `RcvQueue` and is not accessible from the chat layer. A new agent API function is needed to retrieve it. + +The chat layer performs the signing itself with `C.sign'`. + +## API + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +- `ConnId` — the agent connection ID +- Returns — `Just linkPrivSigKey` if the connection has short link credentials, `Nothing` otherwise + +## Implementation + +**File: `simplexmq/src/Simplex/Messaging/Agent.hs`** + +1. Add to module exports: + ```haskell + getConnLinkPrivKey, + ``` + +2. Add public function (near `getConnShortLink`, ~line 427): + ```haskell + getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey c = withAgentEnv c . getConnLinkPrivKey' c + {-# INLINE getConnLinkPrivKey #-} + ``` + +3. Add implementation (near `deleteConnShortLink'`, ~line 1089): + ```haskell + getConnLinkPrivKey' :: AgentClient -> ConnId -> AM (Maybe C.PrivateKeyEd25519) + getConnLinkPrivKey' c connId = do + SomeConn _ conn <- withStore c (`getConn` connId) + pure $ case conn of + ContactConnection _ rq -> linkPrivSigKey <$> shortLink rq + RcvConnection _ rq -> linkPrivSigKey <$> shortLink rq + _ -> Nothing + ``` + +## Design notes + +- Local operation (no network IO) — synchronous, fast +- No `withConnLock` — this is a pure read with no mutations; the lock would add latency for no benefit. Read-only agent operations like `getConn` don't require the conn lock. +- Returns `Maybe` — `Nothing` if connection has no short link credentials or is wrong type +- Handles both `ContactConnection` and `RcvConnection` (both have `RcvQueue` with `shortLink` field, Store.hs:159) +- Chat layer signs: `C.sign' privKey challenge` +- `linkPrivSigKey :: C.PrivateKeyEd25519` on `ShortLinkCreds` (Protocol.hs:1456) +- `shortLink :: Maybe ShortLinkCreds` on `StoredRcvQueue` (Store.hs:159) + +## Verification + +```bash +cd simplexmq && cabal build --ghc-options=-O0 +``` diff --git a/plans/2026-04-01-test-chat-relay-plan.md b/plans/2026-04-01-test-chat-relay-plan.md new file mode 100644 index 0000000000..905f7962c1 --- /dev/null +++ b/plans/2026-04-01-test-chat-relay-plan.md @@ -0,0 +1,813 @@ +# Plan: APITestChatRelay — Relay Liveness + Identity Verification + +**Date: 2026-04-01** + +## Context + +Channel owners configure relays by address but have no way to verify a relay is alive, authentic, or to discover its profile before creating a channel. A broken or impersonated relay means a broken channel. + +`APITestChatRelay` solves this by: +1. Fetching the relay's short link data (validates SMP server reachability + retrieves relay profile) +2. Running a challenge-response handshake (`XGrpRelayTest`) that proves the relay controls its address private key (`linkPrivSigKey`) +3. Returning the relay profile and test result to the UI + +The test can run before any `chat_relays` DB record exists — the UI uses the returned profile to populate the relay name field. + +No DB schema changes are needed — `name` column remains in `chat_relays`. The Haskell type `UserChatRelay` changes from `name :: Text` to `relayProfile :: RelayProfile`, wrapping the same DB column. + +--- + +## Data Flow + +``` +Owner SMP Server Relay + | | | + |--- getShortLinkConnReq ----------->| | + |<-- FixedLinkData{rootKey,cReq} ----| | + | + ConnLinkData{RelayAddressLinkData{relayProfile}} | + | | | + |--- joinConnection(XGrpRelayTest{challenge}) ---------------------->| + | | REQ with challenge | + | | relay signs challenge | + | | with linkPrivSigKey | + |<-- CONF(XGrpRelayTest{signature}) ----------------------------------| + | verify: C.verify' rootKey sig challenge | + | cleanup connections on both sides | +``` + +--- + +## Types + +### RelayProfile (Protocol.hs) + +```haskell +data RelayProfile = RelayProfile {name :: ContactName} + deriving (Eq, Show) + +$(JQ.deriveJSON defaultJSON ''RelayProfile) +``` + +Simpler than `Profile` — relay identity needs only a name. Can be extended later with image, description, etc. + +### RelayAddressLinkData (Protocol.hs) + +```haskell +data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile} + deriving (Show) + +$(JQ.deriveJSON defaultJSON ''RelayAddressLinkData) +``` + +Stored as `userData` in the relay's contact address short link data. Separate from `ContactShortLinkData` (which has irrelevant `message`/`business` fields) and `RelayShortLinkData` (per-group relay links). + +### XGrpRelayTest (Protocol.hs) + +```haskell +XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json +``` + +Single constructor used in both directions: +- **Owner → Relay** (in joinConnection connInfo): `XGrpRelayTest challenge Nothing` +- **Relay → Owner** (in acceptContact connInfo): `XGrpRelayTest challenge (Just signature)` + +The relay profile is NOT included — the owner already has it from `RelayAddressLinkData` in the short link's `userData` (retrieved in step 1 via `decodeLinkUserData`). + +JSON encoding (follows `(.=?)` chain pattern, e.g. `XGrpMemDel`): +```haskell +XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] +``` + +JSON parsing: +```haskell +XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ +``` + +Where `decodeSig` converts `B64UrlByteString` to `Parser (C.Signature 'C.Ed25519)` using `<$?>` (from `Simplex.Messaging.Util`, already imported in Protocol.hs): +```haskell +decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) +decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s +``` + +`(<$?>) :: MonadFail m => (a -> Either String b) -> m a -> m b` — converts `Either` errors into `MonadFail` failures. `JQ.Parser` has `MonadFail`. + +Note: `B64UrlByteString` is defined in `Types.hs:151` — add import to Protocol.hs if not already imported. + +### RelayTestError (Controller.hs) + +```haskell +data RelayTestStep + = RTSGetLink -- fetching short link data from SMP server + | RTSDecodeLink -- decoding RelayAddressLinkData from link userData + | RTSConnect -- preparing and joining connection + | RTSWaitResponse -- waiting for relay's signed response + | RTSVerify -- verifying relay's signature + deriving (Show) + +data RelayTestFailure = RelayTestFailure + { rtfStep :: RelayTestStep, + rtfDescription :: String + } + deriving (Show) +``` + +Pattern follows `ProtocolTestFailure {testStep, testError}` from simplexmq. + +### RelayTest (Controller.hs) + +```haskell +data RelayTest = RelayTest + { challenge :: ByteString, + rootKey :: C.PublicKeyEd25519, + result :: TMVar (Maybe RelayTestFailure) + } +``` + +- `challenge` — random bytes sent to relay +- `rootKey` — from `FixedLinkData`, used to verify relay's signature +- `result` — `Nothing` = success, `Just failure` = error + +### UserChatRelay type change (Operators.hs) + +`UserChatRelay'` changes `name :: Text` to `relayProfile :: RelayProfile`: + +```haskell +data UserChatRelay' s = UserChatRelay + { chatRelayId :: DBEntityId' s, + address :: ShortLinkContact, + relayProfile :: RelayProfile, -- was: name :: Text + domains :: [Text], + preset :: Bool, + tested :: Maybe Bool, + enabled :: Bool, + deleted :: Bool + } +``` + +`relayProfile` is non-optional — always present: +- Before testing: user provides name → `RelayProfile {name = userProvidedName}` +- After testing: relay's actual profile replaces the user-provided one + +No DB migration needed — `name TEXT` column stays in `chat_relays`. The `RelayProfile` wrapper is applied at the Haskell read/write boundary: + +**Constructors:** +```haskell +-- newChatRelay_ (Operators.hs:341): name parameter wraps into RelayProfile +newChatRelay_ preset enabled name domains !address = + UserChatRelay {chatRelayId = DBNewEntity, address, relayProfile = RelayProfile {name}, domains, ...} +``` + +**DB reads** — `toChatRelay` (Profiles.hs:636) and `toGroupRelay` (Groups.hs:1337): wrap `name` column value: +```haskell +-- toChatRelay: name from DB → RelayProfile + UserChatRelay {chatRelayId, address, relayProfile = RelayProfile {name}, domains = ..., ...} +``` + +**DB writes** — `insertChatRelay`, `updateChatRelay`, `undeleteRelay` (Profiles.hs): unwrap `RelayProfile` to get `name` for column: +```haskell +-- insertChatRelay: destructure relayProfile +insertChatRelay db User {userId} ts relay@UserChatRelay {address, relayProfile = RelayProfile {name}, ...} = do +``` + +**Validation** — `chatRelayErrs` (Operators.hs:546): uses `name` from `relayProfile` for duplicate checking: +```haskell +duplicateErrs_ (AUCR _ UserChatRelay {relayProfile = RelayProfile {name}, address}) = ... +allNames = map (\(AUCR _ UserChatRelay {relayProfile = RelayProfile {name}}) -> name) cRelays +``` + +**View** — `viewChatRelay` (View.hs:1581): uses `name` from `relayProfile`: +```haskell +viewChatRelay UserChatRelay {relayProfile = RelayProfile {name}, address, ...} = name <> ... +``` + +**`createRelayForOwner`** (Groups.hs:1342): uses `relayProfile` directly instead of `profileFromName name`: +```haskell +createRelayForOwner db vr gVar user gInfo UserChatRelay {relayProfile = RelayProfile {name}} = do + let memberProfile = profileFromName name + ... +``` + +**JSON** — `deriveJSON` on `UserChatRelay'` picks up the field rename automatically. The JSON changes from `"name": "bob"` to `"relayProfile": {"name": "bob"}`. Mobile apps need to update their model types accordingly. + +### ChatController field + +```haskell +chatRelayTests :: TMap ConnId RelayTest, +``` + +### ChatCommand + +```haskell +| APITestChatRelay UserId ShortLinkContact +| TestChatRelay ShortLinkContact +``` + +Takes a `ShortLinkContact` (`ConnShortLink 'CMContact`) — relay addresses are always short links. This matches `UserChatRelay.address :: ShortLinkContact` and is directly accepted by `getShortLinkConnReq :: ... -> ConnShortLink m -> ...`. + +### ChatResponse + +```haskell +| CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} +``` + +- On success: `relayProfile = Just p, testFailure = Nothing` +- On failure at link fetch/decode: `relayProfile = Nothing, testFailure = Just err` (profile not yet available) +- On failure at connect/verify: `relayProfile = Just p, testFailure = Just err` (profile from link data) + +--- + +## Implementation + +### Phase 1: Protocol — XGrpRelayTest + RelayAddressLinkData + RelayProfile + +**File: `src/Simplex/Chat/Protocol.hs`** + +1. Add `RelayProfile` type (near `RelayShortLinkData`, ~line 1444): + - `data RelayProfile = RelayProfile {name :: ContactName}` + - `deriveJSON` + +2. Add `RelayAddressLinkData` type (after `RelayShortLinkData`): + - `data RelayAddressLinkData = RelayAddressLinkData {relayProfile :: RelayProfile}` + - `deriveJSON` + +3. Add `XGrpRelayTest` constructor (after `XGrpRelayAcpt`, ~line 438): + - `XGrpRelayTest :: ByteString -> Maybe (C.Signature 'C.Ed25519) -> ChatMsgEvent 'Json` + +4. Add event tag `XGrpRelayTest_` (after `XGrpRelayAcpt_`, ~line 966) + +5. Add tag string `"x.grp.relay.test"` (after `"x.grp.relay.acpt"`, ~line 1022) + +6. Add tag parsing (after `XGrpRelayAcpt_` parse, ~line 1079) + +7. Add event-to-tag mapping (after `XGrpRelayAcpt` mapping, ~line 1132): + - `XGrpRelayTest {} -> XGrpRelayTest_` + +8. Add JSON parsing (~line 1284): + ```haskell + XGrpRelayTest_ -> do + B64UrlByteString challenge <- v .: "challenge" + sig_ <- traverse decodeSig =<< opt "signature" + pure $ XGrpRelayTest challenge sig_ + ``` + Where: + ```haskell + decodeSig :: B64UrlByteString -> JQ.Parser (C.Signature 'C.Ed25519) + decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s + ``` + +9. Add JSON encoding (~line 1351): + ```haskell + XGrpRelayTest challenge sig_ -> o $ + ("signature" .=? (B64UrlByteString . C.signatureBytes <$> sig_)) + ["challenge" .= B64UrlByteString challenge] + ``` + +### Phase 2: UserChatRelay type change + +**Files: `src/Simplex/Chat/Operators.hs`, `src/Simplex/Chat/Store/Profiles.hs`, `src/Simplex/Chat/Store/Groups.hs`, `src/Simplex/Chat/View.hs`** + +Change `UserChatRelay'` field `name :: Text` → `relayProfile :: RelayProfile` and update all 10 use sites as described in the Types section above. No DB migration — `name` column stays, `RelayProfile` wraps/unwraps at read/write boundary. + +### Phase 3: Controller types — RelayTest, RelayTestFailure, commands, response + +**File: `src/Simplex/Chat/Controller.hs`** + +1. Add `RelayTestStep` and `RelayTestFailure` types (near `ProtocolTestFailure` usage) + +2. Add `RelayTest` type + +3. Add `chatRelayTests :: TMap ConnId RelayTest` field to `ChatController` (after `relayRequestWorkers`, ~line 252) + +4. Uncomment and update `APITestChatRelay` (lines 401-403): + ```haskell + | APITestChatRelay UserId ShortLinkContact + | TestChatRelay ShortLinkContact + ``` + +5. Add `CRChatRelayTestResult` to `ChatResponse` (after `CRServerTestResult`, ~line 667): + ```haskell + | CRChatRelayTestResult {user :: User, relayProfile :: Maybe RelayProfile, testFailure :: Maybe RelayTestFailure} + ``` + +**File: `src/Simplex/Chat.hs`** + +6. Initialize `chatRelayTests` in `newChatController` (after `relayRequestWorkers`, ~line 175): + ```haskell + chatRelayTests <- TM.emptyIO + ``` + Add `chatRelayTests` to the record construction (~line 218). + +### Phase 4: Agent API — getConnLinkPrivKey (simplexmq change) + +The relay needs to sign the challenge with `ShortLinkCreds.linkPrivSigKey`, which is stored in the agent's DB on `RcvQueue`. The chat layer has no direct access to the key. + +**New agent API function in `simplexmq/src/Simplex/Messaging/Agent.hs`:** + +```haskell +getConnLinkPrivKey :: AgentClient -> ConnId -> AE (Maybe C.PrivateKeyEd25519) +``` + +Implementation: +1. Look up `SomeConn` by `ConnId` via `withStore c getConn` +2. Pattern match on `ContactConnection _ rq` or `RcvConnection _ rq` +3. Return `linkPrivSigKey <$> shortLink rq` (returns `Nothing` if no short link creds) + +The chat layer then signs: `C.sign' privKey challenge`. + +This is a local operation (no network IO), so it's synchronous. + +**Separate plan file:** `plans/agent-sign-for-address.md` + +### Phase 5: Commands.hs — APITestChatRelay handler + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add `import System.Timeout (timeout)`. + +Add handler after `APITestProtoServer` (~line 1491): + +```haskell +APITestChatRelay userId address -> withUserId userId $ \user -> do + -- Step 1: Fetch link data (validates SMP server + gets profile) + let failAt step desc = pure $ CRChatRelayTestResult user Nothing (Just $ RelayTestFailure step desc) + r <- tryAllErrors $ getShortLinkConnReq nm user address + case r of + Left e -> failAt RTSGetLink (show e) + Right (FixedLinkData {rootKey, linkConnReq = cReq}, cData) -> do + -- Step 2: Decode relay profile from link data + relayProfile_ <- liftIO $ decodeLinkUserData cData + case relayProfile_ of + Nothing -> failAt RTSDecodeLink "no relay address link data" + Just RelayAddressLinkData {relayProfile} -> do + let failWithProfile step desc = + pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step desc) + -- Step 3: Generate challenge + prepare connection + gVar <- asks random + challenge <- liftIO $ atomically $ C.randomBytes 32 gVar + lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + Nothing -> failWithProfile RTSConnect "invalid connection request" + Just (agentV, _) -> do + let chatV = agentToChatVersion agentV + subMode <- chatReadVar subscriptionMode + connId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff + conn@Connection {connId = dbConnId} <- withFastStore $ \db -> + createRelayTestConnection db vr user connId ConnPrepared chatV subMode + -- Register test in TMap + testVar <- newEmptyTMVarIO + let acId = aConnId conn + relayTest = RelayTest {challenge, rootKey, result = testVar} + chatRelayTests_ <- asks chatRelayTests + atomically $ TM.insert acId relayTest chatRelayTests_ + -- Join with challenge, wrapped in tryAllErrors for cleanup safety + testResult <- tryAllErrors $ do + dm <- encodeConnInfo $ XGrpRelayTest challenge Nothing + void $ withAgent $ \a -> joinConnection a nm (aUserId user) acId True cReq dm PQSupportOff subMode + liftIO $ timeout 40_000_000 $ atomically $ takeTMVar testVar + -- Cleanup always (even on error) + atomically $ TM.delete acId chatRelayTests_ + withFastStore' $ \db -> deleteConnectionRecord db user dbConnId + deleteAgentConnectionAsync acId + case testResult of + Left e -> failWithProfile RTSConnect (show e) + Right Nothing -> failWithProfile RTSWaitResponse "timeout" + Right (Just Nothing) -> pure $ CRChatRelayTestResult user (Just relayProfile) Nothing + Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) +TestChatRelay address -> withUser $ \User {userId} -> + processChatCommand vr nm $ APITestChatRelay userId address +``` + +Also add CLI parsing for `TestChatRelay` in the command parser. + +Key points: +- `address :: ShortLinkContact` — passes directly to `getShortLinkConnReq` (no type mismatch) +- `conn@Connection {connId = dbConnId}` — explicit pattern match avoids `DuplicateRecordFields` ambiguity +- `tryAllErrors` wraps only the join+wait block; cleanup runs unconditionally after it +- `tryAllErrors` (from `Simplex.Messaging.Util`) catches ALL exceptions via `UE.catch`, not just `ChatError` +- `void $ withAgent $ \a -> joinConnection ...` — discards `(SndQueueSecured, Maybe ClientServiceId)` return + +### Phase 6: Subscriber.hs — Event handlers + +**File: `src/Simplex/Chat/Library/Subscriber.hs`** + +#### Owner side: processDirectMessage CONF handler (contact_ = Nothing) + +Modify the CONF handler at lines 407-417. Before the existing flow, check if this connection is a relay test: + +```haskell +Nothing -> case agentMsg of + CONF confId pqSupport _ connInfo -> do + -- Check if this is a relay test connection + chatRelayTests_ <- asks chatRelayTests + relayTest_ <- atomically $ TM.lookup agentConnId chatRelayTests_ + case relayTest_ of + Just RelayTest {challenge, rootKey, result = testVar} -> do + -- Parse response + r <- tryAllErrors $ do + ChatMessage {chatMsgEvent} <- parseChatMessage conn connInfo + case chatMsgEvent of + XGrpRelayTest _challenge sig_ -> + case sig_ of + Just sig + | C.verify' rootKey sig challenge -> + atomically $ putTMVar testVar Nothing -- success + | otherwise -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "invalid signature") + Nothing -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSVerify "no signature in response") + _ -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse "unexpected message type") + case r of + Left e -> + atomically $ putTMVar testVar (Just $ RelayTestFailure RTSWaitResponse (show e)) + Right () -> pure () + Nothing -> do + -- Existing flow (unchanged) + conn' <- processCONFpqSupport conn pqSupport + (conn'', gInfo_) <- saveConnInfo conn' connInfo + ... +``` + +Note: `agentConnId` is in scope from the `processAgentMessageConn` closure (Subscriber.hs:354). + +#### Relay side: processContactConnMessage REQ handler + +Add `XGrpRelayTest` case after `XGrpRelayInv` at line 1247: + +```haskell +XGrpRelayTest challenge _ -> xGrpRelayTest invId chatVRange challenge +``` + +Add `xGrpRelayTest` function near `xGrpRelayInv` (~line 1450): + +```haskell +xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () +xGrpRelayTest invId chatVRange challenge = do + -- Retrieve private key from address connection's short link creds, sign in chat layer + privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) + case privKey_ of + Nothing -> eToView $ ChatError (CEInternalError "no short link key for relay address") + Just privKey -> do + let sig = C.sign' privKey challenge + msg = XGrpRelayTest challenge (Just sig) + subMode <- chatReadVar subscriptionMode + vr <- chatVersionRange + let chatV = vr `peerConnChatVersion` chatVRange + void $ agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV +``` + +Note: `conn` is the user contact address connection (from `processContactConnMessage` closure). Its `aConnId` is the agent `ConnId` that holds `ShortLinkCreds` with `linkPrivSigKey`. The agent returns `Maybe` — `Nothing` if the connection has no short link credentials (shouldn't happen for a properly configured relay, but handled gracefully — owner will timeout with `RTSWaitResponse`). + +### Phase 7: Store — createRelayTestConnection + +**File: `src/Simplex/Chat/Store/Direct.hs`** + +Add function to create a ConnContact connection without entity: + +```haskell +createRelayTestConnection :: DB.Connection -> VersionRangeChat -> User -> ConnId -> ConnStatus -> VersionChat -> SubscriptionMode -> ExceptT StoreError IO Connection +createRelayTestConnection db vr user@User {userId} agentConnId connStatus chatV subMode = do + currentTs <- liftIO getCurrentTime + liftIO $ DB.execute db + [sql| + INSERT INTO connections ( + user_id, agent_conn_id, conn_level, conn_status, conn_type, + conn_chat_version, to_subscribe, pq_support, pq_encryption, + created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) + |] + ( (userId, agentConnId, 0 :: Int, connStatus, ConnContact) + :. (chatV, BI (subMode == SMOnlyCreate), PQSupportOff, PQSupportOff) + :. (currentTs, currentTs) + ) + connId <- liftIO $ insertedRowId db + getConnectionById db vr user connId +``` + +Pattern: same as `createRelayConnection` (Store/Groups.hs:1388) but `ConnContact` type with no `group_member_id`. + +The resulting row has `contact_id = NULL`, `contact_conn_initiated = 0` (column default), `xcontact_id = NULL`, `via_contact_uri = NULL`. This distinguishes it from `createConnReqConnection` rows which always set `contact_conn_initiated = 1`, `xcontact_id`, and `via_contact_uri`. + +### Phase 8: APICreateMyAddress — Use RelayAddressLinkData + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Update `APICreateMyAddress` (~line 2162-2176) for relay users: + +```haskell +-- Current code (line 2168-2169): +-- TODO [relays] relay: add relay profile, identity, key to link data? +let userData = contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing + +-- New code for relay users: +let userData = if isTrue userChatRelay + then encodeShortLinkData $ RelayAddressLinkData + { relayProfile = RelayProfile {name = displayName (fromLocalProfile $ profile' user)} + } + else contactShortLinkData (userProfileDirect user Nothing Nothing True) Nothing +``` + +### Phase 9: Test connection cleanup + +Test connections are `ConnContact` with no entity (`contact_id = NULL`). They should be cleaned up if the test API handler crashes or times out without cleanup. + +Add `cleanupStaleRelayTestConns` step to `cleanupUser` in `cleanupManager` (after `cleanupInProgressGroups`, ~line 4500): + +```haskell +cleanupStaleRelayTestConns user `catchAllErrors` eToView +liftIO $ threadDelay' stepDelay +``` + +Implementation: +```haskell +cleanupStaleRelayTestConns user = do + ts <- liftIO getCurrentTime + let cutoffTs = addUTCTime (-300) ts -- 5 minutes + staleConns <- withStore' $ \db -> getStaleRelayTestConns db user cutoffTs + forM_ staleConns $ \acId -> do + deleteAgentConnectionAsync acId + withStore' $ \db -> deleteConnectionByAgentConnId db user acId +``` + +Where `getStaleRelayTestConns` queries: +```sql +SELECT agent_conn_id FROM connections +WHERE user_id = ? AND conn_type = 'contact' AND contact_id IS NULL + AND conn_status = 'prepared' AND contact_conn_initiated = 0 + AND created_at < ? +``` + +This uniquely identifies stale test connections. The `contact_conn_initiated = 0` discriminator is critical because `createConnReqConnection` (Store/Direct.hs:164) also creates `ConnContact` rows with `contact_id = NULL` and `conn_status = ConnPrepared`, but it always sets `contact_conn_initiated = True` (line 175). Test connections from `createRelayTestConnection` inherit the column default of 0. + +**No new DB column needed.** + +### Phase 10: Views (iOS + Android/Desktop) + +**iOS:** +- `apps/ios/Shared/Views/UserSettings/NetworkAndServers/ChatRelayView.swift` +- `apps/ios/Shared/Views/NewChat/AddChannelView.swift` + +**Android/Desktop:** +- `apps/multiplatform/.../ChatRelayView.kt` +- `apps/multiplatform/.../AddChannelView.kt` + +Changes: +1. Add "Test" button next to relay address that calls `APITestChatRelay address` +2. On success: show relay profile name, optionally auto-fill name field +3. On failure: show error description from `RelayTestFailure` +4. Show relay status indicator: untested / tested-ok / tested-failed + +### Phase 11: View — CRChatRelayTestResult + +**File: `src/Simplex/Chat/View.hs`** + +Add `CRChatRelayTestResult` case after `CRServerTestResult` (~line 127): + +```haskell +CRChatRelayTestResult u relayProfile_ testFailure_ -> ttyUser u $ viewRelayTestResult relayProfile_ testFailure_ +``` + +Add `viewRelayTestResult` function near `viewServerTestResult` (~line 1600): + +```haskell +viewRelayTestResult :: Maybe RelayProfile -> Maybe RelayTestFailure -> [StyledString] +viewRelayTestResult relayProfile_ = \case + Just RelayTestFailure {rtfStep, rtfDescription} -> + ["relay test failed at " <> plain (show rtfStep) <> ", error: " <> plain rtfDescription] + Nothing -> case relayProfile_ of + Just RelayProfile {name} -> ["relay test passed, profile: " <> plain (T.unpack name)] + Nothing -> ["relay test passed"] +``` + +Output examples: +- Success: `relay test passed, profile: bob` +- Decode failure: `relay test failed at RTSDecodeLink, error: no relay address link data` +- Link failure: `relay test failed at RTSGetLink, error: ...` + +### Phase 12: CLI parsing — TestChatRelay + +**File: `src/Simplex/Chat/Library/Commands.hs`** + +Add CLI parser after `/relays` (~line 4771): + +```haskell +"/relay test " *> (TestChatRelay <$> strP), +``` + +### Phase 13: Tests + +**File: `tests/ChatTests/ChatRelays.hs`** + +Add to `chatRelayTests`: +```haskell +describe "configure chat relays" $ do + ... + it "test chat relay" testChatRelayTest +``` + +#### Test: `testChatRelayTest` + +Single test function covering three scenarios sequentially. Uses alice (owner), bob (relay), and cath (normal user). + +```haskell +testChatRelayTest :: HasCallStack => TestParams -> IO () +testChatRelayTest ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + -- Setup: bob (relay) creates address + bob ##> "/ad" + (bobSLink, _cLink) <- getContactLinks bob True + + -- Setup: cath (normal user) creates address + cath ##> "/ad" + (cathSLink, _cLink) <- getContactLinks cath True + + -- Scenario 1: Happy path — test relay address succeeds + -- Concurrent because alice's test command blocks while bob processes REQ + concurrentlyN_ + [ do + alice ##> ("/relay test " <> bobSLink) + alice <## "relay test passed, profile: bob", + -- Bob's side is automatic (subscriber handles XGrpRelayTest) + -- but we need to consume any potential output on bob's side + pure () + ] + + -- Scenario 2: Non-relay address — cath is not a relay user, + -- her address has ContactShortLinkData, not RelayAddressLinkData + alice ##> ("/relay test " <> cathSLink) + alice <## "relay test failed at RTSDecodeLink, error: no relay address link data" + + -- Scenario 3: Deleted address — bob deletes his address + bob ##> "/da" + bob <## "Your chat address is deleted - accepted contacts will remain connected." + alice ##> ("/relay test " <> bobSLink) + -- Exact error message depends on SMP server response, match prefix + alice <## startsWith "relay test failed at RTSGetLink, error: " +``` + +**Key design decisions:** + +1. **One test, three scenarios** — avoids repeating setup (creating users, addresses) across three separate tests while covering happy path + two failure modes. + +2. **`concurrentlyN_` for happy path** — alice's `TestChatRelay` command blocks on a TMVar waiting for the relay's response. Bob's subscriber processes the REQ automatically via `xGrpRelayTest`, but the test framework needs both sides to run concurrently. The relay side may produce no visible CLI output (the `xGrpRelayTest` handler doesn't emit events to the view), so the relay branch is `pure ()`. + +3. **No concurrency for failure scenarios** — both fail before establishing a connection (at link fetch or decode step), so alice returns immediately with an error. + +4. **`startsWith` for SMP error** — the exact SMP error message may vary (network error, connection refused, etc.), so we match only the prefix `"relay test failed at RTSGetLink, error: "`. + +5. **Bob's output during happy path** — the relay's subscriber handles `XGrpRelayTest` silently (no `toView` call on success). After accepting, the agent creates a new connection whose subsequent events (JOINED, etc.) hit `getConnectionEntity` → `SEConnectionNotFound` → logged via `eToView`. This log noise may or may not appear as a test output line. If it does, we'd need to consume it in the `concurrentlyN_` bob branch. This needs to be verified during implementation — if bob produces output, add `bob <## ...` to consume it. + +**Helper needed:** `startsWith` — matches output lines by prefix. Check if this already exists in test utils: + +```haskell +startsWith :: String -> String -> Bool +startsWith = isPrefixOf +``` + +Or use an existing pattern like `<##.` if available. + +#### Scenarios NOT tested (and why): + +- **Signature verification failure (`RTSVerify`)** — would require the relay to sign with a wrong key. No mechanism to inject that without modifying the relay's behavior (e.g., a test-only flag). Not worth the complexity. +- **Timeout (`RTSWaitResponse`)** — would require the relay to not respond (e.g., by stopping the relay process). The test would take 40 seconds and be fragile. Not practical for a unit test. +- **Connection error (`RTSConnect`)** — would require the SMP server to be reachable (link data returned) but the connection request to fail. Hard to construct reliably. + +Existing relay config tests (`testGetSetChatRelays`, etc.) need updating for the `relayProfile` type change — CLI output changes from `bob_relay: ` to the same (the `name` field is now accessed via `relayProfile`), but the CLI command syntax stays the same (`/relays name=bob_relay `). + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Protocol.hs` | `RelayProfile`, `RelayAddressLinkData`, `XGrpRelayTest` + tags + parsing + encoding | +| `src/Simplex/Chat/Operators.hs` | `UserChatRelay'`: `name` → `relayProfile :: RelayProfile`; update `newChatRelay_`, validation | +| `src/Simplex/Chat/Controller.hs` | `RelayTestStep`, `RelayTestFailure`, `RelayTest`, `chatRelayTests`, `APITestChatRelay`, `CRChatRelayTestResult` | +| `src/Simplex/Chat.hs` | Initialize `chatRelayTests` in `newChatController` | +| `src/Simplex/Chat/Library/Commands.hs` | `APITestChatRelay` handler, `APICreateMyAddress` relay link data, CLI parsing, `cleanupManager` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Owner CONF handler pre-check, relay REQ handler `XGrpRelayTest` | +| `src/Simplex/Chat/Store/Direct.hs` | `createRelayTestConnection` | +| `src/Simplex/Chat/Store/Groups.hs` | `toGroupRelay`, `createRelayForOwner`: wrap/unwrap `RelayProfile` | +| `src/Simplex/Chat/Store/Profiles.hs` | `toChatRelay`, `insertChatRelay`, `updateChatRelay`, `undeleteRelay`: wrap/unwrap `RelayProfile`; `getStaleRelayTestConns` | +| `src/Simplex/Chat/View.hs` | `viewChatRelay`: use `relayProfile`; `CRChatRelayTestResult` + `viewRelayTestResult` | +| `apps/ios/.../ChatRelayView.swift` | `UserChatRelay` model update, test button + result display | +| `apps/ios/.../AddChannelView.swift` | Test integration | +| `apps/multiplatform/.../ChatRelayView.kt` | `UserChatRelay` model update, test button + result display | +| `apps/multiplatform/.../AddChannelView.kt` | Test integration | +| `tests/ChatTests/ChatRelays.hs` | `testChatRelayTest` | + +**Separate simplexmq change:** +| `simplexmq/src/Simplex/Messaging/Agent.hs` | `getConnLinkPrivKey` API | + +--- + +## Key Functions Reused + +- `getShortLinkConnReq` (Internal.hs:1339) — fetch link data + validate SMP + get connReq +- `decodeLinkUserData` (Internal.hs:1361) — decode `RelayAddressLinkData` from `ConnLinkData` +- `encodeShortLinkData` (Internal.hs:1351) — encode `RelayAddressLinkData` for link userData +- `prepareConnectionToJoin` (agent) — prepare agent connection for joining +- `joinConnection` (agent) — join relay's contact address +- `encodeConnInfo` (Internal.hs:1929) — encode `XGrpRelayTest` as connInfo +- `parseChatMessage` (Internal.hs:1563) — parse connInfo in CONF handler +- `agentAcceptContactAsync` (Internal.hs:2421) — relay accepts test connection +- `deleteAgentConnectionAsync` (Internal.hs:2428) — cleanup connections +- `deleteConnectionRecord` (Store/Shared.hs:895) — cleanup DB connection record (takes `Int64` DB connection_id) +- `getConnLinkPrivKey` (agent, new) — retrieve `linkPrivSigKey` from connection's short link creds +- `C.verify'` (simplexmq Crypto:1270) — `PublicKey a -> Signature a -> ByteString -> Bool` +- `C.sign'` (simplexmq Crypto:1175) — `PrivateKey a -> ByteString -> Signature a` +- `C.randomBytes` (simplexmq Crypto:1401) — `Int -> TVar ChaChaDRG -> STM ByteString` +- `eToView` (Controller.hs:1537) — `ChatError -> CM ()` — report error to view + +--- + +## Verification + +### Build +```bash +cabal build --ghc-options=-O0 +``` + +### Test +```bash +cabal test simplex-chat-test --test-options='-m "channels"' +cabal test simplex-chat-test --test-options='-m "chat relays"' +``` + +### Manual verification +1. Start relay user, set as chat relay, create address +2. Start owner user +3. Owner tests relay address → verify CRChatRelayTestResult with profile, no failure +4. Owner tests invalid address → verify failure at RTSGetLink +5. Kill owner during test → verify cleanup by cleanupManager after 5 min + +--- + +## Adversarial Self-Review + +### Pass 1 + +**Issue: Signature type in JSON** — `C.Signature 'C.Ed25519` is a GADT constructor. Need to verify it has JSON/Encoding instances and can be transmitted in a JSON chat message. +**Analysis:** `Signature` has no native JSON instance. For JSON, encode as base64 ByteString using `B64UrlByteString . C.signatureBytes`. For parsing, decode `B64UrlByteString` then `C.decodeSignature :: ByteString -> Either String (Signature 'C.Ed25519)` (Crypto.hs:849). The `(.=?)` pattern handles `Maybe` — only included when `Just`. +**Fix:** Encoding uses `B64UrlByteString . C.signatureBytes <$> sig_`. Parsing uses `traverse decodeSig =<< opt "signature"` where `decodeSig (B64UrlByteString s) = C.decodeSignature <$?> pure s` (returns `JQ.Parser`, not `Either String`). No relay profile in message — owner gets it from link data. + +**Issue: `DuplicateRecordFields` on `connId`** — `connId :: Int64` appears on `Connection`, `PendingContactConnection`, and `UserContactRequest`. With `DuplicateRecordFields` enabled, `connId conn` won't compile as a field selector. +**Analysis:** Must use pattern matching. The handler uses `conn@Connection {connId = dbConnId}`. +**Fix:** Already applied in Phase 5 handler code. + +**Issue: `getConnLinkPrivKey` conn access** — In `xGrpRelayTest`, we call `getConnLinkPrivKey a (aConnId conn)` where `conn` is the user contact address connection. Does the agent's `getConn` find it by the correct ConnId? +**Analysis:** `processContactConnMessage` receives `conn :: Connection` which is the chat-layer connection record. `aConnId conn` gives the agent's `ConnId`. The agent stores `ShortLinkCreds` on the `RcvQueue` of the `ContactConnection` for this `ConnId`. The agent function pattern-matches on `ContactConnection _ rq` and returns `linkPrivSigKey <$> shortLink rq`. This is correct. +**Fix:** No fix needed. + +**Issue: `getConnLinkPrivKey` returns Nothing** — If the relay's address connection has no short link credentials, the relay-side handler logs an error via `eToView` and does not accept the test connection. +**Analysis:** This shouldn't happen for a properly configured relay (creating the address creates short link creds via `createConnection` in the agent). Handled gracefully — the owner will timeout with `RTSWaitResponse`. +**Fix:** No fix needed. + +**Issue: Test connection routing on relay side** — After the relay accepts the test via `agentAcceptContactAsync`, the agent creates a new connection. Future events on this connection (JOINED, etc.) arrive at `processAgentMessageConn`. Since there's no DB connection record, `getConnectionEntity` will fail with `SEConnectionNotFound`, producing error in `eToView`. This is log noise. +**Analysis:** Acceptable for MVP. The agent will eventually GC the connection. The error is harmless and happens for the relay only. The owner's connection is cleaned up by the handler. +**Fix:** Document as known behavior. + +**Issue: `tryAllErrors` behavior** — Does `tryAllErrors` catch all exceptions or just `ChatError`? +**Analysis:** `tryAllErrors` (Util.hs:249) uses `UE.catch` which catches `SomeException` — ALL exceptions, not just `ChatError`. It converts via `fromSomeException` into the error type. This is important: if `joinConnection` throws an IO exception, it's still caught and the cleanup runs. +**Fix:** No fix needed — the behavior is correct. + +**Issue: Multiple CONFs** — Could the owner receive multiple CONF events for the same connection? If yes, the second `putTMVar` would block. +**Analysis:** The SMP protocol sends exactly one CONF per connection. Multiple CONFs would be a protocol violation. +**Fix:** No fix needed. + +**Issue: Cleanup on timeout** — If the timeout fires (40s), the handler deletes the DB connection and agent connection. But the relay's response might arrive AFTER cleanup. +**Analysis:** After timeout, the TMap entry is deleted. A late CONF arriving at the subscriber finds no TMap entry, falls through to the existing flow, fails at `getConnectionEntity` (connection deleted). Harmless — `catchAllErrors eToView` absorbs it. +**Fix:** No fix needed. The cleanup sequence (delete TMap → delete DB → delete agent) is safe in all interleavings. + +### Pass 2 + +**Issue: `decodeLinkUserData cData`** — For relay addresses, `cData` is `ContactLinkData vr UserContactData{..}`. Does `decodeLinkUserData` decode the right field? +**Analysis:** `decodeLinkUserData` (Internal.hs:1361) is polymorphic — uses `JQ.decode` on the `userData` bytes from `UserContactData`. The caller constrains the type via the binding `Just RelayAddressLinkData {relayProfile}`. The `FromJSON` instance is provided by `deriveJSON`. +**Fix:** No fix needed. + +**Issue: `encodeShortLinkData`** — Will it work for `RelayAddressLinkData`? +**Analysis:** `encodeShortLinkData` (Internal.hs:1351) is polymorphic — `J.ToJSON a => a -> UserLinkData`. Uses `J.encode` and wraps in `UserLinkData`. Works for any type with `ToJSON`. +**Fix:** No fix needed. + +**Issue: Cleanup identification query safety** — `getStaleRelayTestConns` uses: `ConnContact + contact_id IS NULL + ConnPrepared + contact_conn_initiated = 0 + old created_at`. Could this match non-test connections? +**Analysis:** All code paths that create `ConnContact` with `contact_id = NULL`: +- `createConnReqConnection` (Direct.hs:158): sets `ConnPrepared` (line 164) BUT also sets `contact_conn_initiated = True` (line 175, `BI True`), `xcontact_id`, and `via_contact_uri`. The `contact_conn_initiated = 0` condition excludes these. +- `createRelayTestConnection` (new): sets `ConnPrepared`, inherits `contact_conn_initiated = 0` default. Matches the query. +- No other code path creates `ConnContact` with `contact_id = NULL` and `contact_conn_initiated = 0`. +**Fix:** The query is safe with the `contact_conn_initiated = 0` discriminator. + +**Issue: Partial failure cleanup** — If `prepareConnectionToJoin` succeeds but the `withFastStore` for `createRelayTestConnection` fails, the agent connection leaks. +**Analysis:** The `prepareConnectionToJoin` call happens before the `tryAllErrors` block. If `createRelayTestConnection` throws, we never reach cleanup. The agent connection from `prepareConnectionToJoin` would leak until restart. However, `createRelayTestConnection` is a simple INSERT — it's unlikely to fail. And if it does, `cleanupManager` won't catch it because no DB row was created. The agent-level connection will be cleaned up on agent restart. +**Fix:** Acceptable for MVP. Could wrap in a broader try-catch, but the failure mode is extremely unlikely and the consequence (one leaked agent connection) is minor. + +**Issue: `void $ withAgent $ \a -> joinConnection ...`** — The return type of `joinConnection` is `AE (SndQueueSecured, Maybe ClientServiceId)`. Using `void` discards both values. +**Analysis:** For the test connection, we don't need `SndQueueSecured` or `ClientServiceId`. The `addRelay` function (Commands.hs:3776) uses the return value to update connection status, but the test connection is deleted immediately anyway. +**Fix:** No fix needed. + +Both passes clean. No further issues found. diff --git a/plans/2026-04-02-desktop-voice-recording.md b/plans/2026-04-02-desktop-voice-recording.md new file mode 100644 index 0000000000..e29af72f34 --- /dev/null +++ b/plans/2026-04-02-desktop-voice-recording.md @@ -0,0 +1,39 @@ +# Desktop Voice Recording + +## Overview + +Implement voice recording on desktop using vlcj (already a dependency). The `RecorderNative` class is currently a stub. All UI is already in common code. + +## Files to modify + +1. `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt` — implement `RecorderNative` +2. `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt` — remove desktop "in development" guard (line 317-318) +3. `apps/multiplatform/desktop/build.gradle.kts` — add `NSMicrophoneUsageDescription` to macOS Info.plist + +## RecorderNative implementation + +Uses `MediaPlayerFactory` + `MediaPlayer` to capture from default microphone and transcode to AAC/m4a via VLC's sout chain. + +Platform-specific capture MRLs: +- macOS: `qtsound://` +- Linux: `pulse://` +- Windows: `dshow://` with `:dshow-vdev=none :dshow-adev=` + +Transcode options: `vcodec=none,acodec=mp4a,ab=32,channels=1,samplerate=16000` — matches Android (mono, 16kHz, 32kbps AAC). + +Factory requires `--sout-avcodec-strict=-2` to enable FFmpeg's native AAC encoder. + +Progress tracked via elapsed time (VLC capture has no position API). Duration read via `AudioPlayer.duration()` after stop. + +Max duration: enforced by stopping recording after `MAX_VOICE_MILLIS_FOR_SENDING` (300,000 ms) in the progress coroutine. + +## macOS permission + +Add `NSMicrophoneUsageDescription` to Info.plist via Gradle `infoPlist` block. + +## What does NOT change + +- `RecorderInterface` (common) +- `ComposeView.kt`, `ComposeVoiceView` — already handle voice preview/sending +- Audio format — `.m4a` (matches Android) +- All voice recording UI — already in common code diff --git a/plans/channel_message_bugs_fix_plan.md b/plans/channel_message_bugs_fix_plan.md new file mode 100644 index 0000000000..c50b5ed7ff --- /dev/null +++ b/plans/channel_message_bugs_fix_plan.md @@ -0,0 +1,321 @@ +# Plan: Channel Message Bugs Fix + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Bug 1: Delivery Context Flag](#bug-1-delivery-context-flag) +3. [Bug 2: Reaction Attribution](#bug-2-reaction-attribution) +4. [Bug 3: Update Fallback Default](#bug-3-update-fallback-default) +5. [Bug 4: Forward API Parameter](#bug-4-forward-api-parameter) +6. [Bug 5: CLI Forward Hardcode](#bug-5-cli-forward-hardcode) +7. [Test Plan](#test-plan) +8. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +**5 bugs identified** in channel message handling: + +| # | Location | Bug | Severity | +|---|----------|-----|----------| +| 1 | Subscriber.hs:935-945 | Events use `isChannelOwner` instead of item's `showGroupAsSender` | Critical | +| 2 | Subscriber.hs:1818-1842 | Reactions allow `m_=Nothing` and fall back to membership | High | +| 3 | Subscriber.hs:1950-1969 | Update fallback creates item without correct sendAsGroup flag | Medium | +| 4 | Commands.hs:930,944 | Forward API ignores `_sendAsGroup` parameter | High | +| 5 | Commands.hs:2191,2196,2201,4633 | CLI forward hardcodes False | Medium | + +--- + +## Bug 1: Delivery Context Flag + +### Current Code (Subscriber.hs:935-945) +```haskell +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> isChannelOwner -- BUG: should use item's flag + XMsgDel {} -> isChannelOwner -- BUG + XMsgReact {} -> isChannelOwner -- BUG + XMsgFileDescr {} -> isChannelOwner -- BUG + XFileCancel {} -> isChannelOwner -- BUG + _ -> False +``` + +### Problem +Events referencing existing items (update, delete, react, file) compute `showGroupAsSender'` from **current sender role** (`isChannelOwner`) instead of **item's stored `showGroupAsSender` flag**. + +### Fix +Extract `showGroupAsSender` from the chat item being referenced: + +```haskell +showGroupAsSender' = case event of + XMsgNew mc -> fromMaybe False (asGroup (mcExtMsgContent mc)) + XMsgUpdate {} -> itemShowGroupAsSender ci -- from item lookup + XMsgDel {} -> itemShowGroupAsSender ci + XMsgReact {} -> itemShowGroupAsSender ci + XMsgFileDescr {} -> itemShowGroupAsSender ci + XFileCancel {} -> itemShowGroupAsSender ci + _ -> False +``` + +**Note:** Use `chatDir` from ChatItem and pattern match on `CIChannelRcv` to determine sendAsGroup flag. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 935-945 + +--- + +## Bug 2: Reaction Attribution + +### Current Code (Subscriber.hs:1818-1842) +```haskell +groupMsgReaction :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) +groupMsgReaction g m_ sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + ... + where + GroupInfo {membership} = g + reactor = fromMaybe membership m_ -- BUG (line 1842): uses membership when m_ is Nothing + ciDir = maybe CIChannelRcv CIGroupRcv m_ +``` + +### Problem +When `m_` is `Nothing`, reactor incorrectly falls back to `membership` (user's own member record). However, reactions should always come from an identifiable member - the `m_` parameter should never be `Nothing` for reactions. + +### Fix +Reactions can only come from members (including owners), never from channels. XMsgReact handler must be reworked to require `GroupMember` instead of `Maybe GroupMember`. The `m_` parameter should not be optional for reactions. + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1818-1842 + +--- + +## Bug 3: Update Fallback Default + +### Current Code (Subscriber.hs:1950-1969) +```haskell +updateRcvChatItem `catchCINotFound` \_ -> do + (chatDir, mentions', scopeInfo) <- case m_ of + Just m -> ... + Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing) -- BUG: no sendAsGroup info + (ci, cInfo) <- saveRcvChatItem' user chatDir msg ... +``` + +### Problem +When `x.msg.update` arrives for a locally-deleted item in a channel (`m_` is `Nothing`), the fallback creates a new item with `CDChannelRcv gInfo Nothing` but doesn't know the original item's `sendAsGroup` flag. + +### Fix (Option B: Require sender to include flag in the event) +Add `asGroup` field to `XMsgUpdate` message format. + +**Rationale:** We don't know what owner wants otherwise - it may send as channel or it may send as owner, and different members must have the same view (e.g. when multiple relays are used, it would be random). + +### Files Modified +- `src/Simplex/Chat/Library/Subscriber.hs`: Lines 1950-1969 +- Protocol message format (XMsgUpdate) + +--- + +## Bug 4: Forward API Parameter + +### Current Code (Commands.hs:930,944) +```haskell +APIForwardChatItems ... _sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope (sendAsGroup' gInfo) False itemTTL cmrs' + -- ^^^^^^^^^^^^^^^^^^^ BUG: ignores _sendAsGroup +``` + +### Problem +The `_sendAsGroup` parameter is received but ignored. The function computes its own `sendAsGroup' gInfo` instead. + +### Fix +```haskell +APIForwardChatItems ... sendAsGroup -> withUser $ \user -> case toCType of + CTGroup -> do + ... + sendGroupContentMessages user gInfo toScope sendAsGroup False itemTTL cmrs' +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Line 930 (rename parameter), Line 944 (use parameter) + +--- + +## Bug 5: CLI Forward Hardcode + +### Current Code (Commands.hs) +```haskell +-- Line 2191 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTDirect contactId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2196 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTGroup groupId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 2201 +processChatCommand vr nm $ APIForwardChatItems toChatRef (ChatRef CTLocal folderId Nothing) (forwardedItemId :| []) Nothing False + +-- Line 4633 +"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP <*> pure False), +``` + +### Problem +All CLI forward commands hardcode `False` for `sendAsGroup` instead of computing based on destination. + +### Fix +Compute `sendAsGroup` before calling API based on destination group's channel status: + +```haskell +-- Lines 2191, 2196, 2201: Need to determine sendAsGroup based on toChatRef +-- If toChatRef is a channel and user is owner, sendAsGroup should default to True + +-- Line 4633: Parser should accept optional flag (parser cannot know context) +``` + +### Files Modified +- `src/Simplex/Chat/Library/Commands.hs`: Lines 2191, 2196, 2201, 4633 + +--- + +## Test Plan + +### New Tests (8 total) + +Tests 1-4 cover Bug 1 (delivery context flag). Each tests a specific event type where the owner sends as member (sendAsGroup=False). Existing tests already cover the "sends as channel" (sendAsGroup=True) case; these tests verify that the delivery context correctly uses the item's stored sendAsGroup=False flag rather than recomputing from the owner's current role. + +#### Test 1: `testChannelOwnerUpdateAsMember` +**Objective:** Verify x.msg.update uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner updates message +4. Verify update delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 2: `testChannelOwnerDeleteAsMember` +**Objective:** Verify x.msg.del uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends message as member (sendAsGroup=False) +2. Member receives message, verify it shows as from member (not channel) +3. Owner deletes message +4. Verify delete delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 3: `testChannelOwnerFileTransferAsMember` +**Objective:** Verify file delivery (including x.msg.file.descr) uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Verify file delivery uses sendAsGroup=False from the item, not recomputed from owner role + +**Note:** x.msg.file.descr is part of file delivery, not a separate event to test independently. + +**Coverage:** Bug 1 + +--- + +#### Test 4: `testChannelOwnerFileCancelAsMember` +**Objective:** Verify x.file.cancel uses item's sendAsGroup=False, not current role. + +**Scenario:** +1. Owner sends file as member (sendAsGroup=False) +2. Member receives file, verify it shows as from member (not channel) +3. Owner cancels file +4. Verify cancel delivery context uses sendAsGroup=False from the item, not recomputed from owner role + +**Coverage:** Bug 1 + +--- + +#### Test 5: `testChannelReactionAttribution` +**Objective:** Verify reactions require a member sender (not optional). + +**Scenario:** +1. Owner sends channel message +2. Owner adds reaction (as member, not as channel) +3. Verify reaction is attributed to owner's member record +4. Member adds reaction to channel message +5. Verify member reaction is attributed correctly +6. Verify channel cannot send reactions (m_ must be Just) + +**Coverage:** Bug 2 + +--- + +#### Test 6: `testChannelUpdateFallbackSendAsGroup` +**Objective:** Verify update on deleted item creates correct sendAsGroup from protocol field. + +**Scenario:** +1. Owner sends channel message (sendAsGroup=True) +2. Member receives and locally deletes +3. Owner updates message (XMsgUpdate includes asGroup=True) +4. Verify member's recreated item has sendAsGroup=True +5. Owner sends message as member (sendAsGroup=False) +6. Member receives and locally deletes +7. Owner updates message (XMsgUpdate includes asGroup=False) +8. Verify member's recreated item has sendAsGroup=False + +**Coverage:** Bug 3 + +--- + +#### Test 7: `testForwardAPIUsesParameter` +**Objective:** Verify Forward API respects sendAsGroup parameter. + +**Scenario:** +1. Create channel with owner +2. Forward message to channel with sendAsGroup=True +3. Verify message sent as channel +4. Forward message with sendAsGroup=False +5. Verify message sent as member + +**Coverage:** Bug 4 + +--- + +#### Test 8: `testForwardCLISendAsGroup` +**Objective:** Verify CLI forward commands compute sendAsGroup correctly. + +**Scenario:** +1. Create channel with owner +2. Use `/forward` to forward to channel +3. Verify sendAsGroup computed correctly (True for owner in channel) + +**Coverage:** Bug 5 + +--- + +## Implementation Order + +### Phase 1: Critical Fix (Bug 1) +1. Fix delivery context in Subscriber.hs +2. Add Tests 1-4 (`testChannelOwnerUpdateAsMember`, `testChannelOwnerDeleteAsMember`, `testChannelOwnerFileTransferAsMember`, `testChannelOwnerFileCancelAsMember`) + +### Phase 2: API Fixes (Bugs 4, 5) +1. Fix Forward API parameter usage +2. Fix CLI forward hardcodes +3. Add Tests 7 and 8 (`testForwardAPIUsesParameter`, `testForwardCLISendAsGroup`) + +### Phase 3: Behavior Fixes (Bugs 2, 3) +1. Rework XMsgReact handler to require GroupMember (not Maybe GroupMember) +2. Add asGroup field to XMsgUpdate protocol message +3. Add Tests 5 and 6 (`testChannelReactionAttribution`, `testChannelUpdateFallbackSendAsGroup`) + +--- + +## Files Summary + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Library/Subscriber.hs` | Lines 935-945 (Bug 1), 1818-1842 (Bug 2), 1950-1969 (Bug 3) | +| `src/Simplex/Chat/Library/Commands.hs` | Lines 930,944 (Bug 4), 2191,2196,2201,4633 (Bug 5) | +| Protocol message types | Add asGroup field to XMsgUpdate (Bug 3) | +| `tests/ChatTests/Groups.hs` | Add 8 new tests | diff --git a/plans/chat-relays-mvp-launch-plan.md b/plans/chat-relays-mvp-launch-plan.md new file mode 100644 index 0000000000..64af3c7d42 --- /dev/null +++ b/plans/chat-relays-mvp-launch-plan.md @@ -0,0 +1,293 @@ +# Chat Relays MVP — Launch Plan + +## Contents +- [Executive Summary](#executive-summary) +- [What's Done](#whats-done) +- [What's Remaining](#whats-remaining): Protocol & Crypto | Relay Protocol | Member Connection | UI | Testing | Polish | Directory +- [Dependency Summary](#dependency-summary) +- [Risk Register](#risk-register) +- [Decisions Made](#decisions-made) +- [Post-MVP Backlog](#post-mvp-backlog) + +--- + +## Executive Summary + +Chat Relays enable large public channels where messages flow owner → relay → members, replacing N-to-N connections. This plan covers what remains for MVP launch. + +**Current state**: Core backend ~75% done (delivery system, forwarding, deduplication, relay invitation/acceptance, group creation with relays all working). UI ~15%. Key remaining work: member key signatures, relay identity validation, forward envelope protocol, UI on both platforms. + +**MVP delivers**: Owners create channels with preset relays. Relays validate and serve groups. Members join via links, receive relay-forwarded messages signed by owners. UI differentiates channels from groups. + +**Out of scope**: Relay removal/recovery, periodic relay health monitoring, relay-to-relay sync, history navigation, e2e encryption in support chats, multi-owner support, reaction/comment batching. See [Post-MVP](#post-mvp-backlog). + +--- + +## What's Done + +- Single-roundtrip group creation with relays (`APINewPublicGroup` → `prepareConnectionLink` → `createConnectionForLink` — Agent API complete) +- Relay invitation/acceptance protocol (`XGrpRelayInv`, `XGrpRelayAcpt`) and relay request worker +- Async delivery task/job system with cursor-paginated member delivery +- `FwdChannel` / `FwdMember` forwarding modes, `ShowGroupAsSender` through full pipeline +- Message deduplication on member side +- Binary batch encoding (`=` prefix) in `Messages/Batch.hs` and `Protocol.hs` +- DB schema: `chat_relays`, `group_relays`, `group_members.relay_link`, key columns on `groups`/`group_members` +- Preset relay configuration framework (3 placeholder relays in `Presets.hs`) +- `CIChannelRcv` chat item direction in backend +- Observer role UI already works on both platforms (compose bar hidden, reactions only) + +## What's Remaining + +Organized by architecture layer, not work streams. Items within each section are roughly ordered by dependency. + +--- + +### 1. Protocol & Cryptography + +#### 1.1 Binary Forward Envelope (`F` prefix) +New top-level binary format replacing `XGrpMsgForward` for relay groups. Wraps original sender bytes verbatim — preserves signatures through relay forwarding without re-encoding. + +Format: `F` (see member-keys-plan.md §8). + +Old groups keep `XGrpMsgForward` (JSON). New relay groups use `F` envelope. Parser accepts both. + +**Files**: `Protocol.hs` (parse/encode), `Batch.hs` (batching), `Subscriber.hs` (forwarding handler replacement) + +#### 1.2 Key Generation & Storage +Generate Ed25519 key pairs on group creation/join. Populate existing DB columns: `root_priv_key`/`root_pub_key` on `groups`, `member_priv_key` on `groups`, `member_pub_key` on `group_members`. + +Consider adding to current `M20260222_chat_relays` migration (unreleased) rather than creating a new one. + +**Files**: `Store/Groups.hs`, `Store/Profiles.hs`, `Commands.hs` (creation flow) + +#### 1.3 Message Signing +Sign roster-modifying messages (`XGrpRelayInv`, `XGrpMemNew`, `XGrpMemRole`, `XGrpMemDel`, `XGrpInfo`, `XGrpPrefs`, `XGrpDel`) with owner's member key. + +**Files**: `Internal.hs` (signChatMessage), `Commands.hs` (sendGroupMessage integration) + +#### 1.4 Signature Verification +Verify signatures on received roster messages. Hard fail for missing/invalid signatures in new-version groups. + +**Files**: `Internal.hs` (verifyChatMessage), `Subscriber.hs` (reception) + +#### 1.5 OwnerAuth Chain +Owner authorization signed by root key, stored in group link's `UserContactData.owners`. Members verify owner identity via chain. Type exists; integration TODO. + +**Files**: `Protocol.hs`, `Commands.hs`, `Subscriber.hs` + +#### 1.6 Version Gating +Chat relays is a new feature — relay groups only joinable by clients of the new version. Add `chatRelaysVersion` to version range. No backward compat needed for relay groups themselves (they don't exist in older versions). + +**Files**: `Types.hs` (version constant), `Commands.hs` (gating) + +--- + +### 2. Relay Protocol + +#### 2.1 Relay Address Link Data +On relay address creation, set link data: relay identity (profile, certificate, relay identity key). Members validate this when connecting. + +**Files**: `Commands.hs` (relay address creation), `Protocol.hs` (relay link data structure) + +#### 2.2 Group Profile Validation by Relay +Before accepting to serve group, relay validates group profile, verifies owner's signature, and checks `shared_group_id` in immutable link data (prevents redirect to wrong group). + +**Files**: `Subscriber.hs` (`runRelayRequestWorker` — stub exists, validation logic TODO) + +#### 2.3 Relay Link Data on Acceptance +When accepting, relay sets: relay identity, relay key for group, group ID in immutable part of relay link data. + +**Files**: `Subscriber.hs` (relay link creation) + +#### 2.4 Relay Key/Identity Validation by Members +When member connects to relay, validate relay link data (identity, key, group ID) matches group link data. This is part of the same signature/identity verification work as §1.4. + +**Files**: `Commands.hs` (`connectToRelay`), `Subscriber.hs` + +#### 2.5 Test Chat Relay Command +`APITestChatRelay` / `TestChatRelay` — channel owners need to verify relay connectivity before creating channels. + +**Files**: `Commands.hs` (new command) + +#### 2.6 Real Relay Addresses in Presets +Replace placeholder URLs in `simplexChatRelays`. Depends on relay server deployment. + +**Files**: `Operators/Presets.hs` + +#### 2.7 Channel-Only Behavior Enforcement +In channel groups (`useRelays = True`), the API supports sending both as channel (`asGroup=True`) and as member. For MVP, UI always passes `asGroup=True`. Backend does not enforce — owners retain the API option to send as member for future use. Non-owner/non-admin members can only send reactions (observer role enforced by existing role system). + +**Files**: UI-only enforcement for MVP (both platforms pass `asGroup=True` in compose) + +--- + +### 3. Member Connection Flow + +#### 3.1 Support `/c` API for Relay Groups +Automate `APIPrepareGroup` → `APIConnectPreparedGroup` flow when using `/c` command with a relay group link. Currently requires manual two-step call. + +**Files**: `Commands.hs` (`connectWithPlan`) + +#### 3.2 Relay Connection State Response Type +New response type/events showing per-relay connection state (connecting, connected, temporary error, permanent error). Needed for both member join and owner creation UX. + +**Files**: `Controller.hs` (new ChatResponse variants), `Commands.hs` (emit events) + +#### 3.3 Member Count for Channels +Existing member count display uses loaded member list — won't work for channels, where members only have records for owners and relays. Relays must communicate real member counts (excluding relays themselves) to members and owners. Needs protocol extension for relay → member count communication. + +**Files**: `Protocol.hs` (new event or extension), `Subscriber.hs` (relay reporting), UI (display) + +--- + +### 4. UI — Both Platforms (iOS + Android/Desktop) + +All UI items must be completed on both platforms for MVP. + +#### 4.1 Channel Visual Distinction +Different icon/badge for channels in chat list. "Channel" label. Key off `useRelays` flag in `GroupInfo`. + +No backend dependency — can start immediately. + +#### 4.2 "Message from Channel" Display +`CIChannelRcv` direction NOT yet handled in either platform's UI. Must add to message rendering pipeline. `showGroupAsSender` message rendering. + +Backend complete. No backend dependency. + +#### 4.3 Channel Creation Flow +"Create Channel" button in new chat menu → name/description → relay selection → creation with relay status feedback (invited → accepted → active). Backend `APINewPublicGroup` exists. + +Depends on: §3.2 (relay connection state type) + +#### 4.4 Relay Management (User Settings) +List of configured relays; add/remove/edit; test connectivity. Follow existing SMP server management pattern. + +Depends on: §2.5 (`APITestChatRelay`) + +#### 4.5 Show Relays in Channel Info +Relay list with status and identity in channel info screen. + +#### 4.6 Relay Connection State During Join +Progress feedback when joining: "Connecting to relays..." → per-relay status → "Connected". + +Depends on: §3.2 (relay connection state type) + +#### 4.7 Owner Posting UI +Compose mode always sends as channel (`asGroup=True`). No toggle for MVP. + +#### 4.8 API Type Updates +- **iOS**: Add `apiNewPublicGroup` to `ChatCommand` enum; add `ChatRelay`, `RelayStatus`, `GroupRelay`, `CIChannelRcv` types +- **Android**: Add corresponding types to Kotlin model layer +- Both: relay connection state event types + +--- + +### 5. Testing + +- Delivery loop restored after restart +- Delivery in support scopes inside channels +- Connect plans for relay groups +- Cancellation on failure to create relay group +- Async retry connecting to relay (members) +- Relay privileges +- Binary forward envelope encode/decode round-trips +- Message signing and verification flow +- Relay signature validation in invitation flow +- Backward compat: old clients cannot join relay groups (version gated) + +--- + +### 6. Polish & Edge Cases + +- Create missing service chat items ("relays updated" for owner, "group invite accepted" for relay) +- Disable link data output in CLI (`View.hs` — currently enabled for manual testing, cleanup) +- When deleting chat relay from user config, check `group_relays` references and mark as deleted instead +- Single file description for all recipients (performance) + +--- + +### 7. Directory Service Verification + +Directory service currently has no channel/relay awareness — it only lists regular groups. Needs verification how channels should appear in directory and what integration work is required. Some adaptation may be needed. + +--- + +## Dependency Summary + +``` +Can start immediately (no dependencies): + §1.2 Key Storage, §1.3-1.5 Signing/Verification, §1.6 Version Gating + §2.1 Relay Address Data, §2.7 Channel Enforcement (UI-only) + §4.1 Channel Visual Distinction, §4.2 "Message from Channel" Display + +Needs §1.3-1.5 (signing): + §2.2 Group Profile Validation, §2.3 Relay Link Data + +Needs §2.1+2.3: + §2.4 Relay Key Validation by Members + +Needs §3.2 (relay state type): + §4.3 Channel Creation UI, §4.6 Join State UI + +Needs §2.5 (test command): + §4.4 Relay Management UI + +Late phase: + §5 Testing (needs most backend complete) + §2.6 Real Relay Addresses (needs server deployment) + §7 Directory Verification +``` + +**Critical path**: §1.1 (Forward Envelope) + §1.2-1.5 (Keys/Signing) → §2.2-2.3 (Relay Validation) → §5 (Testing) → Launch + +**Early UI wins**: §4.1, §4.2 can start in Phase 1. + +--- + +## Risk Register + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Forward envelope (`F`) version mismatch relay↔member | High | Version gating — relay groups require new version on all participants | +| Relay server instability under load | High | Load test early; multi-relay redundancy | +| UI on 2 platforms takes longer than expected | Medium | Both required for MVP; start UI early (§4.1, §4.2 have no backend deps) | +| Member count protocol extension complexity | Medium | Can ship without count initially; add in fast-follow | +| Stale relay "Active" status (no health monitoring) | Low | Multi-relay redundancy; manual `APITestChatRelay`; monitoring post-MVP | + +--- + +## Decisions Made + +- **Single-owner channels**: Allowed without warning (sender identity is clear for "messages from channel"). Single-owner is the main MVP case; "from channel" UX is valuable regardless. Revisit with multi-owner support. +- **Channel-only enforcement**: UI-only for MVP (`asGroup=True` always passed). Backend retains API flexibility for future "send as member" option. +- **Default member role**: Observer by default for channels. No additional owner→relay communication of role/rejection rules for MVP. +- **Contact connection refactoring**: Deferred to post-MVP. Current flow works. +- **Member rejection by relay**: Deferred. MemberId clash unlikely; rejection rules postponed. +- **Relay profiles**: Consider for MVP vs post-MVP. Members and owners see relay profiles in group already; linking to single per-config profile is nice-to-have. +- **Chat relay user filtering**: Post-MVP. Relay user will be visible in client for now. + +--- + +## Post-MVP Backlog + +1. Relay removal and group recovery — owner removes relay, members reconnect via updated link +2. Periodic relay health checks — relay verifies link presence in group link data +3. Relay-to-relay synchronization +4. Managing relays in existing group — add/remove relays post-creation +5. Default member role and rejection rules communication owner→relay +6. Member rejection by relay (duplicate member ID, rule violations) +7. Contact connection flow refactoring (`connectViaContact` simplification) +8. Deduplication highlighting — show differences between relay-forwarded messages +9. History navigation — request older messages from channel +10. E2E encryption in admin/support chats +11. Reaction/comment count batching +12. Priority connections — separate queues for messages vs admin requests +13. Member profile delivery optimization +14. Private relays with password +15. Channel content moderation +16. Indefinite file storage for relays +17. Message revocation from history +18. Channel discovery/directory integration (verify and extend) +19. Advanced forwarding envelope — include channel link in forwarded message metadata for distribution +20. Relay profiles linked to single per-config record +21. Chat relay user filtering/separate UI diff --git a/plans/deduplication-channel-messages.md b/plans/deduplication-channel-messages.md new file mode 100644 index 0000000000..0d09d00528 --- /dev/null +++ b/plans/deduplication-channel-messages.md @@ -0,0 +1,256 @@ +# Deduplication Plan: Channel Message Functions + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Findings by File](#findings-by-file) +3. [Architectural Note: CIChannelRcv Constructor](#architectural-note) +4. [Implementation Order](#implementation-order) + +--- + +## Executive Summary + +The PR introduces channel message support by creating parallel channel-specific functions that duplicate 60-80% of existing group functions. The core pattern: channel messages are group messages without a member sender. Most channel functions are the group function with `Just member` → `Nothing`, `CIGroupRcv m` → `CIChannelRcv`, and moderation/blocking guards removed. + +**High-value deduplication targets** (ordered by impact): + +| # | Candidate | Feasibility | Shared code | +|---|-----------|-------------|-------------| +| 1 | `channelMessageUpdate_` → merge into `groupMessageUpdate` | HIGH | ~36 lines | +| 2 | `fwdChannelReaction` → extract shared helper with `groupMsgReaction` | MEDIUM | ~15 lines inner function | +| 3 | `newChannelContentMessage_` → parameterize `newGroupContentMessage` | MEDIUM | ~12 lines happy path | +| 4 | `processForwardedChannelMsg` → merge into `processForwardedMsg` | MEDIUM | depends on 1-3 | +| 5 | `getGroupCIBySharedMsgId'` → parameterize `getGroupChatItemBySharedMsgId` | HIGH | eliminates function | +| 6 | `channelMessageDelete` → parameterize `groupMessageDelete` | LOW | ~5 lines; group has 60+ lines moderation | +| 7 | `saveRcvChatItem'` CDChannelRcv branches | HIGH | ~14 lines across 3 spots | +| 8 | `processContentItem` CIChannelRcv branch | HIGH | ~3 lines | +| 9 | View.hs/Store/Internal pattern match branches | DEFERRED | ~24 branches; requires constructor change | + +--- + +## Findings by File + +### Subscriber.hs + +**D1: `channelMessageUpdate_` vs `groupMessageUpdate`** + +The `updateRcvChatItem` inner function is nearly line-for-line identical between both (~36 shared lines). Differences: +- Lookup: `getGroupChatItemBySharedMsgId` (by member) vs `getGroupCIBySharedMsgId'` (no member) — parameterizable by `Maybe GroupMemberId` (see D5) +- Pattern match: `CIGroupRcv m'` with `sameMemberId` check vs `CIChannelRcv` — branch on `Maybe GroupMember` +- `getGroupCIReactions`: `Just memberId` vs `Nothing` — already parameterized +- Chat direction in fallback: `CDGroupRcv` vs `CDChannelRcv` — branch on `Maybe GroupMember` +- `channelMessageUpdate_` has explicit `forwarded` param; `groupMessageUpdate` always uses `rcvGroupCITimed gInfo ttl_` — the merged function needs to accept `forwarded :: Bool` (or always `False` from the non-forwarded path) +- `groupMessageUpdate` has `prohibitedSimplexLinks` and `blockedMemberCI` guards — skip when member is `Nothing` +- Mentions handling: `groupMessageUpdate` has `mentions' = if memberBlocked m then [] else mentions`; `channelMessageUpdate_` passes `mentions` directly — when member is `Nothing`, use `mentions` directly (no blocking check needed) + +**Solution:** Extend `groupMessageUpdate` to take `Maybe GroupMember`. When `Nothing`: skip prohibited links check, skip blocked member CI, use `CDChannelRcv`, use `getGroupChatItemBySharedMsgId` with `Nothing`, pass mentions directly. Delete `channelMessageUpdate_`. + +--- + +**D2: `fwdChannelReaction` vs `groupMsgReaction`** + +These functions share the `updateChatItemReaction` inner function shape (~15 lines), but are **structurally different** in their outer logic: + +- **Parameter types**: `groupMsgReaction` takes a concrete `GroupMember` + `Maybe MemberId` (item member) + `Maybe MsgScope`; `fwdChannelReaction` takes `Maybe GroupMember` (reactor) and always passes `Nothing` as item member +- **Return type**: `groupMsgReaction` returns `CM (Maybe DeliveryJobScope)` — used by the main dispatch for delivery job routing; `fwdChannelReaction` returns `CM ()` — forwarded context doesn't need delivery jobs +- **CIReaction constructor**: `groupMsgReaction` always uses `CIGroupRcv m`; `fwdChannelReaction` uses `maybe CIChannelRcv CIGroupRcv reactor_` — semantically different when reactor is `Nothing` +- **catchCINotFound fallback**: `groupMsgReaction` has scope-aware delivery job logic; `fwdChannelReaction` does bare `setGroupReaction` +- **Reactor**: `groupMsgReaction` uses `m` directly; `fwdChannelReaction` computes `fromMaybe membership reactor_` + +`fwdChannelReaction` is NOT a rename of `groupMsgReaction`. Calling `void $ groupMsgReaction` from forwarded contexts would be **semantically wrong**: it would attribute channel reactions to the membership member via `CIGroupRcv` instead of showing them as `CIChannelRcv`, and would trigger unnecessary delivery job scope logic. + +**Solution:** Extract the shared `updateChatItemReaction` body (~15 lines) into a helper parameterized by the `CIReaction` constructor and reactor member. Both `groupMsgReaction` and `fwdChannelReaction` call this helper with their respective parameters. This preserves the distinct outer logic while eliminating the inner body duplication. + +--- + +**D3: `newChannelContentMessage_` vs `newGroupContentMessage`** + +The channel version is the "happy path" of the group version with all member-specific guards removed: +- No `blockedByAdmin` check +- No `prohibitedGroupContent` check +- No `getCIModeration` / moderation logic (~40 lines) +- No scope resolution (`mkGetMessageChatScope`) +- No `blockedMemberCI` +- No member-conditional mentions filtering / autoAcceptFile guard + +The shared "save-view-react-accept" core is ~12 lines. + +**Solution:** Extract a shared `saveGroupContentItem` helper containing: process file invitation, save chat item, get reactions, view, auto-accept, return scope. `newGroupContentMessage` calls it after its checks; `newChannelContentMessage_` calls it directly. This keeps `newGroupContentMessage`'s complex flow intact while eliminating the body duplication. + +Alternatively: extend `newGroupContentMessage` to take `Maybe GroupMember`. When `Nothing`: skip all member-specific guards and use `CDChannelRcv`. This is cleaner but changes the function's signature and control flow significantly. + +--- + +**D4: `processForwardedChannelMsg` vs `processForwardedMsg`** + +These are dispatch tables with identical structure. Each event arm calls the group or channel variant: + +``` +processForwardedMsg author: processForwardedChannelMsg: + XMsgNew → newGroupContentMessage XMsgNew → newChannelContentMessage_ + XMsgFileDescr → groupMessageFileDescription XMsgFileDescr → channelMessageFileDescription + XMsgUpdate → groupMessageUpdate XMsgUpdate → channelMessageUpdate_ + ... ... +``` + +If the underlying functions (D1-D3) are parameterized by `Maybe GroupMember`, this dispatch unifies automatically. The extra group-management events (`XInfo`, `XGrpMemNew`, etc.) are guarded by `Just author`. + +**Subtlety: `XMsgReact` handling.** The `XMsgReact` arm has a three-way split: +- `processForwardedMsg` with `Just memId` → `groupMsgReaction` (member reaction with scope/delivery-job logic) +- `processForwardedMsg` with `Nothing` memId → `fwdChannelReaction gInfo (Just author)` (channel reaction from known author) +- `processForwardedChannelMsg` → `fwdChannelReaction gInfo Nothing` (channel reaction, no author) + +This three-way split needs careful handling in the merged function, since `fwdChannelReaction` differs structurally from `groupMsgReaction` (see D2). + +**Solution:** After D1-D3, merge into `processForwardedMsg` taking `Maybe GroupMember`. When `Nothing`, skip group-management events. The `XMsgReact` arm passes the author to `fwdChannelReaction` when in channel mode. Delete `processForwardedChannelMsg`. + +--- + +**D5: `channelMessageDelete` vs `groupMessageDelete`** + +`groupMessageDelete` has ~60 lines of moderation logic (moderate, checkRole, archiveMessageReports, CIModeration creation) that `channelMessageDelete` does not need. The shared portion is only ~5-7 lines (delete/mark-deleted + view). Additionally, the lookup functions differ: `channelMessageDelete` uses `getGroupCIBySharedMsgId'` (no member); `groupMessageDelete` uses `getGroupMemberCIBySharedMsgId` (JOINs group_members by MemberId). The delete condition also differs: `groupFeatureAllowed` vs `groupFeatureMemberAllowed`. + +**Solution:** LOW priority. The functions are architecturally different enough that forced unification would harm readability. If desired, extend `groupMessageDelete` with a `Maybe GroupMember` parameter where `Nothing` takes the simple "channel delete" path early. But the code clarity cost may exceed the deduplication benefit. + +--- + +### Store/Messages.hs + +**D6: `getGroupCIBySharedMsgId'` vs `getGroupChatItemBySharedMsgId`** + +`getGroupChatItemBySharedMsgId` filters by `group_member_id = ?`. +`getGroupCIBySharedMsgId'` omits the `group_member_id` filter entirely (matches any row regardless of member). + +Channel items store `group_member_id = NULL`. Parameterizing with `Maybe GroupMemberId` and `IS NOT DISTINCT FROM` would: +- `Just gmId` → only that member (existing behavior) +- `Nothing` → only NULL rows (channel items) + +This is **stricter** than `getGroupCIBySharedMsgId'`'s current behavior (which matches any member's items too), but this is actually a correctness improvement — all four callers (Subscriber.hs lines 1846, 1962, 1988, 3233) are channel-specific contexts where items have `group_member_id = NULL`. + +**Solution:** Change `getGroupChatItemBySharedMsgId` to take `Maybe GroupMemberId`. SQL becomes: +```sql +WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? +``` +Delete `getGroupCIBySharedMsgId'`. Update all callers to pass `Just gmId` or `Nothing`. + +**Note:** `getGroupMemberCIBySharedMsgId` is a different function (takes `MemberId`, JOINs `group_members` to resolve). It is NOT a duplicate and should be kept. + +**Additional Store/Messages.hs duplications** (minor, collapse with constructor change): +- `createNewRcvChatItem` quoteRow (lines 560-563): `CDGroupRcv` and `CDChannelRcv` branches are verbatim identical +- `getChatItemQuote_` (lines 649-654): `CDChannelRcv` branch is a subset of `CDGroupRcv` (missing sender-specific case) +- `createNewChatItem_` idsRow/groupScope: `CDChannelRcv` branches repeat `CDGroupSnd`-like tuples + +These are inherent to the separate constructor and collapse automatically with the architectural change (see note below). Not worth addressing independently. + +--- + +### Library/Internal.hs + +**D7: `saveRcvChatItem'` CDChannelRcv branches** + +Three duplicate spots within this function, all verbatim copies of CDGroupRcv branches: + +1. **Mentions/userMention computation** (~7 lines): `getRcvCIMentions`, `userReply` via `cmToQuotedMsg`, `userMention'` via membership check. Verbatim identical between CDGroupRcv and CDChannelRcv. + +2. **createGroupCIMentions** (~2 lines): Both branches call `createGroupCIMentions db g ci mentions'` guarded by `not (null mentions')`. Identical. + +3. **memberChatStats / memberAttentionChange** (~3 lines): Only difference is `Just m` vs `Nothing` passed to `memberAttentionChange`. + +Total: ~14 lines of duplication across 3 spots. + +**Solution:** Extract `GroupInfo` and `Maybe GroupMember` from either constructor at the top: +```haskell +case cd of + CDGroupRcv g _s m -> (g, Just m) + CDChannelRcv g _s -> (g, Nothing) +``` +Then use the extracted values for all three spots. The `memberAttentionChange` call already takes `Maybe GroupMember`. + +--- + +**D8: `processContentItem` CIChannelRcv branch** + +Near-duplicate of `CIGroupRcv` branch (lines 1196-1199 vs 1200-1202). Only difference: no `blockedByAdmin` guard, passes `Nothing` instead of `Just sender`. + +**Solution:** Merge the two branches: +```haskell +(CChatItem SMDRcv ci@ChatItem {chatDir, content = CIRcvMsgContent mc, file}) + | maybe True (not . blockedByAdmin) sender_ -> do + fInvDescr_ <- join <$> forM file getRcvFileInvDescr + processContentItem sender_ ci mc fInvDescr_ + where sender_ = case chatDir of CIGroupRcv m -> Just m; CIChannelRcv -> Nothing; _ -> Nothing +``` + +**Additional Internal.hs duplication** (minor): +- `quoteData` (lines 228-229): `CIGroupRcv m` returns `(qmc, CIQGroupRcv $ Just m, False, Just m)`, `CIChannelRcv` returns `(qmc, CIQGroupRcv Nothing, False, Nothing)`. Two one-liners differing only in `Just m` vs `Nothing`. Trivial but noted. + +--- + +### View.hs + +**D9: View.hs pattern match duplication** + +The actual count of `CIChannelRcv` pattern match branches: +- **View.hs**: 6 branches (chatDirNtf, viewChatItem new, viewChatItem updated, reaction display, sentByMember', fileFrom) +- **Terminal/Output.hs**: 1 branch +- **Commands.hs**: 2 branches (itemDeletable, itemsMsgMemIds) +- **Internal.hs**: 2 branches (quoteData, processContentItem) +- **Subscriber.hs**: ~6 branches (scattered) +- **Store/Messages.hs**: ~4 branches (toGroupChatItem, createNewRcvChatItem, createNewChatItem_, getChatItemQuote_) + +Total: **~24 pattern match sites** across all files (~17 `CIChannelRcv` + ~7 `CDChannelRcv`). Each mirrors the corresponding `CIGroupRcv m` / `CDGroupRcv` branch passing `Nothing` instead of `Just m`. + +The `ttyFromGroup*` family of functions in View.hs was correctly generalized to take `Maybe GroupMember` — the duplication is at the call sites, not in the helper functions. + +**Solution:** This duplication is **inherent to the separate constructor choice** and can only be eliminated by the architectural change (merging `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)`). Without that change, the branches must remain. Extracting local helpers at each call site would add complexity without reducing total code. + +--- + +### Other Files (no significant deduplication needed) + +- **Commands.hs:** Parameter threading (`ShowGroupAsSender`, `SRGroup`). Clean, no duplication. +- **Protocol.hs:** Wire protocol changes (`ExtMsgContent.asGroup`, `XGrpMsgForward Maybe MemberId`). Necessary. +- **Delivery.hs:** `FwdSender` type replaces separate fields. Could be `Maybe (MemberId, ContactName)` but not a priority. +- **Store/Files.hs:** `createRcvGroupFileTransfer` takes `Maybe GroupMember`. Clean parameterization. +- **Store/Groups.hs:** `createPreparedGroup` returns `Maybe GroupMember`. Necessary for relay groups. +- **Types.hs:** `sendAsGroup'`, `groupId'` utilities. Minor. + +--- + +## Architectural Note: CIChannelRcv Constructor {#architectural-note} + +The deepest source of duplication is the choice to add `CIChannelRcv` / `CDChannelRcv` as separate constructors rather than parameterizing `CIGroupRcv :: Maybe GroupMember -> CIDirection 'CTGroup 'MDRcv` and `CDGroupRcv :: GroupInfo -> Maybe GroupChatScopeInfo -> Maybe GroupMember -> ChatDirection 'CTGroup 'MDRcv`. + +This creates ~24 pattern match branches across the codebase, almost all passing `Nothing` where `CIGroupRcv` passes `Just m`. The `chatItemMember` function already returns `Maybe GroupMember`, confirming the abstraction is correct. + +**However**, changing these constructors is a large cross-cutting refactor affecting Messages.hs, View.hs, Commands.hs, Internal.hs, Subscriber.hs, Store/Messages.hs, and tests. It may be better suited as a follow-up PR. + +**Decision needed from user:** Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` in this PR, or defer? + +--- + +## Implementation Order + +### Phase 1: Store layer (D6) +1. Parameterize `getGroupChatItemBySharedMsgId` with `Maybe GroupMemberId` + `IS NOT DISTINCT FROM` +2. Delete `getGroupCIBySharedMsgId'` +3. Update all callers (pass `Just gmId` or `Nothing`) + +### Phase 2: Subscriber.hs function merges (D1, D2, D3) +4. Merge `channelMessageUpdate_` into `groupMessageUpdate` (takes `Maybe GroupMember`) +5. Extract shared `updateChatItemReaction` helper from `groupMsgReaction` and `fwdChannelReaction` +6. Merge `newChannelContentMessage_` into `newGroupContentMessage` (extract shared save-view helper or take `Maybe GroupMember`) + +### Phase 3: Dispatch unification (D4) +7. Merge `processForwardedChannelMsg` into `processForwardedMsg` (takes `Maybe GroupMember`; handle `XMsgReact` three-way split) + +### Phase 4: Internal cleanup (D7, D8) +8. Deduplicate `saveRcvChatItem'` CDChannelRcv branches (3 spots) +9. Merge `processContentItem` CIChannelRcv branch + +### Phase 5 (deferred unless approved): Constructor change (D9) +10. Merge `CIChannelRcv` into `CIGroupRcv (Maybe GroupMember)` — eliminates ~24 pattern match branches across all files + +### Phase 6 (optional): channelMessageDelete (D5) +11. Only if user wants it — extend `groupMessageDelete` with `Maybe GroupMember` diff --git a/plans/delivery-context-fix.md b/plans/delivery-context-fix.md new file mode 100644 index 0000000000..4b1b13c30a --- /dev/null +++ b/plans/delivery-context-fix.md @@ -0,0 +1,354 @@ +# Plan: Fix Channel Message Delivery Architecture + +## Table of Contents +1. [Context](#context) +2. [Executive Summary](#executive-summary) +3. [Issue 1: Eliminate memberForChannel/memberIdForChannel](#issue-1) +4. [Issue 2: groupMsgReaction required GroupMember](#issue-2) +5. [Issue 3: Fix groupMessageUpdate lookup](#issue-3) +6. [Issue 4: DeliveryTaskContext type](#issue-4) +7. [Issue 5: Fix testChannelReactionAttribution](#issue-5) +8. [Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment](#issue-6) +9. [Other: sendAsGroup parameter ordering](#other-issue) +10. [Verification](#verification) + +## Context + +The current implementation on `ep/channel-messages-2` determines delivery context (whether to forward messages as channel or as member) using `isChannelOwner` — inferring from the sender's role whether they're the channel owner. This is architecturally wrong: the delivery context should be determined **from the item's direction** (`CIChannelRcv` vs `CIGroupRcv`), not from who sent it. The `f/msg-from-channel` branch has the correct approach. + +## Executive Summary + +7 changes across 7 files: +1. **Delivery.hs** — Add `DeliveryTaskContext` type, update `NewMessageDeliveryTask` only (`MessageDeliveryTask` unchanged) +2. **Subscriber.hs** — Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; all processing functions return `Maybe DeliveryTaskContext`; determine `sentAsGroup` from item direction; `groupMsgReaction` takes required `GroupMember`; add `withAuthor` in forwarded handler +3. **Store/Delivery.hs** — Update SQL row mapping for `taskContext` +4. **Commands.hs** — Reorder `sendAsGroup` param in `APIForwardChatItems` +5. **Store/Messages.hs** — Reorder `showGroupAsSender` param in `createNewSndChatItem` +6. **Internal.hs** — Reorder `showGroupAsSender` param in `saveSndChatItems`, `prepareGroupMsg` +7. **Tests** — Fix reaction test comment/expectations, fix update fallback test comment + +--- + +## Issue 1: Eliminate memberForChannel/memberIdForChannel {#issue-1} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 935-937, 939-991 + +**Problem:** `isChannelOwner`, `memberForChannel`, `memberIdForChannel` computed at lines 935-937 and passed to processing functions. This pre-infers delivery context from member role. + +**Fix:** Remove these three bindings entirely. Always pass `(Just m'')` to functions that take `Maybe GroupMember`. Functions determine `sentAsGroup` from item direction internally. + +**Direct handler changes (lines 939-991):** +``` +-- BEFORE: +let isChannelOwner = useRelays' gInfo' && memberRole' m'' == GROwner + memberForChannel = if isChannelOwner then Nothing else Just m'' + memberIdForChannel = memberId' <$> memberForChannel +(deliveryJobScope_, showGroupAsSender') <- case event of + ... +forM deliveryJobScope_ $ \jobScope -> + pure $ NewMessageDeliveryTask {messageId = msgId, jobScope, showGroupAsSender = showGroupAsSender'} + +-- AFTER: +deliveryTaskContext_ <- case event of + XMsgNew mc -> ... -- returns Maybe DeliveryTaskContext + XMsgFileDescr ... -> groupMessageFileDescription gInfo' (Just m'') sharedMsgId fileDescr + XMsgUpdate ... -> memberCanSend m'' msgScope Nothing $ groupMessageUpdate gInfo' (Just m'') sharedMsgId ... + XMsgDel ... -> groupMessageDelete gInfo' (Just m'') sharedMsgId ... + XMsgReact ... -> groupMsgReaction gInfo' m'' sharedMsgId ... -- required member + XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId + ...other events -> Just <$> memberEventDeliveryContext m'' / Nothing +forM deliveryTaskContext_ $ \taskContext -> + pure $ NewMessageDeliveryTask {messageId = msgId, taskContext} +``` + +**Processing function signature changes:** +- `groupMessageFileDescription :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> FileDescr -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params, pass `Maybe GroupMember`, determine `sentAsGroup` from `chatDir` of found item +- `groupMessageUpdate :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> Maybe Bool -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param +- `groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> ... -> CM (Maybe DeliveryTaskContext)` — drop `senderGMId_` param; fix `findOwnerCI` dual-lookup (lines 2028-2035) same as Issue 3: when `m_ = Nothing` search with `Nothing`, when `m_ = Just m` use member lookup directly +- `xFileCancelGroup :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> CM (Maybe DeliveryTaskContext)` — drop both `Maybe MemberId` params + +**`validSender` simplification:** Remove second `Maybe MemberId` parameter. With `(Just m'')` always passed, validation is just: +```haskell +validSender :: Maybe MemberId -> CIDirection 'CTGroup 'MDRcv -> Bool +validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m +validSender Nothing CIChannelRcv = True +validSender _ _ = False +``` + +**`isChannelDir` helper** remains as-is (line 1870-1872) — used to derive `sentAsGroup` from item's `chatDir`. + +**`memberCanSend`** (line 1436): Generic signature `a -> CM a -> CM a` — no change needed. Default values at call sites change from `(Nothing, False)` to `Nothing`. + +**`memberCanSend'`** (line 1448): Return type changes from `CM (Maybe DeliveryJobScope)` to `CM (Maybe DeliveryTaskContext)`. Used in forwarded handler (lines 3153, 3159). + +--- + +## Issue 2: groupMsgReaction required GroupMember {#issue-2} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` line 1814 + +**Problem:** `groupMsgReaction :: GroupInfo -> Maybe GroupMember -> ...` allows `Nothing`, uses `fromMaybe membership m_` fallback. + +**Fix:** Change to required `GroupMember`: +```haskell +groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) +``` + +- No `reactor` binding needed — use `m` directly (eliminates `fromMaybe membership m_` fallback) +- `ciDir = CIGroupRcv (Just m)` (reactions always attributed to member) +- Always return `sentAsGroup = False` — reactions are never from channel +- Return type: `Maybe DeliveryTaskContext` (not tuple) + +**Direct handler call site (line 958-960):** +```haskell +XMsgReact sharedMsgId memberId scope_ reaction add -> + groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs +``` + +**Forwarded handler call site (line 3162-3163):** +```haskell +XMsgReact sharedMsgId memId_ scope_ reaction add -> + withAuthor XMsgReact_ $ \author -> groupMsgReaction gInfo author sharedMsgId memId_ scope_ reaction add rcvMsg msgTs +``` + +--- + +## Issue 3: Fix groupMessageUpdate lookup {#issue-3} + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 1973-1994 + +**Problem:** Dual-lookup with `catchError` tries `Nothing` first, then falls back to `senderGMId_`. This is wrong — the `asGroup_` flag from XMsgUpdate should drive the search. + +**Fix:** Use `asGroup_` (the wire flag) to determine search strategy. No `senderGMId_` parameter needed: +```haskell +updateRcvChatItem = do + (cci, scopeInfo) <- withStore $ \db -> do + cci <- case m_ of + Just m -> getGroupMemberCIBySharedMsgId db user gInfo (memberId' m) sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user gInfo Nothing sharedMsgId + (cci,) <$> getGroupChatScopeInfoForItem db vr user gInfo (cChatItemId cci) +``` + +When `m_ = Nothing` (channel owner as channel), search with `Nothing` group_member_id → finds channel items. +When `m_ = Just m` (attributed member message), search with member's `memberId` → finds member items. + +The `isSender` check also simplifies — just check `m_` matches the found item's member. + +**Fallback path** (lines 1948-1968, `catchCINotFound`): When item not found, `showGroupAsSender` is derived from `asGroup_` flag (or defaults based on `m_`), which maps to `sentAsGroup` in the `DeliveryTaskContext`. + +--- + +## Issue 4: DeliveryTaskContext type {#issue-4} + +**File:** `src/Simplex/Chat/Delivery.hs` + +### 4a. Add DeliveryTaskContext type +```haskell +data DeliveryTaskContext = DeliveryTaskContext + { jobScope :: DeliveryJobScope, + sentAsGroup :: ShowGroupAsSender + } + deriving (Show) +``` + +Uses existing `type ShowGroupAsSender = Bool` from Messages.hs. + +### 4b. Modify existing helpers +Rename `infoToDeliveryScope` → `infoToDeliveryContext`, inline the scope logic, add `ShowGroupAsSender` parameter: +```haskell +infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext +infoToDeliveryContext GroupInfo {membership} scopeInfo sentAsGroup = DeliveryTaskContext {jobScope, sentAsGroup} + where + jobScope = case scopeInfo of + Nothing -> DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + Just GCSIMemberSupport {groupMember_} -> + let supportGMId = groupMemberId' $ fromMaybe membership groupMember_ + in DJSMemberSupport {supportGMId} +``` +Remove `infoToDeliveryScope` entirely. + +Rename `memberEventDeliveryScope` → `memberEventDeliveryContext`, change return type: +```haskell +memberEventDeliveryContext :: GroupMember -> Maybe DeliveryTaskContext +memberEventDeliveryContext m@GroupMember {memberRole, memberStatus} + | memberStatus == GSMemPendingApproval = Nothing + | memberStatus == GSMemPendingReview = Just $ DeliveryTaskContext {jobScope = DJSMemberSupport {supportGMId = groupMemberId' m}, sentAsGroup = False} + | memberRole >= GRModerator = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = True}}, sentAsGroup = False} + | otherwise = Just $ DeliveryTaskContext {jobScope = DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}, sentAsGroup = False} +``` + +### 4c. Update NewMessageDeliveryTask +```haskell +data NewMessageDeliveryTask = NewMessageDeliveryTask + { messageId :: MessageId, + taskContext :: DeliveryTaskContext + } + deriving (Show) +``` + +### 4d. MessageDeliveryTask — no change + +`MessageDeliveryTask` stays as-is. It's constructed from DB rows in `getMsgDeliveryTask_` and consumed by relay forwarding code — those consumers need `jobScope` and `fwdSender` directly, not `DeliveryTaskContext`. `DeliveryTaskContext` is only for the path from processing functions → `NewMessageDeliveryTask` creation. + +### 4e. Update Store/Delivery.hs + +**`createMsgDeliveryTask`** (line 71-87): Extract `jobScope` and `sentAsGroup` from `taskContext` instead of separate `jobScope`/`showGroupAsSender` fields. + +**`getMsgDeliveryTask_`** — no change needed (`MessageDeliveryTask` unchanged). + +### 4f. Consumers of MessageDeliveryTask — no change needed + +**Subscriber.hs** lines ~3325-3333 and **Messages/Batch.hs** lines ~77-80 already pattern match on `FwdSender` and use `jobScope` from `MessageDeliveryTask`. Since `MessageDeliveryTask` is unchanged, no updates needed. + +### 4g. Return type changes in processing functions + +All functions currently returning `(Maybe DeliveryJobScope, ShowGroupAsSender)` change to `Maybe DeliveryTaskContext`: +- `groupMessageFileDescription` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageUpdate` → `CM (Maybe DeliveryTaskContext)` +- `groupMessageDelete` → `CM (Maybe DeliveryTaskContext)` +- `xFileCancelGroup` → `CM (Maybe DeliveryTaskContext)` +- `groupMsgReaction` → `CM (Maybe DeliveryTaskContext)` + +Events that return `(Nothing, False)` or `(Just scope, False)` are updated: +- `(Nothing, False)` → `Nothing` +- `(Just scope, False)` → `Just $ DeliveryTaskContext scope False` (or use `memberEventDeliveryContext`) +- `(Just scope, showGroupAsSender)` → `Just $ DeliveryTaskContext scope showGroupAsSender` (or use `infoToDeliveryContext`) + +--- + +## Issue 5: Fix testChannelReactionAttribution {#issue-5} + +**File:** `tests/ChatTests/Groups.hs` lines 9057-9084 + +**Problem:** Comment says "reaction is forwarded as channel (owner is anonymous)" and expects `#team>`. Owner should react **as member** — reactions are always `sentAsGroup = False`. + +**Fix:** Change comment and expectations: +```haskell +-- owner reacts to own member message - reaction is forwarded as member +alice ##> "+1 #team hello" +alice <## "added 👍" +bob <# "#team alice> > alice hello" +bob <## " + 👍" +concurrentlyN_ + [ do cath <# "#team alice> > alice hello" + cath <## " + 👍", + do dan <# "#team alice> > alice hello" + dan <## " + 👍", + do eve <# "#team alice> > alice hello" + eve <## " + 👍" + ] +``` + +--- + +## Issue 6: Fix testChannelUpdateFallbackSendAsGroup comment {#issue-6} + +**File:** `tests/ChatTests/Groups.hs` line 9127 + +**Problem:** Comment says "bob's internally deleted item is still in DB, update finds it with correct member direction". This is wrong — the item was internally deleted, then XMsgUpdate re-creates it via the `catchCINotFound` fallback. + +**Fix:** Change comment to: +```haskell +-- bob's internally deleted item is re-created as from member (sendAsGroup=False) +``` + +--- + +## Other: sendAsGroup parameter ordering {#other-issue} + +**Problem:** `sendAsGroup`/`ShowGroupAsSender` should come right after direction/scope, not at the end. + +### 7a. `APIForwardChatItems` constructor + +**File:** `src/Simplex/Chat/Library/Commands.hs` (ChatCommand type definition + parser) + +Current: `APIForwardChatItems toChat fromChat itemIds itemTTL sendAsGroup` +New: `APIForwardChatItems toChat sendAsGroup fromChat itemIds itemTTL` + +Affects: +- Constructor definition in `src/Simplex/Chat/Controller.hs` line 341 +- Parser at line 4639 +- Call sites at lines 930, 2192, 2198, 2204 + +### 7b. `createNewSndChatItem` + +**File:** `src/Simplex/Chat/Store/Messages.hs` line 528 + +Current: `createNewSndChatItem db user chatDirection msg ciContent quotedItem itemForwarded timed live hasLink showGroupAsSender createdAt` +New: `createNewSndChatItem db user chatDirection showGroupAsSender msg ciContent quotedItem itemForwarded timed live hasLink createdAt` + +Move `showGroupAsSender` right after `chatDirection` (direction context). + +Affects call site in `Internal.hs` line 2276. + +### 7c. `saveSndChatItems` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 2256-2265 + +Current param order: `user -> cd -> itemsData -> itemTimed -> live -> showGroupAsSender` +New: `user -> cd -> showGroupAsSender -> itemsData -> itemTimed -> live` + +Move `showGroupAsSender` right after `cd` (direction context). + +Affects call sites: Internal.hs line 2242, Commands.hs lines 2561, 2608 (and the `saveSndChatItem'` wrapper at line 2240). + +### 7d. `prepareGroupMsg` + +**File:** `src/Simplex/Chat/Library/Internal.hs` line 203 + +Current: `prepareGroupMsg db user gInfo msgScope mc mentions quotedItemId_ itemForwarded fInv_ timed_ live showGroupAsSender` +New: `prepareGroupMsg db user gInfo msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live` + +Move `showGroupAsSender` right after `msgScope` (scope context). + +Affects call sites: Internal.hs line 1249, Commands.hs line 4094. + +--- + +## Forwarded handler (xGrpMsgForward) changes + +**File:** `src/Simplex/Chat/Library/Subscriber.hs` lines 3136-3173 + +Add `withAuthor` helper to replace ad-hoc `| Just author <- author_` guards: +```haskell +where + withAuthor :: CMEventTag e -> (GroupMember -> CM ()) -> CM () + withAuthor tag action = case author_ of + Just author -> action author + Nothing -> messageError $ "x.grp.msg.forward: event " <> tshow tag <> " requires author" +``` + +Update forwarded event handling: +- `XMsgFileDescr` → pass `author_` (Maybe GroupMember) directly +- `XMsgUpdate` → pass `author_` directly, void result +- `XMsgDel` → pass `author_` directly, void result +- `XMsgReact` → use `withAuthor` (required member) +- `XFileCancel` → pass `author_` directly +- Other events with `| Just author <- author_` → use `withAuthor` + +--- + +## Files Modified + +| File | Changes | +|------|---------| +| `src/Simplex/Chat/Delivery.hs` | Add `DeliveryTaskContext`, update `NewMessageDeliveryTask` only | +| `src/Simplex/Chat/Store/Delivery.hs` | Update `createMsgDeliveryTask` to extract from `taskContext` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Eliminate `isChannelOwner`/`memberForChannel`/`memberIdForChannel`; change function signatures to return `Maybe DeliveryTaskContext`; add `withAuthor`; simplify `validSender`; `groupMsgReaction` required member; fix lookup | +| `src/Simplex/Chat/Controller.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` constructor | +| `src/Simplex/Chat/Library/Commands.hs` | Reorder `sendAsGroup` in `APIForwardChatItems` parser + call sites | +| `src/Simplex/Chat/Store/Messages.hs` | Reorder `showGroupAsSender` in `createNewSndChatItem` | +| `src/Simplex/Chat/Library/Internal.hs` | Reorder `showGroupAsSender` in `saveSndChatItems`, `prepareGroupMsg` | +| `src/Simplex/Chat/Messages/Batch.hs` | No change needed (`MessageDeliveryTask` unchanged) | +| `tests/ChatTests/Groups.hs` | Fix reaction test expectations + update fallback comment | + +--- + +## Verification + +1. `cabal build --ghc-options=-O0` — must compile clean +2. Run channel test suite: `cabal test simplex-chat-test --test-option='-m "channels"' --ghc-options=-O0` +3. Adversarial self-review loop until 2 consecutive clean passes +4. Verify no `isChannelOwner` references remain in Subscriber.hs direct handler +5. Verify `groupMsgReaction` signature has required `GroupMember` (no Maybe) +6. Verify no dual-lookup with `catchError` in `groupMessageUpdate` diff --git a/plans/group_channel_feature_coverage.md b/plans/group_channel_feature_coverage.md new file mode 100644 index 0000000000..f4b6e49353 --- /dev/null +++ b/plans/group_channel_feature_coverage.md @@ -0,0 +1,377 @@ +# Group & Channel Feature Test Coverage Plan + +## Table of Contents + +1. [Executive Summary](#executive-summary) +2. [Feature Coverage Matrix](#feature-coverage-matrix) +3. [Gap Analysis by Category](#gap-analysis-by-category) +4. [Recommended New Tests](#recommended-new-tests) +5. [Implementation Roadmap](#implementation-roadmap) + +--- + +## Executive Summary + +**Current State:** The test suite in `Groups.hs` provides comprehensive coverage across 120+ scenarios in 14 categories. Core functionality (group CRUD, messaging, member management) is well-tested. + +**Key Gaps Identified:** +- Business/contact card group links (untested invitation flow) +- Legacy group link auto-accept path +- Permission enforcement for `SGFFullDelete` +- Error recovery paths (file transfers, database busy, duplicate forwarding) +- Moderator-only scoped message delivery (`DJSMemberSupport`) +- Edge cases in channel message deletion + +**Risk Assessment:** +| Priority | Gap Count | Impact | +|----------|-----------|--------| +| Critical | 3 | Production failures in business flows | +| High | 5 | Feature regressions possible | +| Medium | 4 | Edge case handling incomplete | + +**Recommendation:** Add 12 new test scenarios in 3 phases over 2 sprints. + +--- + +## Feature Coverage Matrix + +### Legend +- ✅ Tested (comprehensive) +- ⚠️ Partial (some paths covered) +- ❌ Untested + +### Core Group Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Group creation | ✅ | `testGroup` | Basic + edge cases | +| Group deletion | ✅ | `testGroupDelete*` | Multiple scenarios | +| Group naming/description | ✅ | `testUpdateGroupProfile` | | +| Group preferences | ✅ | `testGroupPreferences` | Voice, files, etc. | +| Group link creation | ✅ | `testGroupLink*` | | +| Group link via contact card | ❌ | - | Business links untested | +| Legacy auto-accept | ❌ | - | Deprecated path | + +### Message Operations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| XMsgNew (send) | ✅ | Multiple | Core flow | +| XMsgUpdate (edit) | ✅ | `testGroupMessageUpdate` | | +| XMsgDel (delete) | ✅ | `testGroupMessageDelete` | | +| XMsgReact | ✅ | `testGroupMsgReaction` | | +| XMsgFileDescr | ✅ | `testGroupFileTransfer` | | +| Batch messages | ✅ | `testBatch*` | | +| Live messages | ✅ | `testGroupLiveMessage` | | +| Quote messages | ✅ | `testGroup*Quote*` | | +| Duplicate forwarding | ❌ | - | De-dup logic untested | + +### Member Management + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Member add | ✅ | `testGroupAddMember*` | | +| Member remove | ✅ | `testGroupRemoveMember*` | | +| Member roles | ✅ | `testGroupMemberRole*` | | +| Member blocking | ✅ | `testGroupBlock*` | | +| Member merging | ✅ | `testMergeMemberContact*` | | +| Member deletion errors | ❌ | - | Error paths missing | +| Contact from member | ✅ | `testCreateMemberContact*` | | + +### Moderation & Full Delete + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Moderate message | ✅ | `testGroupModerate*` | | +| Block for all | ✅ | `testGroupBlockForAll*` | | +| SGFFullDelete enabled | ✅ | `testFullDeleteGroup*` | | +| SGFFullDelete restricted | ❌ | - | Permission checks | + +### Channels & Relays + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| 1-relay delivery | ✅ | `testChannel1Relay*` | | +| 2-relay delivery | ✅ | `testChannel2Relay*` | | +| Owner-only sending | ✅ | `testChannel*Message*` | | +| Identity protection | ✅ | `testChannel*Incognito*` | | +| Channel msg delete errors | ❌ | - | Invalid state handling | + +### Scoped Messages (Support Chats) + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Single moderator | ✅ | `testSupportChat*` | | +| Multi moderator | ✅ | `testSupportChat*Multi*` | | +| Member reports | ✅ | `testReportMessage*` | | +| Forwarding in scope | ✅ | `testSupportChatForward*` | | +| Stats | ✅ | `testSupportChatStats` | | +| DJSMemberSupport delivery | ❌ | - | Moderator-only path | + +### Group Links & Invitations + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| Create/delete link | ✅ | `testGroupLink*` | | +| Join via link | ✅ | `testGroupLink*` | | +| Link screening | ✅ | `testGroupLink*Screening*` | | +| Connection plans | ✅ | `testPlanGroupLink*` | | +| Short links | ✅ | `testGroupShortLink*` | | +| Business link invitation | ❌ | - | Contact card flow | + +### Error Handling + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| CEGroupNotJoined | ⚠️ | Implicit | Some coverage | +| CEGroupMemberNotFound | ⚠️ | Implicit | Some coverage | +| File transfer errors | ❌ | - | Recovery paths | +| Database busy | ❌ | - | Retry logic | +| Simplex link warnings | ❌ | - | Feature gate | + +### History & Disappearing + +| Feature | Status | Test Location | Notes | +|---------|--------|---------------|-------| +| History on join | ✅ | `testGroupHistory*` | | +| File history | ✅ | `testGroupHistoryFiles` | | +| Disappearing messages | ✅ | `testGroupHistoryDisappear*` | | + +--- + +## Gap Analysis by Category + +### Critical Priority (Production Impact) + +#### 1. Business Group Link via Contact Card +**Location:** `APIAddMember` with `InvitationContact` path +**Risk:** Business users cannot invite via contact cards +**Current State:** Only `InvitationMember` path tested +**Missing Coverage:** +- `processGroupInvitation` with `CTContactRequest` +- Auto-accept flow for business links +- Profile merge on business join + +#### 2. SGFFullDelete Permission Enforcement +**Location:** `canFullDelete`, `checkFullDeleteAllowed` +**Risk:** Non-admins might delete others' messages +**Missing Coverage:** +- `SGFFullDelete` set to `FAAdmins` restriction +- Error `CECommandError` when non-admin attempts full delete +- Role-based permission matrix + +#### 3. DJSMemberSupport Delivery Path +**Location:** `deliverGroupMessages`, `groupMsgDeliveryJobs` +**Risk:** Support messages not reaching moderators correctly +**Missing Coverage:** +- `DJSMemberSupport` job creation +- Moderator-only broadcast logic +- Scope isolation verification + +### High Priority (Feature Regressions) + +#### 4. Channel Message Deletion Errors +**Location:** `apiDeleteMemberChatItem`, `deleteGroupChatItemInternal` +**Missing Coverage:** +- Delete non-existent channel message +- Delete by non-owner in channel +- `CEInvalidChatItemDelete` error path + +#### 5. Member Deletion Error Paths +**Location:** `removeMemberDeleteItem`, `deleteGroupChatItem` +**Missing Coverage:** +- Delete item for already-removed member +- Concurrent deletion race condition +- `CEGroupMemberNotFound` specific handling + +#### 6. File Transfer Error Recovery +**Location:** `rcvFileError`, `sndFileError` +**Missing Coverage:** +- Partial transfer resume +- `CEFileTransferError` handling +- Cleanup on failed transfers + +#### 7. Legacy Group Link Auto-Accept +**Location:** `processGroupInvitation`, `autoAcceptGroupLink` +**Risk:** Breaking change for older clients +**Missing Coverage:** +- V1 protocol compatibility +- Auto-accept timing + +#### 8. Duplicate Message Forwarding +**Location:** `forwardGroupMessage`, `checkDuplicateForward` +**Missing Coverage:** +- Same message forwarded twice +- De-duplication by `sharedMsgId` +- UI state consistency + +### Medium Priority (Edge Cases) + +#### 9. Simplex Links Feature Warnings +**Location:** `simplexLinkWarning`, `SGFSimplexLinks` +**Missing Coverage:** +- Warning when feature disabled +- Link detection in messages +- User preference override + +#### 10. Database Busy Error Handling +**Location:** `withTransaction`, `retryOnBusy` +**Missing Coverage:** +- Concurrent group operations +- Retry exhaustion +- State consistency after retry + +#### 11. Invalid Channel/Member Scope Errors +**Location:** `validateGroupChatScope`, `scopeNotAllowed` +**Missing Coverage:** +- Member sending to wrong scope +- Scope mismatch on receive +- `CECommandError "scope not allowed"` path + +#### 12. Contact Card Profile Merge +**Location:** `mergeMemberContactProfile`, `updateContactProfile` +**Missing Coverage:** +- Profile conflict resolution +- Image merge logic +- Display name precedence + +--- + +## Recommended New Tests + +### Phase 1: Critical (Sprint 1) + +```haskell +-- Test 1: Business Group Link Invitation +testBusinessGroupLinkInvitation :: HasCallStack => TestParams -> IO () +-- Covers: InvitationContact path, CTContactRequest, auto-accept + +-- Test 2: Full Delete Permission Restriction +testFullDeletePermissionRestricted :: HasCallStack => TestParams -> IO () +-- Covers: SGFFullDelete FAAdmins, non-admin rejection, CECommandError + +-- Test 3: Moderator-Only Support Delivery +testSupportChatModeratorOnlyDelivery :: HasCallStack => TestParams -> IO () +-- Covers: DJSMemberSupport, moderator broadcast, scope isolation +``` + +### Phase 2: High (Sprint 1-2) + +```haskell +-- Test 4: Channel Message Delete Errors +testChannelMessageDeleteErrors :: HasCallStack => TestParams -> IO () +-- Covers: non-existent delete, non-owner delete, CEInvalidChatItemDelete + +-- Test 5: Member Deletion Error Paths +testMemberDeletionErrorPaths :: HasCallStack => TestParams -> IO () +-- Covers: removed member delete, concurrent delete, CEGroupMemberNotFound + +-- Test 6: File Transfer Error Recovery +testGroupFileTransferErrorRecovery :: HasCallStack => TestParams -> IO () +-- Covers: partial resume, CEFileTransferError, cleanup + +-- Test 7: Legacy Group Link Compatibility +testLegacyGroupLinkAutoAccept :: HasCallStack => TestParams -> IO () +-- Covers: V1 protocol, auto-accept timing + +-- Test 8: Duplicate Forward Prevention +testDuplicateMessageForwardPrevention :: HasCallStack => TestParams -> IO () +-- Covers: duplicate detection, sharedMsgId, UI consistency +``` + +### Phase 3: Medium (Sprint 2) + +```haskell +-- Test 9: Simplex Links Feature Warning +testSimplexLinksFeatureWarning :: HasCallStack => TestParams -> IO () +-- Covers: disabled feature warning, link detection + +-- Test 10: Database Busy Retry +testGroupOperationsDatabaseBusy :: HasCallStack => TestParams -> IO () +-- Covers: concurrent ops, retry logic, state consistency + +-- Test 11: Scope Validation Errors +testGroupChatScopeValidationErrors :: HasCallStack => TestParams -> IO () +-- Covers: wrong scope send, scope mismatch, CECommandError + +-- Test 12: Contact Card Profile Merge +testMemberContactProfileMerge :: HasCallStack => TestParams -> IO () +-- Covers: conflict resolution, image merge, name precedence +``` + +--- + +## Implementation Roadmap + +### Sprint 1 (Week 1-2) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 1: Business link | - | PR ready | +| 3-4 | Test 2: Full delete perms | - | PR ready | +| 5 | Test 3: Moderator delivery | - | PR ready | +| 6-7 | Test 4: Channel delete errors | - | PR ready | +| 8-9 | Test 5: Member delete errors | - | PR ready | +| 10 | Integration + Review | - | Merged | + +### Sprint 2 (Week 3-4) + +| Day | Task | Owner | Deliverable | +|-----|------|-------|-------------| +| 1-2 | Test 6: File error recovery | - | PR ready | +| 3-4 | Test 7: Legacy link compat | - | PR ready | +| 5-6 | Test 8: Duplicate forward | - | PR ready | +| 7-8 | Tests 9-12: Medium priority | - | PR ready | +| 9-10 | Final integration + CI | - | Release | + +### Dependencies + +``` +Test 1 (Business Link) ─┬─> Test 12 (Profile Merge) + │ +Test 3 (Moderator) ─────┴─> Test 11 (Scope Validation) + +Test 4 (Channel Delete) ──> Test 5 (Member Delete) + +Test 6 (File Error) ──────> (standalone) + +Test 7 (Legacy Link) ─────> Test 1 (Business Link) + +Test 8 (Duplicate) ───────> (standalone) + +Tests 9, 10 ──────────────> (standalone) +``` + +### Success Criteria + +1. **Coverage Target:** 95%+ of identified gaps covered +2. **CI Integration:** All tests in nightly suite +3. **Documentation:** Test rationale in docstrings +4. **No Regressions:** Existing 120+ tests still pass + +### Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| Test flakiness | Use explicit waits, avoid timing assumptions | +| Database state leaks | Ensure proper cleanup in each test | +| Protocol version issues | Test both V1 and V2 where applicable | +| CI timeout | Parallelize independent tests | + +--- + +## Appendix: Test File Locations + +| Test Category | Primary File | Secondary | +|---------------|--------------|-----------| +| Group Core | `tests/ChatTests/Groups.hs` | - | +| Channels | `tests/ChatTests/Groups.hs` | `Channels/` if split | +| Support Chats | `tests/ChatTests/Groups.hs` | `ScopedMessages/` if split | +| File Transfers | `tests/ChatTests/Files.hs` | `Groups.hs` | +| Error Handling | Inline with feature tests | - | + +--- + +*Generated: 2026-02-06* +*Branch: ep/channel-messages-2* +*Coverage baseline: 120+ scenarios, 14 categories* diff --git a/plans/groups_coverage_fill_plan.md b/plans/groups_coverage_fill_plan.md new file mode 100644 index 0000000000..ffe0b7a52c --- /dev/null +++ b/plans/groups_coverage_fill_plan.md @@ -0,0 +1,368 @@ +# Plan: Filling Group/Channel Test Coverage Gaps + +## Table of Contents +1. [Executive Summary](#executive-summary) +2. [Test File Organization](#test-file-organization) +3. [Priority 0: Critical Channel Paths](#priority-0-critical-channel-paths) +4. [Priority 1: Error and Fallback Paths](#priority-1-error-and-fallback-paths) +5. [Priority 2: Scope-Related Features](#priority-2-scope-related-features) +6. [Priority 3: Feature Restrictions](#priority-3-feature-restrictions) + +--- + +## Executive Summary + +This plan addresses the coverage gaps identified in `groups_test_coverage.md`, focusing exclusively on DSL-based scenario tests using the existing test infrastructure. All tests follow patterns established in `tests/ChatTests/Groups.hs`. + +**Excluded from scope:** JSON serialization tests (per user request). + +**Key gap categories:** +- Non-channel-owner members sending in channel groups +- Moderation/delete paths in channels (`memberDelete`) +- Error fallback paths (`catchCINotFound`) +- Member support scope (`GCSIMemberSupport`) +- Full-delete feature, live updates, mentions + +--- + +## Test File Organization + +All new tests go in `tests/ChatTests/Groups.hs` under existing or new `describe` blocks. + +### New `describe` blocks to add: + +```haskell +describe "channel moderation" $ do + -- Tests for memberDelete path, channel moderation errors + +describe "channel error paths" $ do + -- Tests for catchCINotFound, invalid sender, etc. + +describe "channel mentions" $ do + -- Tests for mentions in channel messages + +describe "group full delete feature" $ do + -- Tests for SGFFullDelete enabled +``` + +--- + +## Priority 0: Critical Channel Paths + +### Test 1: `testChannelMemberModerate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel moderation"` + +**Objective:** Cover `memberDelete` path in `groupMessageDelete` (lines 2016-2076) - moderation of channel messages by admin/owner. + +**Scenario:** +1. Create channel with owner (alice) + relay (bob) + members (cath, dan) +2. Owner sends channel message +3. Admin/owner moderates (deletes) the channel message +4. Verify message marked deleted for all members +5. Verify moderation event is forwarded + +**Coverage targets:** +- `memberDelete` function execution +- `moderate` helper with role checks +- `delete` with `delMember_` populated + +--- + +### Test 2: `testChannelMemberDeleteError` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover error path `CIChannelRcv -> messageError "x.msg.del: unexpected channel message in member delete"` (line 2036). + +**Scenario:** +1. Create channel with owner + relay + member +2. Attempt to trigger memberDelete on CIChannelRcv item (malformed delete request) +3. Verify error is logged/handled correctly + +**Coverage targets:** +- Line 2036: `CIChannelRcv` error case in `memberDelete` + +--- + +### Test 3: `testChannelUpdateNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMessageUpdate` (lines 1950-1969) - update arrives for locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner updates the message +5. Verify member creates new item from update (fallback path) + +**Coverage targets:** +- Line 1960: `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` +- Lines 1951-1969: create-from-update fallback + +--- + +### Test 4: `testChannelReactionNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `catchCINotFound` fallback in `groupMsgReaction` (lines 1823-1837) - reaction on locally deleted item. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message, member receives +3. Member locally deletes the message +4. Owner adds reaction +5. Verify reaction is handled without crash + +**Coverage targets:** +- Lines 1835-1837: channel reaction fallback + +--- + +### Test 5: `testChannelForwardedMessages` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "relay delivery"` (existing) + +**Objective:** Cover `FwdChannel` branch in delivery task (line 3311) and forwarded message parameters. + +**Scenario:** +1. Create channel with owner + 2 relays + members +2. Send various message types (new, update, delete, reaction) +3. Verify all are forwarded through relay chain +4. Check forwarded parameters are correctly passed + +**Coverage targets:** +- Line 3311: `FwdChannel -> (Nothing, Nothing)` +- Lines 3139-3145: forwarded message handlers + +--- + +## Priority 1: Error and Fallback Paths + +### Test 6: `testGroupDeleteNotFound` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` or existing moderation tests + +**Objective:** Cover delete error when message not found (line 2039). + +**Scenario:** +1. Create group with alice, bob +2. Bob sends message +3. Alice locally deletes it +4. Bob broadcasts delete for the same message +5. Verify error path is handled + +**Coverage targets:** +- Line 2039: `messageError ("x.msg.del: message not found, " <> tshow e)` + +--- + +### Test 7: `testGroupInvalidSenderUpdate` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel error paths"` + +**Objective:** Cover `validSender _ _ = False` (line 1874) and update from wrong member error (line 1980). + +**Scenario:** +1. Create group with alice, bob, cath +2. Bob sends message +3. Cath (with spoofed member ID) attempts to update bob's message +4. Verify error is thrown + +**Coverage targets:** +- Line 1874: `validSender _ _ = False` +- Line 1980: `messageError "x.msg.update: group member attempted to update..."` + +--- + +### Test 8: `testGroupReactionDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing `describe "group message reactions"` + +**Objective:** Cover reaction disabled path (line 1839). + +**Scenario:** +1. Create group with reactions feature disabled +2. Member attempts to add reaction +3. Verify reaction is rejected + +**Coverage targets:** +- Line 1839: `otherwise = pure Nothing` when reactions not allowed + +--- + +### Test 9: `testChannelItemNotChanged` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "channel message operations"` (existing) + +**Objective:** Cover `CEvtChatItemNotChanged` path (lines 2001-2002) - update with same content. + +**Scenario:** +1. Create channel with owner + relay + member +2. Owner sends message +3. Owner "updates" message with identical content +4. Verify no change event is emitted + +**Coverage targets:** +- Lines 2001-2002: `CEvtChatItemNotChanged` path + +--- + +## Priority 2: Scope-Related Features + +### Test 10: `testScopedSupportMentions` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover mentions in scoped support messages (`getRcvCIMentions` with non-empty mentions). + +**Scenario:** +1. Create group with alice (owner), bob (member), dan (moderator) +2. Bob sends support message mentioning @alice +3. Alice receives with mention highlighted +4. Verify `userMention` flag is set correctly + +**Coverage targets:** +- Line 2316: `getRcvCIMentions` with actual mentions +- Line 2319: `sameMemberId mId membership` in userReply check +- Lines 279-281: `uniqueMsgMentions` path + +--- + +### Test 11: `testMemberChatStats` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `memberChatStats` function (lines 2323-2330) for both `CDGroupRcv` and `CDChannelRcv` with scope. + +**Scenario:** +1. Create group with support enabled +2. Member sends support message +3. Verify unread stats are updated +4. Verify `memberAttentionChange` is computed + +**Coverage targets:** +- Lines 2325-2329: `memberChatStats` branches +- Line 2621: `memberAttentionChange` + +**Note:** Tests `testScopedSupportUnreadStatsOnRead` and `testScopedSupportUnreadStatsOnDelete` exist but may not cover all branches. + +--- + +### Test 12: `testMkGetMessageChatScope` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group scoped messages"` (existing) + +**Objective:** Cover `mkGetMessageChatScope` branches (lines 1599-1617). + +**Scenario:** +1. Create group with pending member (knocking) +2. Pending member sends message with scope +3. Verify correct scope resolution +4. Test with `isReport mc` content type + +**Coverage targets:** +- Line 1601: `Just _scopeInfo` return +- Line 1604: `isReport mc` branch +- Lines 1610-1617: `sameMemberId` and `otherwise` branches + +--- + +## Priority 3: Feature Restrictions + +### Test 13: `testGroupFullDelete` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** new `describe "group full delete feature"` + +**Objective:** Cover `groupFeatureAllowed SGFFullDelete` = True path (line 2067) - `deleteGroupCIs` instead of `markGroupCIsDeleted`. + +**Scenario:** +1. Create group with full delete enabled: `/set delete #team on` +2. Bob sends message +3. Alice (or bob) deletes message +4. Verify message is fully deleted (not just marked) + +**Coverage targets:** +- Line 2067: `deleteGroupCIs` path +- `groupFeatureAllowed SGFFullDelete` returns True + +--- + +### Test 14: `testGroupLiveMessage` +**File:** `tests/ChatTests/Groups.hs` +**Note:** `testGroupLiveMessage` exists but may not cover update path. + +**Objective:** Cover live message update path (line 830 in View.hs, `itemLive == Just True`). + +**Scenario:** +1. Create group +2. Send live message +3. Update live message content +4. Verify live update is processed + +**Coverage targets:** +- Line 830: `itemLive == Just True && not liveItems -> []` +- Live update in `groupMessageUpdate` + +--- + +### Test 15: `testGroupVoiceDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** existing tests or new `describe "group feature restrictions"` + +**Objective:** Cover voice message rejection (line 342 in Internal.hs). + +**Scenario:** +1. Create group with voice disabled: `/set voice #team off` +2. Member attempts to send voice message +3. Verify rejection + +**Coverage targets:** +- Line 342: `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` + +--- + +### Test 16: `testGroupReportsDisabled` +**File:** `tests/ChatTests/Groups.hs` +**Add to:** `describe "group member reports"` (existing) + +**Objective:** Cover reports disabled path (line 344 in Internal.hs). + +**Scenario:** +1. Create group with reports disabled +2. Member attempts to send report +3. Verify rejection + +**Coverage targets:** +- Line 344: `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` + +--- + +## Implementation Order + +1. **Phase 1 (P0):** Tests 1-5 - Critical channel paths +2. **Phase 2 (P1):** Tests 6-9 - Error and fallback paths +3. **Phase 3 (P2):** Tests 10-12 - Scope-related features +4. **Phase 4 (P3):** Tests 13-16 - Feature restrictions + +Each test should: +- Use existing DSL operators (`##>`, `<#`, `#$>`, etc.) +- Follow naming convention `test` +- Include `HasCallStack` constraint +- Use appropriate test helpers (`createGroup2`, `createChannel1Relay`, etc.) + +--- + +## Dependencies + +- Existing test infrastructure in `ChatTests.Utils` +- Helper functions: `createChannel1Relay`, `createGroup2`, `createGroup3`, etc. +- DSL operators for assertions + +## Estimated New Tests: 16 + +## Files Modified: 1 +- `tests/ChatTests/Groups.hs` diff --git a/plans/groups_test_coverage.md b/plans/groups_test_coverage.md new file mode 100644 index 0000000000..7ee01f1d6f --- /dev/null +++ b/plans/groups_test_coverage.md @@ -0,0 +1,441 @@ +# Group/Channel Test Coverage Analysis + +Coverage run: `cabal test simplex-chat-test --enable-coverage --ghc-options=-O0 --test-options="-m group"` + +Full 164 group tests executed (151 passed, 13 failed due to unrelated issues). + +## Coverage Summary + +After running all group tests: +- Expressions: 48% +- Alternatives: 33% +- Local declarations: 64% +- Top-level: 34% + +--- + +## What IS Covered (Channel-Specific Paths) + +- `createNewRcvChatItem` with `CDChannelRcv` - channel message creation +- `toGroupChatItem` with `showGroupAsSender = True` - channel message reading +- `validSender Nothing CIChannelRcv = True` - channel sender validation +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId (`IS NOT DISTINCT FROM`) +- `toCIDirection CDChannelRcv -> CIChannelRcv` +- `toChatInfo CDChannelRcv g s -> GroupChat g s` +- `chatItemMember CIChannelRcv -> Nothing` +- `viewChatItem` for both `CIGroupRcv` and `CIChannelRcv` +- `viewItemReaction` dispatch to `groupReaction` for both constructors +- Channel delete happy path (`channelDelete` -> `delete Nothing`) + +--- + +## Uncovered Code Paths + +### 1. Subscriber.hs + +#### `processGroupMessage` dispatch (lines 935-972) + +| Line | Code | Status | +|------|------|--------| +| 956 | `asGroup == Just True && memberRole' m'' < GROwner` | tickonlyfalse - rejecting non-owner sending as group never tested | +| 963 | `ttl` parameter in `groupMessageUpdate` | nottickedoff | +| 965 | `scope_` parameter in `groupMsgReaction` | nottickedoff | +| 967 | `XFile` handler | nottickedoff | +| 970 | `XFileAcptInv` handler | nottickedoff | +| 987 | `XGrpPrefs` handler | nottickedoff | +| 993 | `BFileChunk` handler | nottickedoff | +| 994 | Catch-all `_` for unsupported messages | nottickedoff | + +#### `memberCanSend` / `memberCanSend'` (lines 1446-1454) + +| Line | Code | Status | +|------|------|--------| +| 1449 | `memberPending m` part of condition | tickonlytrue - never false | +| 1450 | `otherwise` branch (error "member is not allowed to send messages") | nottickedoff | + +#### `newGroupContentMessage` (lines 1876-1940) + +| Line | Code | Status | +|------|------|--------| +| 1879 | `vr` parameter in `mkGetMessageChatScope` | nottickedoff | +| 1882 | `ft_` and `False` parameters to `prohibitedGroupContent` | nottickedoff | +| 1883 | `rejected` helper invocation | nottickedoff | +| 1895 | `mentions` parameter for channel messages | nottickedoff | +| 1896 | `pure []` for reactions when `sharedMsgId_` is Nothing | nottickedoff | +| 1901 | `rejected` function body | nottickedoff | +| 1902 | `Just Nothing` for timed_ when forwarded | nottickedoff | +| 1910 | `M.empty` for mentions when blocked | tickonlyfalse | +| 1914 | `gInfo'` and `m'` params to `processFileInv` | nottickedoff | + +#### `groupMessageUpdate` (lines 1943-2002) + +| Line | Code | Status | +|------|------|--------| +| 1960 | `Nothing -> pure (CDChannelRcv gInfo Nothing, M.empty, Nothing)` | nottickedoff - channel catchCINotFound | +| 1967 | `CDChannelRcv {} -> pure ci'` | nottickedoff | +| 1977 | `mentions' = if memberBlocked m then []` | tickonlyfalse | +| 1980 | `otherwise -> messageError "x.msg.update: group member attempted to update..."` | nottickedoff | +| 1984 | `messageError "x.msg.update: invalid message update"` | nottickedoff | +| 2001-2002 | `CEvtChatItemNotChanged` path | nottickedoff | + +#### `groupMessageDelete` (lines 2004-2076) + +**channelDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2013 | `messageError "x.msg.del: invalid channel message delete"` | nottickedoff | +| 2015 | `messageError ("x.msg.del: channel message not found, " <> tshow e)` | nottickedoff | + +**memberDelete path:** +| Line | Code | Status | +|------|------|--------| +| 2028 | `messageError "x.msg.del: member attempted invalid message delete"` | tickonlyfalse | +| 2036 | `CIChannelRcv -> messageError "x.msg.del: unexpected channel message..."` | nottickedoff | +| 2039 | `messageError ("x.msg.del: message not found, " <> tshow e)` | tickonlyfalse | +| 2041-2042 | `messageError "...message of another member with insufficient..."` | tickonlyfalse | +| 2044-2047 | `createCIModeration` scoped moderation path | nottickedoff | + +**moderate helper:** +| Line | Code | Status | +|------|------|--------| +| 2058 | `messageError "x.msg.del: message of another member with incorrect memberId"` | nottickedoff | +| 2059 | `messageError "x.msg.del: message of another member without memberId"` | nottickedoff | +| 2062 | `messageError "...insufficient member permissions"` | tickonlyfalse | + +#### `groupMsgReaction` (lines 1818-1860) + +| Line | Code | Status | +|------|------|--------| +| 1823-1837 | Entire `catchCINotFound` fallback | nottickedoff | +| 1825-1831 | Scoped reaction path for member with scope | nottickedoff | +| 1832-1834 | Regular group reaction when item not found | nottickedoff | +| 1835-1837 | Channel reaction when item not found | nottickedoff | +| 1839 | `otherwise = pure Nothing` when reactions not allowed | tickonlyfalse | +| 1859 | `Nothing` return for channel (`isJust m_` is False) | nottickedoff | +| 1860 | `pure Nothing` when `ciReactionAllowed` is False | nottickedoff | + +#### `validSender` (lines 1871-1874) + +| Line | Code | Status | +|------|------|--------| +| 1872 | `validSender (Just mId) (CIGroupRcv m) = sameMemberId mId m` | nottickedoff | +| 1873 | `validSender Nothing CIChannelRcv = True` | **covered** | +| 1874 | `validSender _ _ = False` | nottickedoff | + +#### `processForwardedMsg` / `xGrpMsgForward` (lines 3127-3153) + +| Line | Code | Status | +|------|------|--------| +| 3133 | `(const Nothing)` wrapper | nottickedoff | +| 3139 | `mentions`, `msgScope`, `ttl`, `live`, `True` to `groupMessageUpdate` | nottickedoff | +| 3141 | `scope_` and `rcvMsg` to `groupMessageDelete` | nottickedoff | +| 3143 | `scope_` to `groupMsgReaction` | nottickedoff | +| 3145 | `XInfo` handler when `author_` is Just | nottickedoff | +| 3152 | `XGrpPrefs` forwarding | nottickedoff | +| 3153 | Catch-all error for unsupported forwarded event | nottickedoff | +| 3311 | `FwdChannel -> (Nothing, Nothing)` | nottickedoff | + +--- + +### 2. View.hs + +#### `viewChatItem` (line 646) + +| Line | Code | Status | +|------|------|--------| +| 555 | `groupNtf user g mention` - `mention` parameter for channel | nottickedoff | +| 673 | `showSndItemProhibited to` for `CISndGroupInvitation` | nottickedoff | +| 674 | `showSndItem to` fallback for GroupChat | nottickedoff | +| 682 | `CIRcvIntegrityError` in group context | nottickedoff | +| 683 | `CIRcvGroupInvitation` with `isJust m_` guard | nottickedoff | +| 684 | `CIRcvModerated` in group context | nottickedoff | +| 685 | `CIRcvBlocked` in group context | nottickedoff | +| 686 | `showRcvItem from` fallback | nottickedoff | +| 691 | `forwardedFrom` in context computation | nottickedoff | + +#### `viewItemUpdate` (line 798) + +| Line | Code | Status | +|------|------|--------| +| 819 | `CIGroupRcv m -> updGroupItem (Just m)` | nottickedoff | +| 822 | `CIGroupSnd _ -> []` fallback | nottickedoff | +| 825 | `ttyToGroup g scopeInfo` (non-edited send path) | nottickedoff | +| 830 | `itemLive == Just True && not liveItems -> []` | tickonlyfalse | +| 832 | `_ -> []` fallback for non-message content | nottickedoff | +| 834 | `ttyFromGroup g scopeInfo m_` (non-edited receive path) | nottickedoff | +| 837 | `forwardedFrom` in context | nottickedoff | +| 838 | `groupQuote g` in context | nottickedoff | + +#### `viewItemReaction` (line 890) + +| Line | Code | Status | +|------|------|--------| +| 898-899 | `sentByMember' g itemDir` in both CIGroupRcv and CIChannelRcv | nottickedoff | +| 913 | `groupReaction _ -> []` (non-message-content fallback) | nottickedoff | +| 917 | `else sentBy` branch when `showGroupAsSender` is False | nottickedoff | +| 958 | `sentByMember'` function | **entirely nottickedoff** | +| 962 | `CIChannelRcv -> Nothing` in sentByMember' | nottickedoff | + +#### `viewItemDelete` (line 869) + +| Line | Code | Status | +|------|------|--------| +| 880 | `_ -> prohibited` in GroupChat branch | nottickedoff | + +#### `viewGroupChatItemsDeleted` (line 866) + +| Line | Code | Status | +|------|------|--------| +| 158 | `member_` parameter | nottickedoff | +| 866 | `maybe "" (\m -> " " <> ttyMember m) member_` - empty string fallback | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `groupScopeInfoStr` (line 2785) + +| Line | Code | Status | +|------|------|--------| +| - | `Just (GCSIMemberSupport {groupMember_}) -> ...` | nottickedoff | +| - | `Nothing -> "(support)"` sub-branch | nottickedoff | +| - | `Just m -> "(support: " <> viewMemberName m <> ")"` sub-branch | nottickedoff | + +#### Scope info display + +| Line | Code | Status | +|------|------|--------| +| 2768 | `groupScopeInfoStr scopeInfo` in `ttyToGroup` | nottickedoff | +| 2779 | `groupScopeInfoStr scopeInfo` in `ttyToGroupEdited` | nottickedoff | +| 2782 | `groupScopeInfoStr scopeInfo` in `fromGroupAttention_` | nottickedoff | + +#### Other display functions + +| Line | Code | Status | +|------|------|--------| +| 625 | `GroupChat g scopeInfo -> [" " <> ttyToGroup g scopeInfo]` | nottickedoff | +| 766 | `(SMDSnd, GroupChat gInfo _scopeInfo) -> Just $ "you #" <> ...` | nottickedoff | +| 767 | `(SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> ...` | nottickedoff | +| 936 | `viewReactionMembers` | **entirely nottickedoff** | +| 1020 | `viewChatCleared` GroupChat branch | nottickedoff | + +--- + +### 3. Internal.hs + +#### `saveRcvChatItem'` (lines 2294-2340) + +| Line | Code | Status | +|------|------|--------| +| 2288 | `M.empty` for non-group mentions | nottickedoff | +| 2299 | `groupMentions` parameters `db` and `membership` | nottickedoff | +| 2300 | `_ -> pure (M.empty, False)` for non-group | nottickedoff | +| 2303 | `contactChatDeleted cd` | tickonlyfalse | +| 2303 | `vr` parameter in `updateChatTsStats` | nottickedoff | +| 2304 | `else pure $ toChatInfo cd` | nottickedoff | +| 2316 | `getRcvCIMentions` - `db`, `user`, `mentions` parameters | nottickedoff | +| 2319 | `sameMemberId mId membership` in userReply check | nottickedoff | +| 2320 | `\CIMention {memberId} -> sameMemberId memberId membership` | nottickedoff | +| 2311 | `createGroupCIMentions db g ci mentions'` | nottickedoff (mentions always empty) | + +#### `memberChatStats` (line 2323) + +| Line | Code | Status | +|------|------|--------| +| 2325-2327 | `CDGroupRcv _g (Just scope) m -> ...` | nottickedoff | +| 2328-2329 | `CDChannelRcv _g (Just scope) -> ...` | nottickedoff | +| 2330 | `_ -> Nothing` | nottickedoff | +| - | Entire function | **entirely nottickedoff** | + +#### `memberAttentionChange` (line 2621) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `getRcvCIMentions` (line 277) + +| Line | Code | Status | +|------|------|--------| +| 279 | `not (null ft) && not (null mentions) -> ...` | nottickedoff | +| 280 | `uniqueMsgMentions maxRcvMentions mentions $ mentionedNames ft` | nottickedoff | +| 281 | `mapM (getMentionedMemberByMemberId db user groupId) mentions'` | nottickedoff | + +#### `uniqueMsgMentions` (line 286) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### `prepareGroupMsg` / `quoteData` (line 204) + +| Line | Code | Status | +|------|------|--------| +| 209 | `MCForward $ ExtMsgContent ...` forward branch | nottickedoff | +| 227 | `CIGroupSnd` with `showGroupAsSender` False | nottickedoff | +| 228 | `CIGroupRcv m -> pure (qmc, CIQGroupRcv $ Just m, False, Just m)` | nottickedoff | + +#### `mkGetMessageChatScope` (lines 1599-1617) + +| Line | Code | Status | +|------|------|--------| +| 1601 | `groupScope@(_gInfo', _m', Just _scopeInfo) -> pure groupScope` | nottickedoff | +| 1604 | `isReport mc -> ...` | tickonlyfalse | +| 1610 | `sameMemberId mId membership -> ...` | nottickedoff | +| 1614 | `otherwise -> do referredMember <- ...` | nottickedoff | +| 1614 | `vr` parameter in `getGroupMemberByMemberId` | nottickedoff | + +#### `mkGroupSupportChatInfo` (line 1620) + +| Line | Code | Status | +|------|------|--------| +| - | Entire function | **entirely nottickedoff** | + +#### Feature checks (tickonlyfalse - never true) + +| Line | Code | Status | +|------|------|--------| +| 342 | `isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo)` | tickonlyfalse | +| 344 | `isReport mc && ... not (groupFeatureAllowed SGFReports gInfo)` | tickonlyfalse | +| 485 | `isACIUserMention deletedChatItem` | tickonlyfalse | +| 1593 | `memberPending m` | tickonlyfalse | + +#### `sendGroupMessages` (line 1986) + +| Line | Code | Status | +|------|------|--------| +| 1989 | `sendProfileUpdate catchAllErrors eToView` | nottickedoff | +| 1995 | `isJust scope = False` branch | nottickedoff | + +--- + +### 4. Messages.hs + +#### JSON direction functions - ALL ENTIRELY UNTESTED + +**`jsonCIDirection` (lines 314-321):** +| Line | Code | Status | +|------|------|--------| +| 315 | `CIDirectSnd -> JCIDirectSnd` | nottickedoff | +| 316 | `CIDirectRcv -> JCIDirectRcv` | nottickedoff | +| 317 | `CIGroupSnd -> JCIGroupSnd` | nottickedoff | +| 318 | `CIGroupRcv m -> JCIGroupRcv m` | nottickedoff | +| 319 | `CIChannelRcv -> JCIChannelRcv` | nottickedoff | +| 320 | `CILocalSnd -> JCILocalSnd` | nottickedoff | +| 321 | `CILocalRcv -> JCILocalRcv` | nottickedoff | + +**`jsonACIDirection` (lines 324-331):** +| Line | Code | Status | +|------|------|--------| +| 325-331 | All branches including `JCIChannelRcv -> ACID SCTGroup SMDRcv CIChannelRcv` | nottickedoff | + +**`jsonCIQDirection` (lines 646-651):** +| Line | Code | Status | +|------|------|--------| +| 647 | `CIQDirectSnd -> JCIDirectSnd` | nottickedoff | +| 648 | `CIQDirectRcv -> JCIDirectRcv` | nottickedoff | +| 649 | `CIQGroupSnd -> JCIGroupSnd` | nottickedoff | +| 650 | `CIQGroupRcv (Just m) -> JCIGroupRcv m` | nottickedoff | +| 651 | `CIQGroupRcv Nothing -> JCIChannelRcv` | nottickedoff | + +**`jsonACIQDirection` (lines 654-661):** +| Line | Code | Status | +|------|------|--------| +| 655-659 | All branches including `JCIChannelRcv -> Right $ ACIQDirection SCTGroup $ CIQGroupRcv Nothing` | nottickedoff | +| 660 | `JCILocalSnd -> Left "unquotable"` | nottickedoff | +| 661 | `JCILocalRcv -> Left "unquotable"` | nottickedoff | + +**ToJSON/FromJSON instances:** +| Line | Code | Status | +|------|------|--------| +| 1469-1470 | `CIDirection` ToJSON | nottickedoff | +| 1473 | `CCIDirection` FromJSON | nottickedoff | +| 1476 | `ACIDirection` FromJSON | nottickedoff | +| 1479 | `CIQDirection` FromJSON | nottickedoff | +| 1482-1483 | `CIQDirection` ToJSON | nottickedoff | + +#### Other Messages.hs functions + +| Line | Code | Status | +|------|------|--------| +| 372-375 | `chatItemRcvFromMember` | partially covered - `_ -> Nothing` nottickedoff | +| 403 | `toCIDirection CDLocalRcv _ -> CILocalRcv` | nottickedoff | +| 413 | `toChatInfo CDLocalRcv l -> LocalChat l` | nottickedoff | +| 486 | `aChatItemRcvFromMember` | nottickedoff | +| 665 | `quoteMsgDirection CIQDirectSnd -> MDSnd` | nottickedoff | +| 666 | `quoteMsgDirection CIQDirectRcv -> MDRcv` | nottickedoff | + +--- + +### 5. Store/Messages.hs + +#### Scope-filtered query functions - ALL ENTIRELY UNTESTED + +| Function | Lines | Status | +|----------|-------|--------| +| `findGroupChatPreviews_` | 862-900 | nottickedoff | +| `getChatContentTypes` | 1183-1197 | nottickedoff | +| `getChatItemIDs` | 1476-1505 | nottickedoff | +| `queryUnreadGroupItems` | 1686-1707 | nottickedoff | +| `updateSupportChatItemsRead` | 2038-2077 | nottickedoff | +| `getGroupUnreadTimedItems` | 2080-2102 | nottickedoff | +| `getGroupMemberCIBySharedMsgId` | 2950-2960 | nottickedoff | + +#### `toGroupChatItem` (lines 2327-2337) + +| Line | Code | Status | +|------|------|--------| +| 2329 | `CIChannelRcv` with file | **covered** | +| 2332 | `CIChannelRcv` without file | **covered** | +| 2334 | `CIGroupRcv member` with file | nottickedoff | +| 2336 | `CIGroupRcv member` without file | nottickedoff | +| 2337 | `badItem` fallback | nottickedoff | +| 2321 | `deletedByGroupMember_` parsing | nottickedoff | + +#### `getChatItemQuote_` CDChannelRcv (lines 648-653) + +| Line | Code | Status | +|------|------|--------| +| 651 | `mId == userMemberId` check | nottickedoff | +| 651 | `getUserGroupChatItemId_` call | nottickedoff | +| 652 | `otherwise` fallback | nottickedoff | +| 653 | `_ -> pure . ciQuote Nothing $ CIQGroupRcv Nothing` | **covered** | + +#### Reaction functions + +| Line | Code | Status | +|------|------|--------| +| 3275 | `getGroupCIReactions` | **covered** | +| 3328 | `deleteGroupCIReactions_` | nottickedoff | + +--- + +## Summary + +### Well-tested channel paths: +- Channel message create/read/delete happy paths +- Basic channel reactions +- Channel quote creation (quoting nothing) +- `validSender Nothing CIChannelRcv` +- `getGroupChatItemBySharedMsgId` with `Nothing` memberId + +### Major gaps: + +1. **Non-channel-owner member in channel groups** - `isChannelOwner` always True, `memberForChannel = Just m''` never executed + +2. **All JSON serialization for CI directions** - `jsonCIDirection`, `jsonACIDirection`, `jsonCIQDirection`, `jsonACIQDirection` and all `ToJSON`/`FromJSON` instances entirely untested + +3. **Member support scope (`GCSIMemberSupport`)** - `mkGroupSupportChatInfo`, `groupScopeInfoStr`, `memberChatStats` entirely untested + +4. **Mentions in channel/group messages** - `getRcvCIMentions` with non-empty mentions, `uniqueMsgMentions`, `createGroupCIMentions` never called + +5. **Error/fallback paths** - `catchCINotFound` in update/delete/reaction, invalid sender validation, permission errors + +6. **Full-delete feature** - `groupFeatureAllowed SGFFullDelete` always false, `deleteGroupCIs` never called + +7. **Live message updates** - `itemLive == Just True` always false + +8. **Forwarded message handling** - Most parameters to forwarded handlers untested, `FwdChannel` branch untested + +9. **View functions** - `sentByMember'`, `viewGroupChatItemsDeleted`, `viewReactionMembers` entirely untested + +10. **Scope-filtered store queries** - 7 functions entirely untested + +11. **Feature restriction checks** - Voice messages (`SGFVoice`), reports (`SGFReports`) feature checks never triggered diff --git a/plans/website-file-page-implementation.md b/plans/website-file-page-implementation.md new file mode 100644 index 0000000000..bca77e77a9 --- /dev/null +++ b/plans/website-file-page-implementation.md @@ -0,0 +1,472 @@ +# File Transfer Page — Implementation Plan + +## Table of Contents +1. [Context](#1-context) +2. [Executive Summary](#2-executive-summary) +3. [High-Level Design](#3-high-level-design) +4. [Detailed Implementation Plan](#4-detailed-implementation-plan) +5. [Known Divergences from Product Plan](#5-known-divergences-from-product-plan) +6. [Verification](#6-verification) + +--- + +## 1. Context + +**Problem**: The website needs a `/file` page that lets users upload/download files via XFTP servers directly in the browser — a live demo that funnels users toward downloading the SimpleX app. + +**Product plan**: `plans/website-file-page-product.md` + +**Approach**: Use the pre-built `dist-web/` bundle from `@shhhum/xftp-web@0.8.0`. Copy three files (`index.js` + `index.css` + `crypto.worker.js`) to website static assets. Wrap with an 11ty page providing the protocol overlay, app download CTA, and i18n bridge. **No Vite/TS build step.** The bundle handles all XFTP protocol, crypto, Web Worker, upload/download UI. + +**Library features used** (v0.8.0): +- `data-xftp-app` — configurable target element +- `data-no-hashchange` — prevents conflict with overlay system +- `window.__XFTP_I18N__` — string externalization for i18n +- `xftp:upload-complete` / `xftp:download-complete` — CustomEvents for CTA injection +- Scoped CSS (`#app` / `.dark #app`) — no global resets +- Relative worker URL — both files co-located in same directory + +**Routing**: `/file/` (no hash) = upload mode; `/file/#` = download mode. + +--- + +## 2. Executive Summary + +| Action | Files | +|--------|-------| +| **Create** | `website/src/file.html`, `website/src/_data/file_overlays.json`, `website/src/_includes/overlay_content/file/protocol.html` | +| **Copy from npm** | `dist-web/assets/index.js` + `dist-web/assets/index.css` + `dist-web/assets/crypto.worker.js` → `src/file-assets/` | +| **Modify** | `website/package.json`, `website/.eleventy.js`, `website/src/_includes/navbar.html`, `website/langs/en.json` (~30 keys), `website/web.sh`, `website/src/js/script.js`, `.gitignore` | + +--- + +## 3. High-Level Design + +### Architecture + +``` +website/src/ +├── file.html # 11ty page +├── _data/file_overlays.json # overlay config (showImage: false for v1) +├── _includes/overlay_content/file/ +│ └── protocol.html # protocol popup content +└── file-assets/ # COPIED from npm dist-web/assets/ (gitignored) + ├── index.js # main bundle (~1.1 MB) + ├── index.css # scoped CSS (~2.3 KB) + └── crypto.worker.js # worker (~1.0 MB) +``` + +### Data flow + +**Upload**: `#app` div → bundle renders drop zone → file input → Worker encrypts (OPFS) → `uploadFile()` → share link → `xftp:upload-complete` event → website shows inline CTA + +**Download**: hash parsed by bundle on init → `decodeDescriptionURI()` → download button → Worker decrypts → browser save → `xftp:download-complete` event → website shows inline CTA + +### Overlay conflict resolution + +Bundle's `hashchange` listener is disabled via `data-no-hashchange` attribute. Protocol overlay opens via **direct DOM manipulation** (inline JS `classList.remove('hidden')`) — not hash-based. script.js's global `.close-overlay-btn` handler still closes it. No hash events fired when opening. + +Note: `closeOverlay()` in script.js calls `history.replaceState(null, null, ' ')` which clears the URL hash. In download mode (`/file/#simplex:...`), this means the hash disappears from the URL bar after closing the overlay. This is cosmetic only — the bundle parses the hash once on init and doesn't re-read it. Download continues unaffected. + +A null guard is added to `openOverlay()` in script.js (Step 9) to prevent crashes when the hash is an XFTP URI fragment rather than a DOM element ID. + +### i18n bridge + +The 11ty template renders `window.__XFTP_I18N__` from en.json keys. The bundle reads via `t(key, fallback)`. All JS-rendered strings are overridable. The bundle renders strings via template literals into innerHTML, so HTML in i18n values (e.g. links in `maxSizeHint`) is rendered correctly. + +--- + +## 4. Detailed Implementation Plan + +### Step 1: Add npm dependency + +**Modify**: `website/package.json` + +```diff + "dependencies": { ++ "@shhhum/xftp-web": "^0.8.0", + } +``` + +### Step 2: Copy dist-web files in web.sh + +**Modify**: `website/web.sh` + +After the existing `cp node_modules/...` lines (after line 30): + +```bash +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.js src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/index.css src/file-assets/ +cp node_modules/@shhhum/xftp-web/dist-web/assets/crypto.worker.js src/file-assets/ +``` + +Add `file.html` to language copy loop (after line 42, `cp src/fdroid.html src/$lang`): +```bash + cp src/file.html src/$lang +``` + +### Step 3: Create 11ty page — `website/src/file.html` + +``` +--- +layout: layouts/main.html +title: "SimpleX File Transfer" +description: "Send files securely with end-to-end encryption" +templateEngineOverride: njk +active_file: true +--- +{% set lang = page.url | getlang %} +{% from "components/macro.njk" import overlay %} +``` + +**Structure** (top to bottom): + +1. **Noscript fallback**: + ```html + + ``` + +2. **Page section** with centered container: + - `

` with i18n title + - `
` — bundle renders here, hashchange disabled + - Static "E2E encrypted" note below `#app`: + ```html +

+ {{ "file-e2e-note" | i18n({}, lang) | safe }} +

+ ``` + - "Learn more" link (opens overlay via inline JS, not hash): + ```html +

+ + {{ "file-learn-more" | i18n({}, lang) | safe }} + +

+ ``` + +3. **Inline CTA container** (hidden, shown by JS after upload/download): + ```html + + ``` + +4. **Protocol overlay** via existing macro: + ```html + {% for section in file_overlays.sections %} + {{ overlay(section, lang) }} + {% endfor %} + ``` + +5. **Bottom CTA section** (same pattern as `join_simplex.html`): + - Heading: "Get SimpleX — the most private messenger" + - Subheading about the app using the same protocol + - 5 buttons: Apple Store, Google Play, F-Droid, TestFlight, APK (same markup as inline CTA) + +6. **i18n bridge script** (BEFORE bundle load, so `window.__XFTP_I18N__` is set when bundle initializes): + ```html + + ``` + +7. **Overlay open + CTA injection script**: + ```html + + ``` + +8. **Bundle + CSS** (bundle AFTER i18n bridge): + ```html + + + ``` + +### Step 4: Create protocol overlay data + content + +**New file**: `website/src/_data/file_overlays.json` +```json +{ + "sections": [{ + "id": 1, + "imgLight": "", + "imgDark": "", + "overlayContent": { + "overlayId": "xftp-protocol", + "overlayScrollTo": "", + "title": "file-protocol-title", + "showImage": false, + "contentBody": "overlay_content/file/protocol.html" + } + }] +} +``` + +Note: `showImage: false` — protocol diagram SVGs are deferred to a future iteration. The overlay works without images (same as existing overlays when `showImage` is false — the content section spans full width). + +**New file**: `website/src/_includes/overlay_content/file/protocol.html` + +5 blocks with heading + paragraph structure (existing hero overlay cards use plain `

` tags; this overlay uses `

` + `

` inside `

` wrappers since it has titled sections): + +```html +
+

{{ "file-proto-h-1" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-1" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-2" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-2" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-3" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-3" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-4" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-4" | i18n({}, lang) | safe }}

+
+
+

{{ "file-proto-h-5" | i18n({}, lang) | safe }}

+

{{ "file-proto-p-5" | i18n({}, lang) | safe }}

+
+

+ + {{ "file-proto-spec" | i18n({}, lang) | safe }} + +

+``` + +### Step 5: Add navbar link + +**Modify**: `website/src/_includes/navbar.html` + +After Directory `
  • ` block (after line 27, before the `
    ` at line 29): +```html +
    +
  • +``` + +Add `and ('file' not in page.url)` to language-selector exclusion condition (line 137): +``` +{% if ('blog' not in page.url) and ('about' not in page.url) and ('donate' not in page.url) and ('privacy' not in page.url) and ('directory' not in page.url) and ('vouchers' not in page.url) and ('file' not in page.url) %} +``` + +### Step 6: Add translation keys + +**Modify**: `website/langs/en.json` — add these keys: + +``` +Navbar: + "file": "File" + +Noscript + static page content: + "file-noscript": "JavaScript is required for file transfer." + "file-e2e-note": "End-to-end encrypted — the server never sees your file." + "file-learn-more": "Learn more about XFTP protocol" + "file-cta-heading": "Get SimpleX — the most private messenger" + "file-cta-subheading": "The file transfer you just used is built on the same protocol as SimpleX Chat — end-to-end encrypted messaging, voice and video calls, groups, and file sharing. No user IDs. No phone numbers." + +i18n bridge (fed to bundle via window.__XFTP_I18N__): + "file-title": "SimpleX File Transfer" + "file-drop-text": "Drag & drop a file here" + "file-drop-hint": "or" + "file-choose": "Choose file" + "file-max-size": "Max 100 MB — the SimpleX app supports up to 1 GB" + "file-encrypting": "Encrypting\u2026" + "file-uploading": "Uploading\u2026" + "file-cancel": "Cancel" + "file-uploaded": "File uploaded" + "file-copy": "Copy" + "file-copied": "Copied!" + "file-share": "Share" + "file-expiry": "Files are typically available for 48 hours." + "file-sec-1": "Your file was encrypted in the browser before upload — the server never sees file contents." + "file-sec-2": "The link contains the decryption key in the hash fragment, which the browser never sends to any server." + "file-sec-3": "For maximum security, use the SimpleX app." + "file-retry": "Retry" + "file-downloading": "Downloading\u2026" + "file-decrypting": "Decrypting\u2026" + "file-download-complete": "Download complete" + "file-download-btn": "Download" + "file-too-large": "File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB." + "file-empty": "File is empty." + "file-invalid-link": "Invalid or corrupted link." + "file-init-error": "Failed to initialize: %error%" + "file-available": "File available (~%size%)" + "file-dl-sec-1": "This file is encrypted \u2014 the server never sees file contents." + "file-dl-sec-2": "The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server." + "file-dl-sec-3": "For maximum security, use the SimpleX app." + "file-workers-required": "Web Workers required \u2014 update your browser" + +Protocol overlay content: + "file-protocol-title": "Why XFTP is the most private file transfer" + "file-proto-h-1": "No accounts, no identifiers" + "file-proto-p-1": "Each file chunk uses a fresh, random credential that is used once and discarded. The server has no concept of \"users\" — it only sees isolated, anonymous chunk operations." + "file-proto-h-2": "Encrypted in your browser" + "file-proto-p-2": "The entire file is encrypted with a random key before upload. The server stores ciphertext it cannot decrypt. The key travels only in the URL fragment, which browsers never send to any server." + "file-proto-h-3": "Triple encryption" + "file-proto-p-3": "Every transfer has three layers: TLS transport encryption, per-recipient transit encryption (unique ephemeral key exchange per download), and file-level end-to-end encryption." + "file-proto-h-4": "Distributed across independent servers" + "file-proto-p-4": "File chunks are split across servers operated by independent parties. No single operator sees all chunks. Even if one operator is compromised, they only see encrypted fragments." + "file-proto-h-5": "Files expire automatically" + "file-proto-p-5": "Files are deleted after approximately 48 hours. There is no persistent storage, no file management, no way to extend expiration. Ephemeral by design." + "file-proto-spec": "Read the XFTP protocol specification →" +``` + +### Step 7: Update .eleventy.js + +**Modify**: `website/.eleventy.js` + +1. Add `"file"` to `supportedRoutes` array (line 56): +```js +const supportedRoutes = ["blog", "contact", "invitation", "messaging", "docs", "fdroid", "file", ""] +``` + +2. Add passthrough copy (after line 306, with the other `addPassthroughCopy` calls): +```js +ty.addPassthroughCopy("src/file-assets") +``` + +### Step 8: Gitignore + +**Modify**: `.gitignore` (project root) — add: +``` +website/src/file-assets/ +``` + +### Step 9: Fix script.js null guard + +**Modify**: `website/src/js/script.js` + +The `openOverlay()` function (line 180) crashes when the URL hash is an XFTP URI fragment (e.g. `#simplex:...`) because `document.getElementById('simplex:...')` returns null, and `el.classList.contains('overlay')` throws a TypeError on null. + +**Change** (line 184-185): +```js +// Before: +const el = document.getElementById(id) +if (el.classList.contains('overlay')) { + +// After: +const el = document.getElementById(id) +if (el && el.classList.contains('overlay')) { +``` + +This is a one-character change (`if (el.classList` → `if (el && el.classList`). It makes `openOverlay()` safely ignore hash fragments that don't correspond to overlay elements — which is correct behavior regardless of the file page (any non-overlay hash should be silently ignored). + +--- + +## 5. Known Divergences from Product Plan + +These are intentional deviations from the product plan, caused by browser constraints or library limitations: + +1. **Download requires a click**: Product plan says "No intermediate 'click to download' step." The bundle shows a "Download" button instead of auto-starting. This is a browser security constraint — triggering a file download requires a user gesture. The button also lets the user see file metadata before downloading. + +2. **No cancel during download**: Product plan specifies a Cancel button during download. The bundle does not implement this. The download is relatively fast (direct HTTPS) and cancellation can be done by closing the tab. + +3. **Protocol diagram deferred**: Product plan describes a protocol flow diagram in the overlay. SVG diagrams are deferred to a future iteration. The overlay ships with text-only content (`showImage: false`). + +4. **Overlay close clears download hash**: When the protocol overlay is opened and closed during download mode, `closeOverlay()` clears the URL hash. This is cosmetic — the bundle already parsed the hash on init and the download is unaffected. The URL bar loses the fragment, but the user received the link from elsewhere and doesn't need to re-copy it. + +--- + +## 6. Verification + +### Build +```bash +cd website +npm install --ignore-scripts +mkdir -p src/file-assets +cp node_modules/@shhhum/xftp-web/dist-web/assets/{index.js,index.css,crypto.worker.js} src/file-assets/ +npm run build +ls _site/file/index.html _site/file-assets/index.js _site/file-assets/index.css _site/file-assets/crypto.worker.js +``` + +### Manual test checklist +``` +Visit /file/ + 1. Navbar "File" link is active + 2.