diff --git a/.gitignore b/.gitignore index 2f4af38cca..7bd3d04e59 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ website/src/images/ website/src/js/lottie.min.js website/src/js/ethers* website/src/file-assets/ +website/src/link-images/ website/src/privacy.md # Generated files website/package/generated* diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000000..8487f0a53c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,552 @@ +# Release History + +## v6.5 + +30 April, 2026 + +Public channels - speak freely! +- Reliability: many relays per channel. +- Ownership: you can run your own relays. +- Security: owners hold channel keys. +- Privacy: for owners and subscribers. + +Easier to invite your friends: we made connecting simpler for new users. + +Safe web links: +- opt-in to send link previews. +- use SOCKS proxy for previews (if enabled). +- prevent hyperlink phishing. +- remove link tracking. + +Non-profit governance: to make SimpleX Network last. + +Read more on April 30 at 20:00 UTC: https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +## v6.4 + +15 July, 2025 + +- Connect faster: message instantly once you tap Connect. +- Review group members: chat with new members before they join. +- Chat with admins: send your private feedback to group owners. +- New group role: Moderator - can remove messages and block members. +- Improved message delivery - less traffic on mobile networks. + +Read about the new UX for making connections in the blog post: https://simplex.chat/blog/20250703-simplex-network-protocol-extension-for-securely-connecting-people.html + +## v6.3 + +7 March, 2025 + +Better groups. +- Mention members and get notified when mentioned. +- Send private reports to moderators. +- Delete, block and change role for multiple members at once (Android and desktop only). +- Faster sending messages and faster deletion. + +Better chat navigation +- Organize chats into lists to keep track of what's important. +- Jump to found and forwarded messages. + +Better privacy and security. +- Private media file names. +- Message expiration in chats. + +Read more on March 8: https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html + +## v6.2 + +7 December, 2024 + +- SimpleX Chat and Flux (https://runonflux.com) made an agreement to include servers operated by Flux into the app – to improve metadata privacy. +- Business chats – your customers' privacy. +- Improved user experience of chats: + - Open chat on the first unread message. + - Jump to quoted messages anywhere in the conversation. + - See who reacted to messages. +- Improved iOS push notifications. + +Read more on December 10: https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html + +## v6.1 + +12 October, 2024 + +Better security: +- SimpleX protocols reviewed by Trail of Bits. +- security improvements (don't worry, there is nothing critical there). + +Better calls: +- you can switch audio and video during the call +- share the screen from desktop app. + +Better iOS notifications: +- improved delivery, reduced traffic usage. +- more improvements are coming soon! + +Better user experience: +- switch chat profile for 1-time invitations. +- customizable message shape. +- better message dates. +- forward up to 20 messages at once. +- delete or moderate up to 200 messages. + +The protocols review by Trail of Bits and release announcement will be published on October 14 afternoon here: https://simplex.chat/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.html + +## v6.0 + +11 August, 2024 + +New chat experience: +- connect to your friends faster. +- archive contacts to chat later. +- delete up to 20 messages at once. +- increase font size. +- new chat themes on iOS - same as on Android and desktop in the previous version. +- reachable chat toolbar - use the app with one hand. + +New media options: +- share from other apps (iOS). +- play from the chat list. +- blur for better privacy. + +Private routing: it protects your IP address and connections and is now enabled by default. + +Connection and servers information: to control your network status and usage. + +Read more on 8/14: https://simplex.chat/blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.html + +## v5.8 + +3 June, 2024 + +- private message routing to protect IP addresses (opt-in in this version). +- protect IP address when receiving files. +- chat themes with wallpapers - set themes for all chats app-wide, per chat profile and per conversation - Android and desktop apps. +- some groups permissions can now be granted to admins only. +- improved message and file delivery with reduced battery usage. +- Persian interface language - Android and desktop apps. + +Read more: https://simplex.chat/blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.html + +## v5.7 + +26 April, 2024 + +- quantum resistant end-to-end encryption – will be enabled for all direct chats! +- forward and save messages and files, without revealing the source. +- improved calls: in-call sounds when connecting calls, better support for bluetooth headphones. +- customizable shapes of profile images - from square to circle. +- more reliable network connection. + +Lithuanian UI language in Android and desktop apps - thanks to our users! + +Read more: https://simplex.chat/blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.html + +## v5.6 + +21 March, 2024 + +1. **Quantum resistant end-to-end encryption** in direct chats (BETA). + It can be enabled for the new contacts by *Post-quantum E2EE* toggle in dev tools, and for the existing contacts - both users need to tap *Allow PQ encryption* in contact information page (and the toggle in dev tools should be enable for this button to be available). + Once quantum resistant shared secret is agreed, there will be a message indicating it - it takes about 2-3 messages from each side to be sent in turns before it gets enabled. + Read more about end-to-end encryption in SimpleX Chat here: https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html + +2. **App data migration**. + As suggested by one of SimpleX Chat users in our [users group](simplex:/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D), you can now migrate all data from one device to a new app installation by uploading it to the configured XFTP relays and then scanning QR code from the new device – choose *Migrate to another device* from the app settings and *Migrate from another device* on the first screen after installing the app. + +3. **Use the app during the audio and video calls**. + Now you can continue using the app, with small video if it's a video call. + +Also in this version: +- admins can block a member for all other members. +- much faster leaving and deleting groups. +- filtering chats no longer includes muted chats with unread messages. +- reduced memory usage when sending large files. +- desktop: scrollbars in all views with the scrolling - finally! +- iOS: + - fixed rendering glitches with messages and context menus. + - added Hungarian interface language. + +The blog post with the announcement is coming on 3/23/2024. + +## v5.5 + +23 January, 2024 + +- private notes - with encrypted files and media. +- paste link to connect - search bar now accepts invitation links. +- optional recent history in groups. +- improved message delivery - with reduced battery usage. +- reveal secrets in messages by tapping them. +- all files in local app storage are encrypted by default. +- allow deleting the last visible user profile. +- do not share contact address in member profile. +- many fixes! + +Also, we added Hungarian (Android only) and Turkish interface - thanks to the users and Weblate (https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat). + +## v5.4 + +25 November, 2023 + +- Link mobile and desktop apps via secure quantum-resistant protocol. +- Better groups: + - Faster to join and more reliable. + - Create groups with incognito profile. + - Block group members to hide their messages. + - Prohibit files and media in a group. +- Better calls: + - Connect faster and more stable (still far from great). + - Screen sharing in video calls in desktop app. +- Other improvements: + - profile names now allow spaces. + - when you delete contacts, they are optionally notified. + - previously used and your own SimpleX links are recognised by the app. + - many fixes and improvements. + +## v5.3 + +22 September, 2023 + +All apps (Android, iOS, desktop): +- encrypt local files in app storage (except videos). +- improved groups: + - delivery receipts (up to 20 members). + - send direct messages to members even after contact is deleted. + - faster and more stable. +- simplified incognito mode. +- new privacy settings: show last messages & save draft. +- faster app loading. +- reduced memory usage by 40%. +- fixed bug preventing group members connecting (it will only help the new connections). +- iOS app fixes: + - playing videos on full screen. + - screen reader for messages. + - fixed most background crashes. + +Also, 6 new interface languages added by the users: Arabic*, Bulgarian, Finnish, Hebrew*, Thai and Ukrainian! + +\* Android and desktop only + +## v5.2 + +22 July, 2023 + +- message delivery receipts – with opt out per contact! +- filter favorite and unread chats. +- keep your connections working after restoring from backup. +- share your address with group members via your chat profile. +- improved disappearing messages. +- a bit more usable groups. +- chat preference to prohibit message reactions. +- restart and shutdown buttons. +- more stable message delivery. + +Read more: https://simplex.chat/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html + +## v5.1 + +22 May, 2023 + +Mobile apps: +- message reactions 🚀 +- self-destruct passcode +- improved messages: + - voice messages up to 5 minutes. + - custom time to disappear - can be set just for one message. + - message editing history. +- setting to disable audio/video calls per contact. +- welcome message visible in group profile. + +Android only: +- new design and custom themes for Android - you can share them! +- configurable SOCKS proxy port. +- improved calls on lock screen. +- fixes for sending files. +- locale-dependent formatting of time and date. + +Also, the users have added Japanese and Portuguese (Brazil) interfaces (the latter is available on Android only) - huge thanks! + +## v5.0 + +20 April, 2023 + +- send videos and files up to 1gb - the recipient must have at least version 4.6.1. +- you can self-host XFTP servers and configure the app to use your servers. +- passcode as an alternative to system/device authentication. +- support for IPv6 server addresses. +- configurable SOCKS proxy host and port in Android app. + +Also we added Polish interface language – [thanks to the users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat). + +See more details in this post: https://simplex.chat/blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.html + +## v4.6 + +25 March, 2023 + +Mobile apps: + +- hidden chat profiles – you can protect them with a password! +- audio/video calls: + - iOS: completely re-implemented using WebRTC native library and iOS CallKit. Calls now work when the app is in background, and can be answered when the app is fully stopped. + - Android: added support for bluetooth headphones, volume control in video calls, proximity sensor turns off screen in audio calls. +- group moderation. Admins now can delete member messages and disable members (by assigning "observer" role). +- group welcome message to show to the new users when they join. +- reduced battery usage, particularly when sending messages to large groups. +- Chinese and Spanish interface. + +Android app now supports Android 8+ (API 26+), and also supports 32 bit/ARMv7a devices via a separate APK. If you don't know which APK you need, try simplex.apk first. You can check your device CPU in z-cpu app. + +Terminal / CLI app: + +- hidden profiles are supported. +- improved help, with all supported commands included. + +## v4.5 + +3 February, 2023 + +- multiple chat profiles: use different names, avatars and transport isolation. +- transport isolation: separate transport connections are used for each chat profile (default) or for each connection (BETA – enable dev tools to make this option available in Network & Servers.) +- message draft: the last message text and any attachments are now preserved when you leave the conversation (while the app is running). +- private filenames: to protect your timezone, image and voice message files now use UTC time. + +## v4.4 + +31 December, 2022 + +- disappearing messages - with mutual agreement! +- live messages – they update for all recipients as you type them, every few seconds. +- connection security code verification, for contacts and group members – protect from MITM attack (e.g. invitation link substitution). +- performance improvements - faster UI loading, faster group deletion, etc. + +Mobile apps: +- French language support in the UI! + +iOS app: +- send animated images and "stickers" (e.g., from GIF and PNG files and from 3rd party keyboards) + +## v4.3 + +4 December, 2022 + +Mobile apps: +- instant voice messages! +- irreversible deletion of sent messages on recipients devices (depends on chat preferences) +- an option to hide the app screen in the recent apps, and also prevent the screenshots on Android +- add SMP servers by scanning QR code, support for server passwords (with the new version 4.0 of SMP server) +- improved privacy and security of SimpleX invitation links in the app + +## v4.2 + +6 November, 2022 + +- fixed issues from security audit! +- group links - group admins can create the links for new members to join +- auto-accept contact requests + configure to accept incognito and welcome message +- change group member role +- mark chat as unread +- on Android: + - support for image/gif/sticker keyboards + - fix keyboard bug with backspace + +Beta features (enable Developer tools): +- manually switch contact or member to another address / server +- receive files faster (enable in Privacy settings) + +## v4.1 + +13 October, 2022 + +Changes: +- automatic message deletion (set TTL per-chat or globally) +- change group member roles +- send multiple images at once +- connection aliases and information view +- share text and files from other apps into SimpleX (Android) +- image gallery (Android) +- scroll to quoted message (Android) +- German translations +- improved connection stability and performance + +## v4.0 + +24 September, 2022 + +Changes: + +Local database encryption with passphrase on iOS, Android, Linux, Mac! + +Mobile apps: +- configurable WebRTC ICE servers - see https://github.com/simplex-chat/simplex-chat/blob/stable/docs/WEBRTC.md +- improved stability of establishing direct and group connections, files transfers and message reception. +- support for animated images on Android +- German language UI +- deleting files and media + +Terminal app: +- disable messages and notifications per contact / group + +For developers: +- [TypeScript SDK for integrating with SimpleX Chat](#typescript-sdk-for-integrating-with-simplex-chat) (e.g., chat bots or chat assistants). + +## v3.2 + +20 August, 2022 + +Changes: +- use .onion addresses of the servers (if available) when Tor is used – it is based on a separate setting on iOS. +- endless scrolling and search in chats +- UI improvements +- reduced Android APK size (from 200 to 46Mb) + +## v3.1 + +6 August, 2022 + +Mobile apps: +- secret chat groups! +- support accessing SimpleX messaging servers via Orbot (both iOS and Android) +- new app icons +- advanced network settings +- improved battery usage and traffic + +Terminal app: +- support SOCKS5 proxy +- `/info` command to show information and servers for contacts and group members: use `/info ` for contact and `/info # ` for member information. + +## v3.0 + +9 July, 2022 + +Changes: + +Chat core: +- support for push notifications on iOS +- support for database export/import in mobile clients + +Terminal client: +- automatically accept contact requests and sending reply message with `/auto_accept on` and `/auto_accept on ` coomands + +Mobile clients: +- instant push notifications for iOS (the sending clients have to be upgraded too for notifications to work), +- e2e encrypted WebRTC audio/video calls, +- export and import of chat database, allowing to move the chat profile to another device, +- improved privacy and performance of the protocol. + +Please see [this post](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220711-simplex-chat-v3-released-ios-notifications-audio-video-calls-database-export-import-protocol-improvements.md) for more details. + +## v2.2 + +1 June, 2022 + +Changes: +- WebRTC calls integrated with CallKit (iOS) +- call notifications and alerts (Android) +- local authentication / app lock (both platforms) +- call settings and invitation timeouts +- privacy settings: auto-accepting images, link previews +- paste image from clipboard (iOS) +- SMP servers settings page (iOS) + +## v2.1 + +21 May, 2022 + +New commands for terminal users: +- /clear - delete all messages in a conversation +- /image - send file as image for mobile clients +- /fforward - forward file to another conversation +- /image_forward - forward image to another conversation + +## v2.0 + +11 May, 2022 + +For terminal users: +- /tail command to show the last messages from a given chat or from all chats + +## v1.6 + +16 April, 2022 + +Changes: +- Improved stability of network connection. +- The new protocol to exchange files, in preparation to support images, files and groups in mobile apps. It makes sending files to groups much more efficient, and allows attaching files to the text messages. This version is backwards and forwards compatible, so you can exchange the files with the previous version. It will not be possible to receive the files sent from the next version (1.7) in the previous version (1.5) - please upgrade. +- **Up arrow** key in the terminal can be used to edit the last message you sent. +- CLI option to execute a single command / send one message, e.g. to use in CI to notify about the build completion, or for any other scenario. +- Library support + [chat bot examples](https://github.com/simplex-chat/simplex-chat/tree/stable/apps) to create SimpleX Chat chat bots. + +## v1.5 + +3 April, 2022 + +Edit, delete and reply to messages, in the mobile apps and from the terminal. + +## v1.4 + +26 March, 2022 + +Changes: +- message edit and delete in mobile apps +- profile images +- TCP keep-alive replacing SMP protocol pings (improved connection stability) +- bug fixes for chat scrolling and empty chat views + +## v1.3 + +26 February, 2022 + +Changes: +- markdown support in messages (both platforms) +- user addresses (Android) +- group member names shown in messages +- display name validation +- asynchronous message processing (improved performance) +- search in chats +- Android app UI redesign (welcome page, help view, dark mode fixes) + +## v1.2 + +14 February, 2022 + +Changes: +- message sent/unread status indicators (iOS) +- search in chats +- auto-accept contact requests option +- deduplicate contact requests +- iOS public beta launch +- connection stability fixes + +## v1.1 + +2 February, 2022 + +- TLS 1.3 support. +- Terminal app is now also a backend for our new mobile app - public access to our new iOS app via TestFlight is coming soon! +- The code base now includes an iOS app preview. + +## v1.0 + +12 January, 2022 + +### The most private and secure chat and application platform + +We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is our first application, a messaging application built on the SimpleX platform. + +### What is SimpleX? + +There is currently no messaging application other than SimpleX Chat that guarantees metadata privacy - who is communicating with whom and when. SimpleX is designed to not use any permanent users identities to protect meta-data privacy. See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more details. + +### SimpleX protocol changes + +Best possible E2E encryption - the only messenger using two-layer E2E encryption, with one layer using double ratchet protocol that provides forward secrecy and break-in recovery, and additional encryption layer providing meta-data protection. See more details about encryption algorithms in [SimpleXMQ change log](https://github.com/simplex-chat/simplexmq/blob/master/CHANGELOG.md#100). + +Performance and space efficiency improvements - protocol overhead is reduced from circa 15% to 3.7% thanks to binary encoding, and performance is substantially improved due to more efficient cryptographic algorithms. + +Shorter invitation and contact links due to switching from long RSA to much shorter Curve448/25519 keys - for example, you can connect to the team via [team's SimpleX Chat contact address](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D) (you need to use it in terminal app) or just by using `/simplex` command in the chat. + +This [this post](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md) for more information. + diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 547c2b7000..b459f36c9d 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -2122,6 +2122,7 @@ struct AppSettings: Codable, Equatable { var privacyAskToApproveRelays: Bool? = nil var privacyAcceptImages: Bool? = nil var privacyLinkPreviews: Bool? = nil + var privacySanitizeLinks: Bool? = nil var privacyShowChatPreviews: Bool? = nil var privacySaveLastDraft: Bool? = nil var privacyProtectScreen: Bool? = nil @@ -2157,6 +2158,7 @@ struct AppSettings: Codable, Equatable { if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages } if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews } + if privacySanitizeLinks != def.privacySanitizeLinks { empty.privacySanitizeLinks = privacySanitizeLinks } if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews } if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft } if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen } @@ -2193,6 +2195,7 @@ struct AppSettings: Codable, Equatable { privacyAskToApproveRelays: true, privacyAcceptImages: true, privacyLinkPreviews: true, + privacySanitizeLinks: false, privacyShowChatPreviews: true, privacySaveLastDraft: true, privacyProtectScreen: false, diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift index d83a5e8504..92bab973c9 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -20,10 +20,11 @@ struct ChatItemForwardingView: View { var composeState: Binding? = nil var isProhibited: ((Chat) -> Bool)? = nil var onSelectChat: ((Chat) -> Void)? = nil + var includeLocal: Bool = true @State private var searchText: String = "" @State private var alert: SomeAlert? - private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) + private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } var body: some View { NavigationView { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index a141a53b4c..66148034df 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2175,8 +2175,14 @@ struct ChatView: View { ) } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessage(.cidmInternal, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessage(.cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessage(.cidmInternal, moderate: false) + } } if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport { Button(broadcastDeleteButtonText(chat), role: .destructive) { @@ -2185,8 +2191,14 @@ struct ChatView: View { } } .confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) { - Button("Delete for me", role: .destructive) { - deleteMessages(chat, deletingItems, moderate: false) + if publicGroupEditor(chat) { + Button("Delete from history", role: .destructive) { + deleteMessages(chat, deletingItems, .cidmHistory, moderate: false) + } + } else { + Button("Delete for me", role: .destructive) { + deleteMessages(chat, deletingItems, moderate: false) + } } } .confirmationDialog(archivingReports?.count == 1 ? "Archive report?" : "Archive \(archivingReports?.count ?? 0) reports?", isPresented: $showArchivingReports, titleVisibility: .visible) { @@ -2817,6 +2829,9 @@ struct ChatView: View { } } catch { logger.error("ChatView.deleteMessage error: \(error)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } @@ -2963,6 +2978,14 @@ class FloatingButtonModel: ObservableObject { } +private func publicGroupEditor(_ chat: Chat) -> Bool { + if case let .group(groupInfo, _) = chat.chatInfo { + groupInfo.useRelays && groupInfo.membership.memberRole >= .moderator + } else { + false + } +} + private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } @@ -3010,6 +3033,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe await onSuccess() } catch { logger.error("ChatView.deleteMessages error: \(error.localizedDescription)") + await MainActor.run { + showAlert(NSLocalizedString("Error deleting message", comment: "alert title"), message: responseError(error)) + } } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 5c57a46129..5242923258 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -742,7 +742,7 @@ struct ComposeView: View { (relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped) } let removedCount = relayMembers.filter { (_, m) in relayMemberRemoved(m?.memberStatus) }.count - let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .rsActive && m?.activeConn?.connFailedErr == nil }.count + let activeCount = relayMembers.filter { (relay, m) in !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == .active && m?.activeConn?.connFailedErr == nil }.count let failedCount = relayMembers.filter { (_, m) in !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != nil }.count let noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.count return (relays, activeCount, failedCount, removedCount, noActiveRelays) diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 6600cec47b..27935768e3 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -37,7 +37,9 @@ struct ChannelRelaysView: View { } // TODO [relays] re-enable when relay management ships // .sheet(isPresented: $showAddRelay) { - // let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId }) + // // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // // regardless of relayStatus, so all current rows must be excluded from the add list. + // let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId }) // AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) { // Task { await chatModel.loadGroupMembers(groupInfo) } // } @@ -112,7 +114,10 @@ struct ChannelRelaysView: View { } private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { - if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) { + let relayStatus = groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus + return if relayStatus == .rejected { + "rejected" + } else if [.memLeft, .memRemoved, .memGroupDeleted].contains(member.memberStatus) { relayConnStatus(member).text } else if case .failed = member.activeConn?.connStatus { "failed" @@ -121,8 +126,7 @@ struct ChannelRelaysView: View { } else if member.activeConn?.connInactive ?? false { "inactive" } else { - groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text - ?? relayConnStatus(member).text + relayStatus?.text ?? relayConnStatus(member).text } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index eee9500b3b..34479fc6cb 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -1104,7 +1104,8 @@ func shareChannelPicker(groupInfo: GroupInfo, composeState: Binding some View { let failedCount = groupRelays.filter { relayMemberConnFailed($0) != nil }.count - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count let total = groupRelays.count return List { Group { @@ -376,7 +376,7 @@ struct AddChannelView: View { .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 }) { + if relays.allSatisfy({ $0.relayStatus == .active && relayMemberConnFailed($0) == nil }) { showLinkStep = true channelRelaysModel.reset() } @@ -433,7 +433,7 @@ struct AddChannelView: View { } private func showCancelChannelAlert(_ gInfo: GroupInfo) { - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .active && relayMemberConnFailed($0) == nil }.count let total = groupRelays.count showAlert( NSLocalizedString("Cancel creating channel?", comment: "alert title"), @@ -486,8 +486,14 @@ func chatRelayDisplayName(_ relay: UserChatRelay) -> String { func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View { let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false - let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow) - let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text + let isRejected = status == .rejected + let color: Color = connFailed || removed || isRejected ? .red : (status == .active ? .green : .yellow) + let text: LocalizedStringKey = + connFailed ? "failed" + : isRejected ? "rejected" + : memberStatus == .memLeft ? "removed by operator" + : removed ? "removed" + : status.text return HStack(spacing: 4) { Circle() .fill(color) diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 8be0798fb1..3554ce720f 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -38,6 +38,7 @@ extension AppSettings { privacyLinkPreviewsGroupDefault.set(val) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) } + if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } @@ -77,6 +78,7 @@ extension AppSettings { c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyLinkPreviews = privacyLinkPreviewsGroupDefault.get() + c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get() c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index a43f84f153..f0bd6d9118 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -183,8 +183,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.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.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 */; }; @@ -561,8 +561,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.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.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 = ""; }; @@ -731,8 +731,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -818,8 +818,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.1.1-Fx4JRO2FuL8K4q8f3JAaMO.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.2.0-8CNxlktzYRPIWqOtKFvRIa.a */, ); path = Libraries; sourceTree = ""; @@ -2073,7 +2073,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2098,7 +2098,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2123,7 +2123,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2148,7 +2148,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2165,11 +2165,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2185,11 +2185,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2210,7 +2210,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2225,7 +2225,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2247,7 +2247,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2262,7 +2262,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2284,7 +2284,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2310,7 +2310,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2335,7 +2335,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2362,7 +2362,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2389,7 +2389,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2404,7 +2404,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2423,7 +2423,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 331; + CURRENT_PROJECT_VERSION = 332; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2438,7 +2438,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 6.5.1; + MARKETING_VERSION = 6.5.2; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index e77ad6cb82..d8543735b0 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -237,6 +237,8 @@ public let privacyEncryptLocalFilesGroupDefault = BoolDefault(defaults: groupDef public let privacyAskToApproveRelaysGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS) +public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS) + public let profileImageCornerRadiusGroupDefault = Default(defaults: groupDefaults, forKey: GROUP_DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1dfa477c91..594f90c4e4 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2635,11 +2635,12 @@ public struct GroupShortLinkData: Codable, Hashable { } public enum RelayStatus: String, Decodable, Equatable, Hashable { - case rsNew = "new" - case rsInvited = "invited" - case rsAccepted = "accepted" - case rsActive = "active" - case rsInactive = "inactive" + case new + case invited + case accepted + case active + case inactive + case rejected } public struct RelayProfile: Codable, Equatable, Hashable { @@ -2708,11 +2709,12 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { extension RelayStatus { public var text: LocalizedStringKey { switch self { - case .rsNew: "new" - case .rsInvited: "invited" - case .rsAccepted: "accepted" - case .rsActive: "active" - case .rsInactive: "inactive" + case .new: "new" + case .invited: "invited" + case .accepted: "accepted" + case .active: "active" + case .inactive: "inactive" + case .rejected: "rejected" } } } @@ -4091,6 +4093,7 @@ public enum CIDeleteMode: String, Decodable, Hashable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" case cidmInternalMark = "internalMark" + case cidmHistory = "history" } protocol ItemContent { diff --git a/apps/ios/SimpleXChat/ChatUtils.swift b/apps/ios/SimpleXChat/ChatUtils.swift index 788ac12bae..7de7f3704d 100644 --- a/apps/ios/SimpleXChat/ChatUtils.swift +++ b/apps/ios/SimpleXChat/ChatUtils.swift @@ -53,11 +53,11 @@ extension ChatLike { } } -public func filterChatsToForwardTo(chats: [C]) -> [C] { +public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { var filteredChats = chats.filter { c in c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) } - if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { filteredChats.insert(privateNotes, at: 0) } return filteredChats diff --git a/apps/ios/product/concepts.md b/apps/ios/product/concepts.md index 3fa722d47a..6d63ee2faf 100644 --- a/apps/ios/product/concepts.md +++ b/apps/ios/product/concepts.md @@ -49,7 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | 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`) | +| 31 | Channels (Relays) | [glossary.md](glossary.md), [views/chat.md](views/chat.md), [views/group-info.md](views/group-info.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` incl. `.rsRejected`, `RelayStatus.text`, `GroupRelay`, `GroupMemberRole.relay`, `GroupMemberStatus.memRejected`, `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/GroupMemberInfoView.swift` (rejected-status row), `Shared/Views/Chat/Group/ChannelMembersView.swift`, `Shared/Views/Chat/Group/ChannelRelaysView.swift`, `Shared/Views/NewChat/AddChannelView.swift` (`relayStatusIndicator` rejected branch), `Shared/Model/AppAPITypes.swift` (`GroupShortLinkInfo`, `UserChatRelay`), `Shared/Model/SimpleXAPI.swift` (`apiNewPublicGroup`), `SimpleX SE/ShareAPI.swift` (channel `sendAsGroup`) | `Controller.hs` (`APINewPublicGroup`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | --- diff --git a/apps/ios/product/views/group-info.md b/apps/ios/product/views/group-info.md index ee0c449c68..bfc9acfa71 100644 --- a/apps/ios/product/views/group-info.md +++ b/apps/ios/product/views/group-info.md @@ -188,6 +188,7 @@ New view accessible from channel info, showing relay members (role == `.relay`): | 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 | +| Add relay sheet | Owner-only "Add relay" button opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's swipe action | | Empty state | "No chat relays" | | Footer | "Chat relays forward messages to channel subscribers." | @@ -221,6 +222,7 @@ Owner sees relay status from `apiGetGroupRelays`; non-owner sees connection stat | "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 | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == .rsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `.memLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | | 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 diff --git a/apps/ios/spec/api.md b/apps/ios/spec/api.md index 45a06c371f..f9a3c35917 100644 --- a/apps/ios/spec/api.md +++ b/apps/ios/spec/api.md @@ -415,6 +415,7 @@ Event processing entry point: [`processReceivedMsg`](../Shared/Model/SimpleXAPI. | `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) | +| `groupRelayUpdated` | `user, groupInfo, member, groupRelay` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = .rsRejected` and `member.memberStatus = .memRejected` — final until cleared by the relay operator's `/group allow ` (no event emitted to the owner for that clear). | Controller.hs (`CEvtGroupRelayUpdated`) | ### File Transfer Events diff --git a/apps/ios/spec/client/chat-view.md b/apps/ios/spec/client/chat-view.md index 182e7b7ce9..afe656ed04 100644 --- a/apps/ios/spec/client/chat-view.md +++ b/apps/ios/spec/client/chat-view.md @@ -350,8 +350,13 @@ Groups use separate [`groupLinkButton()`](../../Shared/Views/Chat/Group/GroupCha ### [`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`). +- **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`). When `relayStatus == .rsRejected` the indicator dot is red and the text reads "rejected", matching the `connFailed`/`removed` rendering. - **Member**: filters `chatModel.groupMembers` by `.memberRole == .relay`. Shows relay member display names only (no status data). +- **Add relay sheet**: `existingRelayIds` excludes every `chatRelayId` present in `groupRelays` regardless of `relayStatus`, so an already-listed relay (including `.rsInactive` and `.rsRejected`) cannot be re-added from the sheet. This mirrors the backend gate at `APIAddGroupRelays` (`existingRelayIds`), which rejects duplicate `chatRelayId`s; operator must remove the relay first via the swipe action. + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `.memLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`.memRejected` is reserved for the knocking-admission flow). The transition surfaces through `CEvtGroupRelayUpdated`. In `GroupMemberInfoView`, an additional `Status: rejected by relay operator` info row appears when `groupRelay?.relayStatus == .rsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. ### Leave Button Logic diff --git a/apps/ios/spec/impact.md b/apps/ios/spec/impact.md index eaf646e7f4..74acec789e 100644 --- a/apps/ios/spec/impact.md +++ b/apps/ios/spec/impact.md @@ -61,7 +61,7 @@ | 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/Chat/Group/GroupMemberInfoView.swift | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; rejected-by-operator status row for relay members | | 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 | diff --git a/apps/ios/spec/state.md b/apps/ios/spec/state.md index 6dda4ba275..db16aa2936 100644 --- a/apps/ios/spec/state.md +++ b/apps/ios/spec/state.md @@ -390,8 +390,8 @@ A **channel** is a group with `groupInfo.useRelays == true`. These types support | 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) | +| `RelayStatus` | `enum` | Relay lifecycle: `.rsNew`, `.rsInvited`, `.rsAccepted`, `.rsActive`, `.rsInactive`, `.rsRejected` | [L2506](../SimpleXChat/ChatTypes.swift#L2506) | +| `RelayStatus.text` | `extension` | Localized display text: New/Invited/Accepted/Active/Inactive/Rejected | [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) | diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt index 47506d9532..c16d1ea90d 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/usersettings/Appearance.android.kt @@ -2,7 +2,6 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionDividerSpaced -import SectionSpacer import SectionView import android.app.Activity import android.content.ComponentName @@ -126,9 +125,9 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ProfileImageSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.settings_section_title_icon), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { + SectionView(stringResource(MR.strings.settings_section_title_icon), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF)) { LazyRow { items(AppIcon.values().size, { index -> AppIcon.values()[index] }) { index -> val item = AppIcon.values()[index] @@ -152,7 +151,7 @@ fun AppearanceScope.AppearanceLayout( } } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() FontScaleSection() SectionBottomSpacer() 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 80b68f37a5..3c9ece9dce 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 @@ -2286,18 +2286,20 @@ data class GroupShortLinkData ( @Serializable enum class RelayStatus { - @SerialName("new") RsNew, - @SerialName("invited") RsInvited, - @SerialName("accepted") RsAccepted, - @SerialName("active") RsActive, - @SerialName("inactive") RsInactive; + @SerialName("new") New, + @SerialName("invited") Invited, + @SerialName("accepted") Accepted, + @SerialName("active") Active, + @SerialName("inactive") Inactive, + @SerialName("rejected") Rejected; 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) - RsInactive -> generalGetString(MR.strings.relay_status_inactive) + New -> generalGetString(MR.strings.relay_status_new) + Invited -> generalGetString(MR.strings.relay_status_invited) + Accepted -> generalGetString(MR.strings.relay_status_accepted) + Active -> generalGetString(MR.strings.relay_status_active) + Inactive -> generalGetString(MR.strings.relay_status_inactive) + Rejected -> generalGetString(MR.strings.relay_status_rejected) } } @@ -3735,7 +3737,8 @@ sealed class CIForwardedFrom { enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), @SerialName("internalMark") cidmInternalMark("internalMark"), - @SerialName("broadcast") cidmBroadcast("broadcast"); + @SerialName("broadcast") cidmBroadcast("broadcast"), + @SerialName("history") cidmHistory("history"); } interface ItemContent { 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 88b4e387df..a31dc145a3 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 @@ -91,6 +91,13 @@ enum class SimplexLinkMode { } } +enum class CloseBehavior { + Ask, Quit, MinimizeToTray; + companion object { val default = Ask } +} + +class HintPref(val reset: () -> Unit, val isUnchanged: () -> Boolean) + // Spec: spec/state.md#AppPreferences class AppPreferences { // deprecated, remove in 2024 @@ -99,6 +106,7 @@ class AppPreferences { SHARED_PREFS_NOTIFICATIONS_MODE, if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default ) { NotificationsMode.values().firstOrNull { it.name == this } } + val closeBehavior: SharedPreference = mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name) val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true) val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false) @@ -257,17 +265,23 @@ class AppPreferences { val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true) val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true) - val hintPreferences: List, Boolean>> = listOf( - laNoticeShown to false, - oneHandUICardShown to false, - addressCreationCardShown to false, - liveMessageAlertShown to false, - showHiddenProfilesNotice to true, - showMuteProfileAlert to true, - showReportsInSupportChatAlert to true, - showDeleteConversationNotice to true, - showDeleteContactNotice to true, - privacyLinkPreviewsShowAlert to true, + val hintPreferences: List = listOf( + hintPref(laNoticeShown, false), + hintPref(oneHandUICardShown, false), + hintPref(addressCreationCardShown, false), + hintPref(liveMessageAlertShown, false), + hintPref(showHiddenProfilesNotice, true), + hintPref(showMuteProfileAlert, true), + hintPref(showReportsInSupportChatAlert, true), + hintPref(showDeleteConversationNotice, true), + hintPref(showDeleteContactNotice, true), + hintPref(privacyLinkPreviewsShowAlert, true), + hintPref(closeBehavior, CloseBehavior.default), + ) + + private fun hintPref(pref: SharedPreference, default: T) = HintPref( + reset = { pref.set(default) }, + isUnchanged = { pref.state.value == default }, ) private fun mkIntPreference(prefName: String, default: Int) = @@ -479,6 +493,7 @@ class AppPreferences { private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto" private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast" private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState" + private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior" private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice" private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice" private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy" @@ -1198,14 +1213,14 @@ object ChatController { suspend fun apiDeleteChatItems(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List, mode: CIDeleteMode): List? { val r = sendCmd(rh, CC.ApiDeleteChatItem(type, id, scope, itemIds, mode)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } suspend fun apiDeleteMemberChatItems(rh: Long?, groupId: Long, itemIds: List): List? { val r = sendCmd(rh, CC.ApiDeleteMemberChatItem(groupId, itemIds)) if (r is API.Result && r.res is CR.ChatItemsDeleted) return r.res.chatItemDeletions - Log.e(TAG, "apiDeleteMemberChatItem bad response: ${r.responseType} ${r.details}") + apiErrorAlert("apiDeleteMemberChatItems", generalGetString(MR.strings.error_deleting_message), r) return null } @@ -8027,6 +8042,7 @@ data class AppSettings( var privacyAskToApproveRelays: Boolean? = null, var privacyAcceptImages: Boolean? = null, var privacyLinkPreviews: Boolean? = null, + var privacySanitizeLinks: Boolean? = null, var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null, var privacyShowChatPreviews: Boolean? = null, var privacySaveLastDraft: Boolean? = null, @@ -8063,6 +8079,7 @@ data class AppSettings( if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays } if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages } if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews } + if (privacySanitizeLinks != def.privacySanitizeLinks) { empty.privacySanitizeLinks = privacySanitizeLinks } if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks } if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews } if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft } @@ -8110,6 +8127,7 @@ data class AppSettings( privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) } privacyAcceptImages?.let { def.privacyAcceptImages.set(it) } privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) } + privacySanitizeLinks?.let { def.privacySanitizeLinks.set(it) } privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) } privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) } privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) } @@ -8147,6 +8165,7 @@ data class AppSettings( privacyAskToApproveRelays = true, privacyAcceptImages = true, privacyLinkPreviews = true, + privacySanitizeLinks = false, privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK, privacyShowChatPreviews = true, privacySaveLastDraft = true, @@ -8185,6 +8204,7 @@ data class AppSettings( privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(), privacyAcceptImages = def.privacyAcceptImages.get(), privacyLinkPreviews = def.privacyLinkPreviews.get(), + privacySanitizeLinks = def.privacySanitizeLinks.get(), privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(), privacyShowChatPreviews = def.privacyShowChatPreviews.get(), privacySaveLastDraft = def.privacySaveLastDraft.get(), 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 1de47df7ce..b8dc9ff6d8 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 @@ -596,10 +596,38 @@ data class ThemeModeOverride ( } } -fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState?, bgLayer: GraphicsLayer?/*, shape: Shape = RectangleShape*/): Modifier { +// Canvas color for settings/info screens (drawn behind cards by themedBackground) +// and for the 2dp item divider inside section cards (matches canvas so dividers +// read as gaps showing the screen behind). +// LIGHT: formula derives off-white from palette bg + onBackground — lifts white +// cards above. DARK/BLACK: palette bg (cards already raised via founder's +// formula in Section.kt). SIMPLEX: gradient bottom stop (darker), since the +// canvas itself is a gradient drawn by themedBackgroundBrush. +fun canvasColorForCurrentTheme(): Color { + val theme = CurrentColors.value + val c = theme.colors + return when (theme.base) { + DefaultTheme.LIGHT -> c.background.mixWith(c.onBackground, 0.94f) + DefaultTheme.SIMPLEX -> c.background.darker(0.4f) + else -> c.background + } +} + +// Card background color for SectionView. LIGHT: pure white (raised above the +// off-white canvas). DARK/BLACK/SIMPLEX: founder's mixWith formula (lifts cards +// above palette bg using onBackground tint). +fun sectionCardColor(): Color { + val theme = CurrentColors.value + return if (theme.base == DefaultTheme.LIGHT) Color.White + else theme.colors.background.mixWith(theme.colors.onBackground, 0.95f) +} + +fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState?, bgLayer: GraphicsLayer?, overrideColor: Color? = null): Modifier { return drawBehind { copyBackgroundToAppBar(bgLayerSize, bgLayer) { - if (baseTheme == DefaultTheme.SIMPLEX) { + if (overrideColor != null) { + drawRect(overrideColor) + } else if (baseTheme == DefaultTheme.SIMPLEX) { drawRect(brush = themedBackgroundBrush()) } else { drawRect(CurrentColors.value.colors.background) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 061ea71016..a063477f84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -3,10 +3,9 @@ package chat.simplex.common.views.chat import InfoRow import InfoRowEllipsis import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview @@ -553,7 +552,7 @@ fun ChatInfoLayout( LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged) - SectionSpacer() + SectionDividerSpaced() Box( Modifier.fillMaxWidth(), @@ -573,10 +572,10 @@ fun ChatInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() if (customUserProfile != null) { - SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionView(generalGetString(MR.strings.incognito)) { SectionItemViewSpaceBetween { Text(generalGetString(MR.strings.incognito_random_profile)) Text(customUserProfile.chatViewName, color = Indigo) @@ -601,7 +600,7 @@ fun ChatInfoLayout( } WallpaperButton { - ModalManager.end.showModal { + ModalManager.end.showModal(cardScreen = true) { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { @@ -610,13 +609,13 @@ fun ChatInfoLayout( } } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + SectionDividerSpaced() val conn = contact.activeConn if (conn != null) { @@ -627,13 +626,13 @@ fun ChatInfoLayout( } if (contact.contactLink != null) { - SectionView(stringResource(MR.strings.address_section_title).uppercase()) { + SectionView(stringResource(MR.strings.address_section_title)) { SimpleXLinkQRCode(contact.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) } - SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) } - SectionDividerSpaced(maxTopPadding = true) + SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) + SectionDividerSpaced() } if (contact.ready && contact.active) { @@ -670,7 +669,7 @@ fun ChatInfoLayout( } } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() } SectionView { 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 af58996393..5299a5e686 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 @@ -317,6 +317,7 @@ fun ChatView( itemIds.sorted(), questionText = questionText, forAll = canDeleteForAll, + editorial = publicGroupEditor(chatInfo), deleteMessages = { ids, forAll -> deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) { selectedChatItems.value = null @@ -405,7 +406,7 @@ fun ChatView( val selectedItems: MutableState?> = mutableStateOf(null) ModalManager.end.showCustomModal { close -> val appBar = remember { mutableStateOf(null as @Composable (BoxScope.() -> Unit)?) } - ModalView(close, appBar = appBar.value) { + ModalView(close, cardScreen = true, appBar = appBar.value) { val chatInfo = remember { activeChat }.value?.chatInfo if (chatInfo is ChatInfo.Direct) { var contactInfo: Pair? by remember { mutableStateOf(preloadedContactInfo) } @@ -508,7 +509,7 @@ fun ChatView( if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() } - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(chatRh, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, close = close, closeAll = close) } @@ -800,7 +801,7 @@ fun ChatView( } is ChatInfo.ContactConnection -> { val close = { chatModel.chatId.value = null } - ModalView(close, showClose = appPlatform.isAndroid, content = { + ModalView(close, showClose = appPlatform.isAndroid, cardScreen = true, content = { ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, false, close) }) LaunchedEffect(chatInfo.id) { @@ -3192,7 +3193,7 @@ fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: withBGApi { setGroupMembers(rhId, groupInfo, chatModel) close?.invoke() - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } @@ -3203,7 +3204,7 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: ( withBGApi { val link = chatModel.controller.apiGetGroupLink(rhId, groupInfo.groupId) close?.invoke() - ModalManager.end.showModalCloseable(true) { + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, link, onGroupLinkUpdated = null, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } } @@ -3351,7 +3352,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List +fun publicGroupEditor(chatInfo: ChatInfo): Boolean = + chatInfo is ChatInfo.Group && chatInfo.groupInfo.useRelays && chatInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator + private fun keyForItem(item: ChatItem): ChatViewItemKey = ChatViewItemKey(item.id, item.meta.createdAt.toEpochMilliseconds()) private fun ViewConfiguration.bigTouchSlop(slop: Float = 50f) = object: ViewConfiguration { 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 d0782f6bb4..d874079238 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 @@ -2011,7 +2011,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState? relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId } } val removedCount = relayMembers.count { (_, m) -> relayMemberRemoved(m?.memberStatus) } - val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.RsActive && m?.activeConn?.connFailedErr == null } + val activeCount = relayMembers.count { (relay, m) -> !relayMemberRemoved(m?.memberStatus) && relay.relayStatus == RelayStatus.Active && m?.activeConn?.connFailedErr == null } val failedCount = relayMembers.count { (_, m) -> !relayMemberRemoved(m?.memberStatus) && m?.activeConn?.connFailedErr != null } val noActiveRelays = activeCount == 0 && (failedCount + removedCount) == relays.size return OwnerRelayState(relays, activeCount, failedCount, removedCount, noActiveRelays) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt index 7c04c30f67..0276727ccc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContactPreferences.kt @@ -2,12 +2,13 @@ package chat.simplex.common.views.chat import InfoRow import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* +import androidx.compose.ui.Modifier import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* @@ -54,6 +55,7 @@ fun ContactPreferencesView( if (featuresAllowed == currentFeaturesAllowed) close() else showUnsavedChangesAlert({ savePrefs(close) }, close) }, + cardScreen = true, ) { ContactPreferencesLayout( featuresAllowed, @@ -90,27 +92,27 @@ private fun ContactPreferencesLayout( TimedMessagesFeatureSection(featuresAllowed, contact.mergedPreferences.timedMessages, timedMessages, onTTLUpdated) { allowed, ttl -> applyPrefs(featuresAllowed.copy(timedMessagesAllowed = allowed, timedMessagesTTL = ttl ?: currentFeaturesAllowed.timedMessagesTTL)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowFullDeletion: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.fullDelete) } FeatureSection(ChatFeature.FullDelete, user.fullPreferences.fullDelete.allow, contact.mergedPreferences.fullDelete, allowFullDeletion) { applyPrefs(featuresAllowed.copy(fullDelete = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowReactions: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.reactions) } FeatureSection(ChatFeature.Reactions, user.fullPreferences.reactions.allow, contact.mergedPreferences.reactions, allowReactions) { applyPrefs(featuresAllowed.copy(reactions = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowVoice: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.voice) } FeatureSection(ChatFeature.Voice, user.fullPreferences.voice.allow, contact.mergedPreferences.voice, allowVoice) { applyPrefs(featuresAllowed.copy(voice = it)) } - SectionDividerSpaced(true) + SectionDividerSpaced() val allowCalls: MutableState = remember(featuresAllowed) { mutableStateOf(featuresAllowed.calls) } FeatureSection(ChatFeature.Calls, user.fullPreferences.calls.allow, contact.mergedPreferences.calls, allowCalls) { applyPrefs(featuresAllowed.copy(calls = it)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = savePrefs, @@ -135,7 +137,7 @@ private fun FeatureSection( ) SectionView( - feature.text.uppercase(), + feature.text, icon = feature.iconFilled(), iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, leadingIcon = true, @@ -170,7 +172,7 @@ private fun TimedMessagesFeatureSection( ) SectionView( - ChatFeature.TimedMessages.text.uppercase(), + ChatFeature.TimedMessages.text, icon = ChatFeature.TimedMessages.iconFilled(), iconTint = if (enabled.forUser) SimplexGreen else if (enabled.forContact) WarningYellow else Color.Red, leadingIcon = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt index 9298b600e9..0749df7775 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupMembersView.kt @@ -5,7 +5,6 @@ import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionItemViewWithoutMinPadding -import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -161,7 +160,7 @@ fun AddGroupMembersLayout( iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight ) } - SectionSpacer() + SectionDividerSpaced() if (contactsToAdd.isEmpty() && searchText.value.text.isEmpty()) { Row( @@ -195,8 +194,8 @@ fun AddGroupMembersLayout( SectionCustomFooter { InviteSectionFooter(selectedContactsCount = selectedContacts.size, allowModifyMembers, clearSelection) } - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.select_contacts).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.select_contacts)) { SectionItemView(padding = PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)) { SearchRowView(searchText) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt index d0c2486069..1ed75bd2a2 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -5,6 +5,7 @@ import SectionCustomFooter import SectionDividerSpaced import SectionItemView import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -131,8 +132,8 @@ private fun AddGroupRelayLayout( fontSize = 14.sp ) } - SectionDividerSpaced(maxTopPadding = true) - SectionView(generalGetString(MR.strings.select_relays).uppercase()) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.select_relays)) { availableRelays.forEach { item -> val selected = item.relayId in selectedRelayIds SectionItemView( 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 index 0cf3a3c96f..bcf8048971 100644 --- 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 @@ -44,7 +44,7 @@ fun ChannelMembersView( if (groupInfo.isOwner) { val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong() - SectionView(title = subscriberCountStr(subscriberCount).uppercase()) { + SectionView(title = subscriberCountStr(subscriberCount)) { SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) { ChannelMemberRow(groupInfo.membership, user = true, showRole = true) } 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 index 891753aed8..d99e16d15f 100644 --- 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 @@ -114,8 +114,10 @@ private fun ChannelRelaysLayout( if (groupInfo.isOwner) { SectionView { SectionItemView(click = { - val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet() - ModalManager.end.showModalCloseable(true) { close -> + // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays + // regardless of relayStatus, so all current rows must be excluded from the add list. + val existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet() + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> AddGroupRelayView( groupInfo = groupInfo, existingRelayIds = existingRelayIds, @@ -179,7 +181,10 @@ private fun subscriberRelayStatusText(member: GroupMember): String { } private fun ownerRelayStatusText(member: GroupMember, groupRelays: List): String { - return if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) { + val relayStatus = groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }?.relayStatus + return if (relayStatus == RelayStatus.Rejected) { + generalGetString(MR.strings.relay_status_rejected) + } else if (member.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)) { relayConnStatus(member).first } else if (member.activeConn?.connStatus is ConnStatus.Failed) { generalGetString(MR.strings.relay_conn_status_failed) @@ -188,8 +193,7 @@ private fun ownerRelayStatusText(member: GroupMember, groupRelays: List + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, false, chatModel, close) } } @@ -126,7 +130,7 @@ fun ModalData.GroupChatInfoView( } else { member to null } - ModalManager.end.showModalCloseable(true) { closeCurrent -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = false, groupRelay = groupRelay, close = closeCurrent) { closeCurrent() @@ -167,7 +171,7 @@ fun ModalData.GroupChatInfoView( clearChat = { clearChatDialog(chat, close) }, leaveGroup = { leaveGroupDialog(rhId, groupInfo, chatModel, close) }, manageGroupLink = { - ModalManager.end.showModal { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } + ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) } }, onSearchClicked = onSearchClicked, deletingItems = deletingItems @@ -552,7 +556,7 @@ fun ModalData.GroupChatInfoLayout( LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged) - SectionSpacer() + SectionDividerSpaced() Box( Modifier.fillMaxWidth(), @@ -581,10 +585,10 @@ fun ModalData.GroupChatInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() if (groupInfo.useRelays && groupInfo.membership.memberIncognito) { - SectionView(generalGetString(MR.strings.incognito).uppercase()) { + SectionView(generalGetString(MR.strings.incognito)) { SectionItemViewSpaceBetween { Text(generalGetString(MR.strings.incognito_random_profile)) Text(groupInfo.membership.chatViewName, color = Indigo) @@ -658,7 +662,7 @@ fun ModalData.GroupChatInfoLayout( } } if (anyTopSectionRowShow) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() } SectionView { if (groupInfo.isOwner && groupInfo.businessChat?.chatType == null) { @@ -677,7 +681,7 @@ fun ModalData.GroupChatInfoLayout( else 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) + SectionDividerSpaced() SectionView { if (!groupInfo.useRelays) { @@ -688,7 +692,7 @@ fun ModalData.GroupChatInfoLayout( } } WallpaperButton { - ModalManager.end.showModal { + ModalManager.end.showModal(cardScreen = true) { val chat = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chat.id } } } val c = chat.value if (c != null) { @@ -697,12 +701,12 @@ fun ModalData.GroupChatInfoLayout( } } ChatTTLOption(chatItemTTL, setChatItemTTL, deletingItems) - SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = true) + SectionTextFooter(stringResource(MR.strings.chat_ttl_options_footer)) + SectionDividerSpaced() if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1)) { + SectionView(title = String.format(generalGetString(MR.strings.group_info_section_title_num_members), activeSortedMembers.count() + 1), cardShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)) { if (groupInfo.canAddMembers) { val onAddMembersClick = if (chat.chatInfo.incognito) ::cantInviteIncognitoAlert else addMembers val tint = if (chat.chatInfo.incognito) MaterialTheme.colors.secondary else MaterialTheme.colors.primary @@ -725,32 +729,36 @@ fun ModalData.GroupChatInfoLayout( } } if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - items(filteredMembers.value, key = { it.groupMemberId }) { member -> - Divider() - val showMenu = remember { mutableStateOf(false) } - val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator - SectionItemViewLongClickable( - click = { - if (selectedItems.value != null) { - if (canBeSelected) { - toggleItemSelection(member.groupMemberId, selectedItems) + itemsIndexed(filteredMembers.value, key = { _, m -> m.groupMemberId }) { index, member -> + val isLast = index == filteredMembers.value.lastIndex + val shape = if (isLast) RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp) else RectangleShape + Column(Modifier.padding(horizontal = CARD_PADDING).fillMaxWidth().clip(shape).background(sectionCardColor())) { + Divider() + val showMenu = remember { mutableStateOf(false) } + val canBeSelected = groupInfo.membership.memberRole >= member.memberRole && member.memberRole < GroupMemberRole.Moderator + SectionItemViewLongClickable( + click = { + if (selectedItems.value != null) { + if (canBeSelected) { + toggleItemSelection(member.groupMemberId, selectedItems) + } + } else { + showMemberInfo(member, null) + } + }, + longClick = { showMenu.value = true }, + minHeight = 54.dp, + padding = PaddingValues(horizontal = DEFAULT_PADDING) + ) { + Box(contentAlignment = Alignment.CenterStart) { + androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) + } + val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) + DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) + Box(Modifier.padding(start = selectionOffset)) { + MemberRow(member) } - } else { - showMemberInfo(member, null) - } - }, - longClick = { showMenu.value = true }, - minHeight = 54.dp, - padding = PaddingValues(horizontal = DEFAULT_PADDING) - ) { - Box(contentAlignment = Alignment.CenterStart) { - androidx.compose.animation.AnimatedVisibility(selectedItems.value != null, enter = fadeIn(), exit = fadeOut()) { - SelectedListItem(Modifier.alpha(if (canBeSelected) 1f else 0f).padding(start = 2.dp), member.groupMemberId, selectedItems) - } - val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp) - DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu) - Box(Modifier.padding(start = selectionOffset)) { - MemberRow(member) } } } @@ -758,7 +766,7 @@ fun ModalData.GroupChatInfoLayout( } item { if (!groupInfo.nextConnectPrepared && !groupInfo.useRelays) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() } SectionView { if (groupInfo.useRelays && (groupInfo.isOwner || activeSortedMembers.any { it.memberRole == GroupMemberRole.Relay })) { @@ -1186,7 +1194,9 @@ private fun ChannelLinkButton(onClick: () -> Unit) { @Composable private fun ChannelLinkQRCodeSection(groupLink: String) { val clipboard = LocalClipboardManager.current - SimpleXLinkQRCode(connReq = groupLink) + Box(Modifier.padding(vertical = DEFAULT_PADDING_HALF)) { + SimpleXLinkQRCode(connReq = groupLink) + } SectionItemView({ clipboard.shareText(simplexChatLink(groupLink)) }) { 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 57ba0fbd88..b68f0efaf5 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 @@ -1,7 +1,9 @@ package chat.simplex.common.views.chat.group import SectionBottomSpacer +import SectionDividerSpaced import SectionItemView +import SectionView import SectionViewWithButton import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -215,7 +217,10 @@ fun GroupLinkLayout( } } else { if (!isChannel) { - RoleSelectionRow(groupInfo, groupLinkMemberRole) + SectionView { + RoleSelectionRow(groupInfo, groupLinkMemberRole) + } + SectionDividerSpaced() } var initialLaunch by remember { mutableStateOf(true) } LaunchedEffect(groupLinkMemberRole.value) { @@ -225,69 +230,70 @@ fun GroupLinkLayout( initialLaunch = false } val showShortLink = remember { mutableStateOf(true) } - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) SectionViewWithButton( titleButton = if (!isChannel && groupLink.connLinkContact.connShortLink != null) { { ToggleShortLinkButton(showShortLink) } } else null) { - SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) - } - if (!isChannel && groupLink.shouldBeUpgraded) { + Box(Modifier.padding(vertical = DEFAULT_PADDING_HALF)) { + SimpleXCreatedLinkQRCode(groupLink.connLinkContact, short = showShortLink.value) + } + if (!isChannel && groupLink.shouldBeUpgraded) { + SettingsActionItem( + painterResource(MR.images.ic_add), + stringResource(MR.strings.upgrade_group_link), + click = { showAddShortLinkAlert(null) }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + val clipboard = LocalClipboardManager.current SettingsActionItem( - painterResource(MR.images.ic_add), - stringResource(MR.strings.upgrade_group_link), - click = { showAddShortLinkAlert(null) }, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) - } - val clipboard = LocalClipboardManager.current - SettingsActionItem( - painterResource(MR.images.ic_share), - stringResource(MR.strings.share_link), - click = { - if (!isChannel && groupLink.shouldBeUpgraded) { - showAddShortLinkAlert { + painterResource(MR.images.ic_share), + stringResource(MR.strings.share_link), + click = { + if (!isChannel && groupLink.shouldBeUpgraded) { + showAddShortLinkAlert { + clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) + } + } else { clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) } - } else { - clipboard.shareText(groupLink.connLinkContact.simplexChatUri(short = showShortLink.value)) - } - }, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) - if (shareGroupInfo != null) { - SettingsActionItem( - painterResource(MR.images.ic_forward), - stringResource(MR.strings.share_via_chat), - click = { - chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) - chatModel.chatId.value = null - ModalManager.closeAllModalsEverywhere() }, iconColor = MaterialTheme.colors.primary, textColor = MaterialTheme.colors.primary, ) - } - if (!creatingGroup && !isChannel) { - SettingsActionItem( - painterResource(MR.images.ic_delete), - stringResource(MR.strings.delete_link), - click = deleteLink, - iconColor = Color.Red, - textColor = Color.Red, - ) - } - if (creatingGroup && close != null) { - SettingsActionItem( - painterResource(MR.images.ic_check), - stringResource(MR.strings.continue_to_next_step), - click = close, - iconColor = MaterialTheme.colors.primary, - textColor = MaterialTheme.colors.primary, - ) + if (shareGroupInfo != null && isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_forward), + stringResource(MR.strings.share_via_chat), + click = { + chatModel.sharedContent.value = SharedContent.ChatLink(shareGroupInfo) + chatModel.chatId.value = null + ModalManager.closeAllModalsEverywhere() + }, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } + if (!creatingGroup && !isChannel) { + SettingsActionItem( + painterResource(MR.images.ic_delete), + stringResource(MR.strings.delete_link), + click = deleteLink, + iconColor = Color.Red, + textColor = Color.Red, + ) + } + if (creatingGroup && close != null) { + SettingsActionItem( + painterResource(MR.images.ic_check), + stringResource(MR.strings.continue_to_next_step), + click = close, + iconColor = MaterialTheme.colors.primary, + textColor = MaterialTheme.colors.primary, + ) + } } } } 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 28cbb663a6..8117f674f6 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 @@ -2,12 +2,12 @@ package chat.simplex.common.views.chat.group import InfoRow import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.InlineTextContent @@ -423,7 +423,7 @@ fun GroupMemberInfoLayout( // TODO [relays] re-enable when relay management ships val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay if (canBlockForAll || canRemove) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { if (canBlockForAll) { if (member.blockedByAdmin) { @@ -445,7 +445,7 @@ fun GroupMemberInfoLayout( @Composable fun NonAdminBlockSection() { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { if (member.blockedByAdmin) { SettingsActionItem( @@ -469,7 +469,7 @@ fun GroupMemberInfoLayout( ) { GroupMemberInfoHeader(member) } - SectionSpacer() + SectionDividerSpaced() val contactId = member.memberContactId @@ -533,7 +533,7 @@ fun GroupMemberInfoLayout( } } - SectionSpacer() + SectionDividerSpaced() } val showMemberSupportChat = !openedFromSupportChat && @@ -566,7 +566,7 @@ fun GroupMemberInfoLayout( } if (member.contactLink != null) { - SectionView(stringResource(MR.strings.address_section_title).uppercase()) { + SectionView(stringResource(MR.strings.address_section_title)) { SimpleXLinkQRCode(member.contactLink) val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } @@ -577,8 +577,8 @@ fun GroupMemberInfoLayout( } else { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) } - SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(member.displayName)) } + SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(member.displayName)) SectionDividerSpaced() } @@ -616,6 +616,9 @@ fun GroupMemberInfoLayout( val clipboard = LocalClipboardManager.current ShareRelayAddressButton { clipboard.shareText(simplexChatLink(relayAddress)) } } + if (groupRelay?.relayStatus == RelayStatus.Rejected) { + InfoRow(stringResource(MR.strings.member_info_status), stringResource(MR.strings.member_info_relay_status_rejected_by_operator)) + } } if (groupInfo.useRelays && member.memberRole == GroupMemberRole.Relay) { SectionTextFooter( 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 740349eaea..d8be4998be 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 @@ -6,9 +6,11 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.runtime.saveable.rememberSaveable import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.compose.stringResource @@ -64,6 +66,7 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> if (preferences == currentPreferences) close() else showUnsavedChangesAlert({ savePrefs(close) }, close, saveTextId) }, + cardScreen = true, ) { GroupPreferencesLayout( preferences, @@ -182,37 +185,39 @@ private fun GroupPreferencesLayout( AppBarTitle(stringResource(titleId)) if (!groupInfo.useRelays) { if (groupInfo.businessChat == null) { - MemberAdmissionButton(openMemberAdmission) - SectionDividerSpaced(maxBottomPadding = false) + SectionView { + MemberAdmissionButton(openMemberAdmission) + } + SectionDividerSpaced() } TimedMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() DirectMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FullDeletePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReactionsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() VoicePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FilesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SimplexLinksPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReportsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() HistoryPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SupportPreference(disabled = true) } else { TimedMessagesPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() FullDeletePreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() ReactionsPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() HistoryPreference() - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() SupportPreference(notice = generalGetString(MR.strings.chat_with_admins_relay_note), onEnable = { revert -> AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.enable_chats_with_admins_question), @@ -225,7 +230,7 @@ private fun GroupPreferencesLayout( }) } if (groupInfo.isOwner) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() val saveTextId = if (groupInfo.useRelays) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members ResetSaveButtons( 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 7c9db58316..544af8ed7e 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 @@ -6,7 +6,10 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -49,6 +52,7 @@ fun MemberAdmissionView(m: ChatModel, rhId: Long?, chatId: String, close: () -> if (admission == currentAdmission) close() else showUnsavedChangesAlert({ saveAdmission(close) }, close) }, + cardScreen = true, ) { MemberAdmissionLayout( admission, @@ -85,7 +89,7 @@ private fun MemberAdmissionLayout( } } if (groupInfo.isOwner) { - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = saveAdmission, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt index 6680ef99bc..180c9f9d23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportChatView.kt @@ -85,7 +85,7 @@ fun MemberSupportChatAppBar( } else { null } - ModalManager.end.showModalCloseable(true) { closeCurrent -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { closeCurrent -> remember { derivedStateOf { chatModel.getGroupMember(scopeMember_.groupMemberId) } }.value?.let { mem -> GroupMemberInfoView(rhId, groupInfo, mem, scrollToItemId, stats, code, chatModel, openedFromSupportChat = true, close = closeCurrent) { closeCurrent() 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 064b5370bc..8bfbea9fa6 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 @@ -182,7 +182,7 @@ fun CIImageView( .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()) + Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f)) } else Modifier ) .desktopModifyBlurredState(!smallView, blurred, showMenu), 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 15b0a12822..64288d9055 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 @@ -374,7 +374,7 @@ fun ChatItemView( @Composable fun DeleteItemMenu() { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -392,7 +392,7 @@ fun ChatItemView( if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) { ArchiveReportItemAction(cItem.id, cInfo.groupInfo.membership.memberActive, showMenu, archiveReports) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report)) Divider() SelectItemAction(showMenu, selectChatItem) } @@ -482,7 +482,7 @@ fun ChatItemView( CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction) } if (!(live && cItem.meta.isLive) && !preview) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) } if (cItem.chatDir !is CIDirection.GroupSnd) { val groupInfo = cItem.memberToModerate(cInfo)?.first @@ -508,7 +508,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -518,7 +518,7 @@ fun ChatItemView( cItem.isDeletedContent -> { DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -532,7 +532,7 @@ fun ChatItemView( } else { ExpandItemAction(revealed, showMenu, reveal) } - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -541,7 +541,7 @@ fun ChatItemView( } else -> { DefaultDropdownMenu(showMenu) { - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (selectedChatItems.value == null) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -558,7 +558,7 @@ fun ChatItemView( RevealItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -587,7 +587,7 @@ fun ChatItemView( DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) DefaultDropdownMenu(showMenu) { ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -661,7 +661,7 @@ fun ChatItemView( ExpandItemAction(revealed, showMenu, reveal) } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) - DeleteItemAction(chatsCtx, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) + DeleteItemAction(chatsCtx, cInfo, cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages) if (cItem.canBeDeletedForSelf) { Divider() SelectItemAction(showMenu, selectChatItem) @@ -866,6 +866,7 @@ fun ItemInfoAction( @Composable fun DeleteItemAction( chatsCtx: ChatModel.ChatsContext, + cInfo: ChatInfo, cItem: ChatItem, revealed: State, showMenu: MutableState, @@ -898,13 +899,13 @@ fun DeleteItemAction( deleteMessages = { ids, _ -> deleteMessages(ids) } ) } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } } else { - deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage) + deleteMessageAlertDialog(cItem, questionText, cInfo, deleteMessage = deleteMessage) } }, color = Color.Red @@ -1371,7 +1372,9 @@ fun cancelFileAlertDialog(fileId: Long, cancelFile: (Long) -> Unit, cancelAction ) } -fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) { +fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, chatInfo: ChatInfo, deleteMessage: (Long, CIDeleteMode) -> Unit) { + val canDeleteForEveryone = chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport + val editorial = publicGroupEditor(chatInfo) AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_message__question), text = questionText, @@ -1382,11 +1385,18 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes .padding(horizontal = 8.dp, vertical = 2.dp), horizontalArrangement = Arrangement.Center, ) { - TextButton(onClick = { - deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) - AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) { + if (editorial) { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmHistory) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.from_history), color = MaterialTheme.colors.error) } + } else { + TextButton(onClick = { + deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) + AlertManager.shared.hideAlert() + }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + } + if (canDeleteForEveryone) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -1398,7 +1408,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes ) } -fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, deleteMessages: (List, Boolean) -> Unit) { +fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: Boolean, editorial: Boolean = false, deleteMessages: (List, Boolean) -> Unit) { AlertManager.shared.showAlertDialogButtons( title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size), text = questionText, @@ -1412,7 +1422,7 @@ fun deleteMessagesAlertDialog(itemIds: List, questionText: String, forAll: TextButton(onClick = { deleteMessages(itemIds, false) AlertManager.shared.hideAlert() - }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } + }) { Text(stringResource(if (editorial) MR.strings.from_history else MR.strings.for_me_only), color = MaterialTheme.colors.error) } if (forAll) { TextButton(onClick = { 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 0cec9ab773..d3533bbd02 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 @@ -583,7 +583,7 @@ fun ContactConnectionMenuItems(rhId: Long?, chatInfo: ChatInfo.ContactConnection onClick = { ModalManager.center.closeModals() ModalManager.end.closeModals() - ModalManager.center.showModalCloseable(true, showClose = appPlatform.isAndroid) { close -> + ModalManager.center.showModalCloseable(settings = true, showClose = appPlatform.isAndroid, cardScreen = true) { close -> ContactConnectionInfoView(chatModel, rhId, chatInfo.contactConnection.connLinkInv, chatInfo.contactConnection, true, close) } showMenu.value = false 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 01dcd021f7..94b13a8270 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 @@ -1,5 +1,6 @@ package chat.simplex.common.views.chatlist +import LocalCardScreen import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -572,7 +573,7 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow navigationButton = { if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) { NavigationButtonMenu { - ModalManager.start.showModalCloseable { close -> + ModalManager.start.showModalCloseable(cardScreen = true) { close -> SettingsView(chatModel, setPerformLA, close) } } @@ -854,8 +855,8 @@ enum class ScrollDirection { @Composable fun BoxScope.StatusBarBackground() { if (appPlatform.isAndroid) { - val finalColor = MaterialTheme.colors.background.copy(0.88f) - Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor)) + val bg = if (LocalCardScreen.current) canvasColorForCurrentTheme() else MaterialTheme.colors.background + Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(bg.copy(0.88f))) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt index ed1c7116e6..5d94b9c2d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ServersSummaryView.kt @@ -10,6 +10,7 @@ import SectionView import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.background import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -151,7 +152,7 @@ enum class PresentedServerType { @Composable private fun ServerSessionsView(sess: ServerSessions) { - SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_sessions_connected), numOrDash(sess.ssConnected) @@ -293,7 +294,7 @@ private fun XFTPServersListView(servers: List, statsStartedAt @Composable private fun SMPStatsView(stats: AgentSMPServerStatsData, statsStartedAt: Instant, remoteHostInfo: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_statistics_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_messages_sent), numOrDash(stats._sentDirect + stats._sentViaProxy) @@ -329,7 +330,7 @@ private fun SMPSubscriptionsSection(totals: SMPTotals) { horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2) ) { Text( - generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(), + generalGetString(MR.strings.servers_info_subscriptions_section_header), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp @@ -359,7 +360,7 @@ private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSumma horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2) ) { Text( - generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(), + generalGetString(MR.strings.servers_info_subscriptions_section_header), color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp @@ -415,7 +416,7 @@ private fun reconnectServerAlert(rh: RemoteHostInfo?, server: String) { @Composable fun XFTPStatsView(stats: AgentXFTPServerStatsData, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_statistics_section_header)) { InfoRow( generalGetString(MR.strings.servers_info_uploaded), prettySize(stats._uploadsSize) @@ -449,7 +450,7 @@ private fun IndentedInfoRow(title: String, desc: String) { @Composable fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Instant) { - SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header)) { InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_total), numOrDash(stats._sentDirect + stats._sentViaProxy)) InfoRowTwoValues(generalGetString(MR.strings.sent_directly), generalGetString(MR.strings.attempts_label), stats._sentDirect, stats._sentDirectAttempts) InfoRowTwoValues(generalGetString(MR.strings.sent_via_proxy), generalGetString(MR.strings.attempts_label), stats._sentViaProxy, stats._sentViaProxyAttempts) @@ -465,7 +466,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta SectionDividerSpaced() - SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header).uppercase()) { + SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header)) { InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_received_total), numOrDash(stats._recvMsgs)) SectionItemView { Text(generalGetString(MR.strings.servers_info_detailed_statistics_receive_errors), color = MaterialTheme.colors.onBackground) @@ -483,7 +484,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta SectionDividerSpaced() - SectionView(generalGetString(MR.strings.connections).uppercase()) { + SectionView(generalGetString(MR.strings.connections)) { InfoRow(generalGetString(MR.strings.created), numOrDash(stats._connCreated)) InfoRow(generalGetString(MR.strings.secured), numOrDash(stats._connSecured)) InfoRow(generalGetString(MR.strings.completed), numOrDash(stats._connCompleted)) @@ -502,7 +503,7 @@ fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Insta @Composable fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Instant) { - SectionView(generalGetString(MR.strings.uploaded_files).uppercase()) { + SectionView(generalGetString(MR.strings.uploaded_files)) { InfoRow(generalGetString(MR.strings.size), prettySize(stats._uploadsSize)) InfoRowTwoValues(generalGetString(MR.strings.chunks_uploaded), generalGetString(MR.strings.attempts_label), stats._uploads, stats._uploadAttempts) InfoRow(generalGetString(MR.strings.upload_errors), numOrDash(stats._uploadErrs)) @@ -510,7 +511,7 @@ fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Ins InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._deleteErrs)) } SectionDividerSpaced() - SectionView(generalGetString(MR.strings.downloaded_files).uppercase()) { + SectionView(generalGetString(MR.strings.downloaded_files)) { InfoRow(generalGetString(MR.strings.size), prettySize(stats._downloadsSize)) InfoRowTwoValues(generalGetString(MR.strings.chunks_downloaded), generalGetString(MR.strings.attempts_label), stats._downloads, stats._downloadAttempts) SectionItemView { @@ -528,7 +529,7 @@ fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Ins @Composable fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.server_address).uppercase()) { + SectionView(generalGetString(MR.strings.server_address)) { SelectionContainer { Text( summary.xftpServer, @@ -546,7 +547,7 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, if (summary.stats != null) { XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt) if (summary.sessions != null) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } } @@ -560,7 +561,7 @@ fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, @Composable fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) { - SectionView(generalGetString(MR.strings.server_address).uppercase()) { + SectionView(generalGetString(MR.strings.server_address)) { SelectionContainer { Text( summary.smpServer, @@ -578,7 +579,7 @@ fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, r if (summary.stats != null) { SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt) if (summary.subs != null || summary.sessions != null) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } } @@ -605,7 +606,8 @@ fun ModalData.SMPServerSummaryView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { val bottomPadding = DEFAULT_PADDING @@ -628,7 +630,8 @@ fun ModalData.DetailedXFTPStatsView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -652,7 +655,8 @@ fun ModalData.DetailedSMPStatsView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -676,7 +680,8 @@ fun ModalData.XFTPServerSummaryView( statsStartedAt: Instant ) { ModalView( - close = close + close = close, + cardScreen = true, ) { ColumnWithScrollBar { Box(contentAlignment = Alignment.Center) { @@ -839,7 +844,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta val statsStartedAt = it.statsStartedAt SMPStatsView(totals.stats, statsStartedAt, rh) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() SMPSubscriptionsSection(totals) SectionDividerSpaced() @@ -847,7 +852,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = currentlyUsedSMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_connected_servers_section_header), rh = rh ) SectionDividerSpaced() @@ -857,7 +862,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = previouslyUsedSMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header), rh = rh ) SectionDividerSpaced() @@ -867,11 +872,11 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta SMPServersListView( servers = proxySMPServers, statsStartedAt = statsStartedAt, - header = generalGetString(MR.strings.servers_info_proxied_servers_section_header).uppercase(), + header = generalGetString(MR.strings.servers_info_proxied_servers_section_header), footer = generalGetString(MR.strings.servers_info_proxied_servers_section_footer), rh = rh ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } ServerSessionsView(totals.sessions) @@ -888,13 +893,13 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta val previouslyUsedXFTPServers = xftpSummary.previouslyUsedXFTPServers XFTPStatsView(totals.stats, statsStartedAt, rh) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() if (currentlyUsedXFTPServers.isNotEmpty()) { XFTPServersListView( currentlyUsedXFTPServers, statsStartedAt, - generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(), + generalGetString(MR.strings.servers_info_connected_servers_section_header), rh ) SectionDividerSpaced() @@ -904,7 +909,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta XFTPServersListView( previouslyUsedXFTPServers, statsStartedAt, - generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(), + generalGetString(MR.strings.servers_info_previously_connected_servers_section_header), rh ) SectionDividerSpaced() @@ -915,7 +920,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { ReconnectAllServersButton(rh) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 2be17052ad..96af5337d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -196,7 +196,7 @@ private fun ShareList( val oneHandUI = remember { appPrefs.oneHandUI.state } val chats by remember(search) { derivedStateOf { - val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled }.sortedByDescending { it.chatInfo is ChatInfo.Local } + val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted) } } 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 c6cc887655..fe61859937 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 @@ -1,7 +1,6 @@ package chat.simplex.common.views.chatlist import SectionCustomFooter -import SectionDivider import SectionItemView import TextIconSpaced import androidx.compose.animation.core.animateDpAsState @@ -157,7 +156,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary) } } - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } } } 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 a02e0dc768..5cf7d9325f 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 @@ -380,7 +380,7 @@ private fun GlobalSettingsSection( SectionItemView( click = { - ModalManager.start.showModalCloseable { close -> + ModalManager.start.showModalCloseable(cardScreen = true) { close -> SettingsView(chatModel, setPerformLA, close) } }, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt index 1c1c37b7ac..b656b8b8da 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseEncryptionView.kt @@ -119,7 +119,7 @@ fun DatabaseEncryptionLayout( ChatStoppedView() SectionSpacer() } - SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) { + SectionView(if (migration) generalGetString(MR.strings.database_passphrase) else null) { SavePassphraseSetting( useKeychain.value, initialRandomDBPassphrase.value, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index d55d89f26b..648a0eb8e7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.foundation.background import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter @@ -170,7 +171,7 @@ fun DatabaseLayout( AppBarTitle(stringResource(MR.strings.your_chat_database)) if (!chatModel.desktopNoUserNoRemote) { - SectionView(stringResource(MR.strings.messages_section_title).uppercase()) { + SectionView(stringResource(MR.strings.messages_section_title)) { TtlOptions(chatItemTTL, enabled = rememberUpdatedState(!stopped && !progressIndicator), onChatItemTTLSelected) } SectionTextFooter( @@ -184,7 +185,7 @@ fun DatabaseLayout( } } ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } val toggleEnabled = remember { chatModel.remoteHosts }.none { it.sessionState is RemoteHostSessionState.Connected } if (chatModel.localUserCreated.value == true) { @@ -200,7 +201,7 @@ fun DatabaseLayout( RunChatSetting(stopped, toggleEnabled && !progressIndicator, startChat, stopChatAlert) } if (stopped) SectionTextFooter(stringResource(MR.strings.you_must_use_the_most_recent_version_of_database)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() } SectionView(stringResource(MR.strings.chat_database_section)) { @@ -214,7 +215,7 @@ fun DatabaseLayout( if (unencrypted) painterResource(MR.images.ic_lock_open_right) else if (useKeyChain) painterResource(MR.images.ic_vpn_key_filled) else painterResource(MR.images.ic_lock), stringResource(MR.strings.database_passphrase), - click = { ModalManager.start.showModal { DatabaseEncryptionView(chatModel, false) } }, + click = { ModalManager.start.showModal(cardScreen = true) { DatabaseEncryptionView(chatModel, false) } }, iconColor = if (unencrypted || (appPlatform.isDesktop && passphraseSaved)) WarningOrange else MaterialTheme.colors.secondary, disabled = operationsDisabled ) @@ -262,7 +263,7 @@ fun DatabaseLayout( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.files_and_media_section).uppercase()) { + SectionView(stringResource(MR.strings.files_and_media_section)) { val deleteFilesDisabled = operationsDisabled || appFilesCountAndSize.value.first == 0 SectionItemView( deleteAppFilesAndMedia, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt index 28c81fbf56..02c0b45de4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ModalView.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* +import LocalCardScreen import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chatlist.StatusBarBackground import chat.simplex.common.views.onboarding.OnboardingStage @@ -27,6 +28,7 @@ fun ModalView( showAppBar: Boolean = true, enableClose: Boolean = true, background: Color = Color.Unspecified, + cardScreen: Boolean = false, modifier: Modifier = Modifier, showSearch: Boolean = false, searchAlwaysVisible: Boolean = false, @@ -40,7 +42,9 @@ fun ModalView( } val oneHandUI = remember { derivedStateOf { if (appPrefs.onboardingStage.state.value == OnboardingStage.OnboardingComplete) appPrefs.oneHandUI.state.value else false } } Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) { - Box(if (background != Color.Unspecified) Modifier.background(background) else Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) { + val bgOverride = if (cardScreen) canvasColorForCurrentTheme() else if (background != Color.Unspecified) background else null + CompositionLocalProvider(LocalCardScreen provides cardScreen) { + Box(Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer, overrideColor = bgOverride)) { Box(modifier = modifier) { content() } @@ -66,6 +70,7 @@ fun ModalView( } } } + } } } @@ -111,15 +116,15 @@ class ModalManager(private val placement: ModalPlacement? = null) { fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id - fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { + fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, forceAnimated: Boolean = false, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) { showCustomModal(id = id, forceAnimated = forceAnimated) { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() }) + ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content() }) } } - fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { + fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, cardScreen: Boolean = false, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) { showCustomModal(id = id) { close -> - ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) }) + ModalView(close, showClose = showClose, cardScreen = cardScreen, endButtons = endButtons, content = { content(close) }) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index 7ee52af784..9afcdd0b94 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -1,9 +1,15 @@ 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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.Layout import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalDensity @@ -12,6 +18,7 @@ import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* +import androidx.compose.ui.text.font.FontWeight import chat.simplex.common.platform.onRightClick import chat.simplex.common.platform.windowWidth import chat.simplex.common.ui.theme.* @@ -20,16 +27,82 @@ import chat.simplex.common.views.onboarding.SelectableCard import chat.simplex.common.views.usersettings.SettingsActionItemWithContent import chat.simplex.res.MR +private val SectionCardShape = RoundedCornerShape(16.dp) +val CARD_PADDING = 18.dp +val ICON_TEXT_SPACING = 8.dp + +val LocalCardScreen = staticCompositionLocalOf { false } + +val itemHPadding: Dp + @Composable get() = if (LocalCardScreen.current) CARD_PADDING else DEFAULT_PADDING + @Composable -fun SectionView(title: String? = null, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { +private fun CardColumnLayout( + contentPadding: PaddingValues = PaddingValues(), + cardShape: Shape = SectionCardShape, + content: @Composable () -> Unit +) { + val dividerColor = canvasColorForCurrentTheme() + val dividerPx = with(LocalDensity.current) { 2.dp.toPx() } + val childBottoms = remember { mutableListOf() } + Layout( + content = content, + modifier = Modifier + .padding(horizontal = CARD_PADDING) + .fillMaxWidth() + .clip(cardShape) + .background(sectionCardColor()) + .padding(contentPadding) + .drawBehind { + for (i in 0 until childBottoms.size - 1) { + val y = childBottoms[i] + drawLine(dividerColor, Offset(0f, y), Offset(size.width, y), strokeWidth = dividerPx) + } + } + ) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + childBottoms.clear() + var y = 0f + placeables.forEach { p -> + y += p.height + childBottoms.add(y) + } + layout(constraints.maxWidth, y.toInt()) { + var yPos = 0 + placeables.forEach { p -> + p.placeRelative(0, yPos) + yPos += p.height + } + } + } +} + +@Composable +private fun CardColumn( + contentPadding: PaddingValues = PaddingValues(), + cardShape: Shape = SectionCardShape, + content: @Composable () -> Unit +) { + if (LocalCardScreen.current) { + CardColumnLayout(contentPadding, cardShape, content) + } else { + Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + } +} + +@Composable +fun SectionView(title: String? = null, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, cardShape: Shape = SectionCardShape, content: (@Composable ColumnScope.() -> Unit)) { + val card = LocalCardScreen.current Column { if (title != null) { Text( title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, - modifier = Modifier.padding(start = DEFAULT_PADDING, bottom = headerBottomPadding), fontSize = 12.sp + modifier = Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = if (card) 8.dp else headerBottomPadding), + fontSize = if (card) 14.sp else 12.sp, + fontWeight = if (card) FontWeight.Medium else FontWeight.Normal ) } - Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + CardColumn(contentPadding, cardShape) { content() } } } @@ -42,24 +115,27 @@ fun SectionView( padding: PaddingValues = PaddingValues(), content: (@Composable ColumnScope.() -> Unit) ) { + val card = LocalCardScreen.current Column { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } - Row(Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(start = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING, bottom = 5.dp), verticalAlignment = Alignment.CenterVertically) { if (leadingIcon) Icon(icon, null, Modifier.padding(end = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) - Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal) if (!leadingIcon) Icon(icon, null, Modifier.padding(start = DEFAULT_PADDING_HALF).size(iconSize), tint = iconTint) } - Column(Modifier.padding(padding).fillMaxWidth()) { content() } + CardColumn(padding) { content() } } } @Composable fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> Unit)?, contentPadding: PaddingValues = PaddingValues(), headerBottomPadding: Dp = DEFAULT_PADDING, content: (@Composable ColumnScope.() -> Unit)) { + val card = LocalCardScreen.current Column { if (title != null || titleButton != null) { - Row(modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = headerBottomPadding).fillMaxWidth()) { + val hPadding = if (card) DEFAULT_PADDING + DEFAULT_PADDING_HALF else DEFAULT_PADDING + Row(modifier = Modifier.padding(start = hPadding, end = hPadding, bottom = if (card) 8.dp else headerBottomPadding).fillMaxWidth()) { if (title != null) { - Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = 12.sp) + Text(title, color = MaterialTheme.colors.secondary, style = MaterialTheme.typography.body2, fontSize = if (card) 14.sp else 12.sp, fontWeight = if (card) FontWeight.Medium else FontWeight.Normal) } if (titleButton != null) { Spacer(modifier = Modifier.weight(1f)) @@ -67,7 +143,7 @@ fun SectionViewWithButton(title: String? = null, titleButton: (@Composable () -> } } } - Column(Modifier.padding(contentPadding).fillMaxWidth()) { content() } + CardColumn(contentPadding) { content() } } } @@ -121,9 +197,9 @@ fun SectionItemView( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else - PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier @@ -144,9 +220,9 @@ fun SectionItemViewWithoutMinPadding( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else - PaddingValues(horizontal = DEFAULT_PADDING), + PaddingValues(horizontal = itemHPadding), content: (@Composable RowScope.() -> Unit) ) { SectionItemView(click, minHeight, disabled, extraPadding, padding, content) @@ -160,9 +236,9 @@ fun SectionItemViewLongClickable( disabled: Boolean = false, extraPadding: Boolean = false, padding: PaddingValues = if (extraPadding) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL, bottom = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL) else - PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), + PaddingValues(horizontal = itemHPadding, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL), content: (@Composable RowScope.() -> Unit) ) { val modifier = Modifier @@ -185,7 +261,7 @@ fun SectionItemViewSpaceBetween( click: (() -> Unit)? = null, onLongClick: (() -> Unit)? = null, minHeight: Dp = DEFAULT_MIN_SECTION_ITEM_HEIGHT, - padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING), + padding: PaddingValues = PaddingValues(horizontal = itemHPadding), disabled: Boolean = false, content: (@Composable RowScope.() -> Unit) ) { @@ -256,20 +332,19 @@ fun SectionCustomFooter(padding: PaddingValues = PaddingValues(start = DEFAULT_P } } -@Composable -fun SectionDivider() { - Divider(Modifier.padding(horizontal = 8.dp)) -} - @Composable fun SectionDividerSpaced(maxTopPadding: Boolean = false, maxBottomPadding: Boolean = true) { - Divider( - Modifier.padding( - start = DEFAULT_PADDING_HALF, - top = if (maxTopPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp, - end = DEFAULT_PADDING_HALF, - bottom = if (maxBottomPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp) - ) + if (LocalCardScreen.current) { + Spacer(Modifier.height(30.dp)) + } else { + Divider( + Modifier.padding( + start = DEFAULT_PADDING_HALF, + top = if (maxTopPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp, + end = DEFAULT_PADDING_HALF, + bottom = if (maxBottomPadding) DEFAULT_PADDING + 18.dp else DEFAULT_PADDING + 2.dp) + ) + } } @Composable @@ -284,11 +359,11 @@ fun SectionBottomSpacer() { @Composable fun TextIconSpaced(extraPadding: Boolean = false) { - Spacer(Modifier.padding(horizontal = if (extraPadding) 17.dp else DEFAULT_PADDING_HALF)) + Spacer(Modifier.padding(horizontal = if (extraPadding) 17.dp else if (LocalCardScreen.current) ICON_TEXT_SPACING else DEFAULT_PADDING_HALF)) } @Composable -fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING)) { +fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = itemHPadding)) { SectionItemViewSpaceBetween(padding = padding) { Row { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index d7cdf0e2e3..c8c24d918a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -3,8 +3,8 @@ package chat.simplex.common.views.helpers import SectionBottomSpacer import SectionDividerSpaced import SectionItemView -import SectionSpacer import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme.colors @@ -108,18 +108,22 @@ fun ModalData.UserWallpaperEditor( ) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - // If applying for : - // - all themes: no overrides needed - // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected - val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null - ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) - }, - onChooseType = onChooseType - ) + SectionView { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + // If applying for : + // - all themes: no overrides needed + // - specific user: only user overrides for currently selected theme are needed, because they will NOT be copied when other wallpaper is selected + val perUserOverride = if (wallpaperType.sameType(type)) chatModel.currentUser.value?.uiThemes else null + ThemeManager.currentColors(type, null, perUserOverride, appPrefs.themeOverrides.get()) + }, + onChooseType = onChooseType + ) + } + + SectionDividerSpaced() WallpaperSetupView( themeModeOverride.value.type, @@ -133,29 +137,30 @@ fun ModalData.UserWallpaperEditor( onTypeChange = onTypeChange, ) - SectionSpacer() + SectionDividerSpaced() - if (!globalThemeUsed.value) { - ResetToGlobalThemeButton(true) { - themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) - globalThemeUsed.value = true - withBGApi { save(applyToMode.value, null) } - } - } - - SetDefaultThemeButton { - globalThemeUsed.value = false - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - val mode = themeModeOverride.value.mode - withBGApi { - // Saving for both modes in one place by changing mode once per save - if (applyToMode.value == null) { - val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT - save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + SectionView { + if (!globalThemeUsed.value) { + ResetToGlobalThemeButton(true) { + themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + globalThemeUsed.value = true + withBGApi { save(applyToMode.value, null) } + } + } + SetDefaultThemeButton { + globalThemeUsed.value = false + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + val mode = themeModeOverride.value.mode + withBGApi { + // Saving for both modes in one place by changing mode once per save + if (applyToMode.value == null) { + val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + } + themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) + save(themeModeOverride.value.mode, themeModeOverride.value) } - themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) - save(themeModeOverride.value.mode, themeModeOverride.value) } } @@ -174,38 +179,40 @@ fun ModalData.UserWallpaperEditor( } } - SectionSpacer() + SectionDividerSpaced() if (showMore) { - val values by remember { mutableStateOf( - listOf( - null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), - DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), - DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + SectionView { + val values by remember { mutableStateOf( + listOf( + null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), + DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), + DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + ) ) - ) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.chat_theme_apply_to_mode), - values, - applyToMode, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { - applyToMode.value = it - if (it != null && it != CurrentColors.value.base.mode) { - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) - } } - ) + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_theme_apply_to_mode), + values, + applyToMode, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { + applyToMode.value = it + if (it != null && it != CurrentColors.value.base.mode) { + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) + } + } + ) + } SectionDividerSpaced() AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor) - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() ImportExportThemeSection(null, remember { chatModel.currentUser }.value?.uiThemes) { withBGApi { @@ -214,7 +221,9 @@ fun ModalData.UserWallpaperEditor( } } } else { - AdvancedSettingsButton { showMore = true } + SectionView { + AdvancedSettingsButton { showMore = true } + } } SectionBottomSpacer() @@ -329,32 +338,36 @@ fun ModalData.ChatWallpaperEditor( ThemeManager.currentColors(type, if (type?.sameType(themeModeOverride.value.type) == true) themeModeOverride.value else null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - WallpaperPresetSelector( - selectedWallpaper = currentTheme.wallpaper.type, - activeBackgroundColor = currentTheme.wallpaper.background, - activeTintColor = currentTheme.wallpaper.tint, - baseTheme = CurrentColors.collectAsState().value.base, - currentColors = { type -> currentColors(type) }, - onChooseType = { type -> - when { - type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ } - type is WallpaperType.Image && ((themeModeOverride.value.type is WallpaperType.Image && !globalThemeUsed.value) || currentColors(type).wallpaper.type.image == null) -> { - withLongRunningApi { importWallpaperLauncher.launch("image/*") } - } - type is WallpaperType.Image -> { - if (!onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)) { + SectionView { + WallpaperPresetSelector( + selectedWallpaper = currentTheme.wallpaper.type, + activeBackgroundColor = currentTheme.wallpaper.background, + activeTintColor = currentTheme.wallpaper.tint, + baseTheme = CurrentColors.collectAsState().value.base, + currentColors = { type -> currentColors(type) }, + onChooseType = { type -> + when { + type is WallpaperType.Image && chatModel.remoteHostId() != null -> { /* do nothing */ } + type is WallpaperType.Image && ((themeModeOverride.value.type is WallpaperType.Image && !globalThemeUsed.value) || currentColors(type).wallpaper.type.image == null) -> { withLongRunningApi { importWallpaperLauncher.launch("image/*") } } + type is WallpaperType.Image -> { + if (!onTypeCopyFromSameTheme(currentColors(type).wallpaper.type)) { + withLongRunningApi { importWallpaperLauncher.launch("image/*") } + } + } + globalThemeUsed.value || themeModeOverride.value.type != type -> { + onTypeCopyFromSameTheme(type) + } + else -> { + onTypeChange(type) + } } - globalThemeUsed.value || themeModeOverride.value.type != type -> { - onTypeCopyFromSameTheme(type) - } - else -> { - onTypeChange(type) - } - } - }, - ) + }, + ) + } + + SectionDividerSpaced() WallpaperSetupView( themeModeOverride.value.type, @@ -368,29 +381,30 @@ fun ModalData.ChatWallpaperEditor( onTypeChange = onTypeChange, ) - SectionSpacer() + SectionDividerSpaced() - if (!globalThemeUsed.value) { - ResetToGlobalThemeButton(remember { chatModel.currentUser }.value?.uiThemes?.preferredMode(isInDarkTheme()) == null) { - themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) - globalThemeUsed.value = true - withBGApi { save(applyToMode.value, null) } - } - } - - SetDefaultThemeButton { - globalThemeUsed.value = false - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - val mode = themeModeOverride.value.mode - withBGApi { - // Saving for both modes in one place by changing mode once per save - if (applyToMode.value == null) { - val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT - save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + SectionView { + if (!globalThemeUsed.value) { + ResetToGlobalThemeButton(remember { chatModel.currentUser }.value?.uiThemes?.preferredMode(isInDarkTheme()) == null) { + themeModeOverride.value = ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) + globalThemeUsed.value = true + withBGApi { save(applyToMode.value, null) } + } + } + SetDefaultThemeButton { + globalThemeUsed.value = false + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + val mode = themeModeOverride.value.mode + withBGApi { + // Saving for both modes in one place by changing mode once per save + if (applyToMode.value == null) { + val oppositeMode = if (mode == DefaultThemeMode.LIGHT) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT + save(oppositeMode, ThemeModeOverride.withFilledAppDefaults(oppositeMode, if (oppositeMode == DefaultThemeMode.LIGHT) lightBase else darkBase)) + } + themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) + save(themeModeOverride.value.mode, themeModeOverride.value) } - themeModeOverride.value = ThemeModeOverride.withFilledAppDefaults(mode, if (mode == DefaultThemeMode.LIGHT) lightBase else darkBase) - save(themeModeOverride.value.mode, themeModeOverride.value) } } @@ -409,38 +423,40 @@ fun ModalData.ChatWallpaperEditor( } } - SectionSpacer() + SectionDividerSpaced() if (showMore) { - val values by remember { mutableStateOf( - listOf( - null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), - DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), - DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + SectionView { + val values by remember { mutableStateOf( + listOf( + null to generalGetString(MR.strings.chat_theme_apply_to_all_modes), + DefaultThemeMode.LIGHT to generalGetString(MR.strings.chat_theme_apply_to_light_mode), + DefaultThemeMode.DARK to generalGetString(MR.strings.chat_theme_apply_to_dark_mode), + ) ) - ) - } - ExposedDropDownSettingRow( - generalGetString(MR.strings.chat_theme_apply_to_mode), - values, - applyToMode, - icon = null, - enabled = remember { mutableStateOf(true) }, - onSelected = { - applyToMode.value = it - if (it != null && it != CurrentColors.value.base.mode) { - val lightBase = DefaultTheme.LIGHT - val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX - ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) - } } - ) + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_theme_apply_to_mode), + values, + applyToMode, + icon = null, + enabled = remember { mutableStateOf(true) }, + onSelected = { + applyToMode.value = it + if (it != null && it != CurrentColors.value.base.mode) { + val lightBase = DefaultTheme.LIGHT + val darkBase = if (CurrentColors.value.base != DefaultTheme.LIGHT) CurrentColors.value.base else if (appPrefs.systemDarkTheme.get() == DefaultTheme.DARK.themeName) DefaultTheme.DARK else if (appPrefs.systemDarkTheme.get() == DefaultTheme.BLACK.themeName) DefaultTheme.BLACK else DefaultTheme.SIMPLEX + ThemeManager.applyTheme(if (it == DefaultThemeMode.LIGHT) lightBase.themeName else darkBase.themeName) + } + } + ) + } SectionDividerSpaced() AppearanceScope.CustomizeThemeColorsSection(currentTheme, editColor = editColor) - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() ImportExportThemeSection(themeModeOverride.value, remember { chatModel.currentUser }.value?.uiThemes) { withBGApi { themeModeOverride.value = it @@ -448,7 +464,9 @@ fun ModalData.ChatWallpaperEditor( } } } else { - AdvancedSettingsButton { showMore = true } + SectionView { + AdvancedSettingsButton { showMore = true } + } } SectionBottomSpacer() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt index 03542ca8af..39c4cb0b7f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateFromDevice.kt @@ -1,7 +1,7 @@ package chat.simplex.common.views.migration import SectionBottomSpacer -import SectionSpacer +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* @@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.foundation.background import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -134,6 +135,7 @@ fun MigrateFromDeviceView(close: () -> Unit) { } close() }, + cardScreen = true, ) { MigrateFromDeviceLayout( migrationState = migrationState, @@ -182,7 +184,7 @@ private fun SectionByState( @Composable private fun MutableState.ChatStopInProgressView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_stopping_chat)) {} ProgressView() } LaunchedEffect(Unit) { @@ -192,9 +194,9 @@ private fun MutableState.ChatStopInProgressView() { @Composable private fun MutableState.ChatStopFailedView(reason: String) { - SectionView(stringResource(MR.strings.error_stopping_chat).uppercase()) { + SectionView(stringResource(MR.strings.error_stopping_chat)) { Text(reason) - SectionSpacer() + SectionDividerSpaced() SettingsActionItemWithContent( icon = painterResource(MR.images.ic_report_filled), text = stringResource(MR.strings.auth_stop_chat), @@ -224,9 +226,9 @@ private fun MutableState.PassphraseConfirmationView() { val view = LocalMultiplatformView() Column { ChatStoppedView() - SectionSpacer() + SectionDividerSpaced() - SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_verify_database_passphrase)) { PassphraseField(currentKey, placeholder = stringResource(MR.strings.current_passphrase), Modifier.padding(horizontal = DEFAULT_PADDING), isValid = ::validKey, requestFocus = true) SettingsActionItemWithContent( @@ -243,8 +245,8 @@ private fun MutableState.PassphraseConfirmationView() { } } ) {} - SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } + SectionTextFooter(stringResource(MR.strings.migrate_from_device_confirm_you_remember_passphrase)) } if (verifyingPassphrase.value) { ProgressView() @@ -254,7 +256,7 @@ private fun MutableState.PassphraseConfirmationView() { @Composable private fun MutableState.UploadConfirmationView() { - SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_confirm_upload)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), text = stringResource(MR.strings.migrate_from_device_archive_and_upload), @@ -268,7 +270,7 @@ private fun MutableState.UploadConfirmationView() { @Composable private fun MutableState.ArchivingView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_archiving_database).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_archiving_database)) {} ProgressView() } LaunchedEffect(Unit) { @@ -279,7 +281,7 @@ private fun MutableState.ArchivingView() { @Composable private fun MutableState.DatabaseInitView(tempDatabaseFile: File, totalBytes: Long, archivePath: String) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_database_init)) {} ProgressView() } LaunchedEffect(Unit) { @@ -298,7 +300,7 @@ private fun MutableState.UploadProgressView( archivePath: String, ) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_uploading_archive)) { val ratio = uploadedBytes.toFloat() / max(totalBytes, 1) LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_from_device_bytes_uploaded).format(formatBytes(uploadedBytes))) } @@ -310,7 +312,7 @@ private fun MutableState.UploadProgressView( @Composable private fun MutableState.UploadFailedView(totalBytes: Long, archivePath: String, chatReceiver: MigrationFromChatReceiver?) { - SectionView(stringResource(MR.strings.migrate_from_device_upload_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_upload_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_ios_share), text = stringResource(MR.strings.migrate_from_device_repeat_upload), @@ -329,7 +331,7 @@ private fun MutableState.UploadFailedView(totalBytes: Long, @Composable private fun LinkCreationView() { Box { - SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_from_device_creating_archive_link)) {} ProgressView() } } @@ -361,15 +363,15 @@ private fun MutableState.LinkShownView(fileId: Long, link: S ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) } - SectionSpacer() - SectionView(stringResource(MR.strings.show_QR_code).uppercase()) { + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_choose_migrate_from_another_device)) + SectionDividerSpaced() + SectionView(stringResource(MR.strings.show_QR_code)) { SimpleXLinkQRCode(link, onShare = {}) } - SectionSpacer() - SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.migrate_from_device_or_share_this_file_link)) { LinkTextView(link, true) } } @@ -377,7 +379,7 @@ private fun MutableState.LinkShownView(fileId: Long, link: S @Composable private fun MutableState.FinishedView(chatDeletion: Boolean) { Box { - SectionView(stringResource(MR.strings.migrate_from_device_migration_complete).uppercase()) { + SectionView(stringResource(MR.strings.migrate_from_device_migration_complete)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_play_arrow_filled), text = stringResource(MR.strings.migrate_from_device_start_chat), @@ -410,9 +412,9 @@ private fun MutableState.FinishedView(chatDeletion: Boolean) ) } ) {} - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) - SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) } + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device)) + SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption)) if (chatDeletion) { ProgressView() } 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 cabfbf031e..f92a5e0ce4 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 @@ -2,6 +2,7 @@ package chat.simplex.common.views.migration import SectionBottomSpacer import SectionItemView +import SectionDividerSpaced import SectionSpacer import SectionTextFooter import SectionView @@ -9,6 +10,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.foundation.background import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalClipboardManager import chat.simplex.common.model.* @@ -148,6 +150,7 @@ fun ModalData.MigrateToDeviceView(close: () -> Unit) { close() } }, + cardScreen = true, ) { MigrateToDeviceLayout( migrationState = migrationState, @@ -201,7 +204,7 @@ private fun MutableState.PasteOrScanLinkView(close: () -> Uni val progressIndicator = remember { mutableStateOf(false) } Column { if (appPlatform.isAndroid) { - SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ').uppercase()) { + SectionView(stringResource(MR.strings.scan_QR_code).replace('\n', ' ')) { QRCodeScanner(showQRCodeScanner = remember { mutableStateOf(true) }) { text -> checkUserLink(text) } @@ -209,12 +212,12 @@ private fun MutableState.PasteOrScanLinkView(close: () -> Uni SectionSpacer() } - SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) { + SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link)) { PasteLinkView() } SectionSpacer() - SectionView(stringResource(MR.strings.chat_archive).uppercase()) { + SectionView(stringResource(MR.strings.chat_archive)) { ArchiveImportView(progressIndicator, close) } } @@ -280,7 +283,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin mutableStateOf(getNetCfg().withOnionHosts(onionHosts.value).copy(socksProxy = linkNetworkProxy?.toProxyString() ?: legacyLinkSocksProxy, sessionMode = sessionMode.value)) } - SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_confirm_network_settings)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_check), text = stringResource(MR.strings.migrate_to_device_apply_onion), @@ -305,7 +308,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin val networkProxyPref = SharedPreference(get = { networkProxy.value }, set = { networkProxy.value = it }) - SectionView(stringResource(MR.strings.network_settings_title).uppercase()) { + SectionView(stringResource(MR.strings.network_settings_title)) { OnionRelatedLayout( appPreferences.developerTools.get(), networkUseSocksProxy, @@ -325,7 +328,7 @@ private fun ModalData.OnionView(link: String, legacyLinkSocksProxy: String?, lin @Composable private fun MutableState.DatabaseInitView(link: String, tempDatabaseFile: File, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_database_init).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_database_init)) {} ProgressView() } LaunchedEffect(Unit) { @@ -345,7 +348,7 @@ private fun MutableState.LinkDownloadingView( networkProxy: NetworkProxy? ) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_downloading_details).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_downloading_details)) {} ProgressView() } LaunchedEffect(Unit) { @@ -356,7 +359,7 @@ private fun MutableState.LinkDownloadingView( @Composable private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_downloading_archive)) { val ratio = downloadedBytes.toFloat() / max(totalBytes, 1) LargeProgressView(ratio, "${(ratio * 100).toInt()}%", stringResource(MR.strings.migrate_to_device_bytes_downloaded).format(formatBytes(downloadedBytes))) } @@ -365,7 +368,7 @@ private fun DownloadProgressView(downloadedBytes: Long, totalBytes: Long) { @Composable private fun MutableState.DownloadFailedView(link: String, chatReceiver: MigrationToChatReceiver?, archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { - SectionView(stringResource(MR.strings.migrate_to_device_download_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_download_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_download), @@ -386,7 +389,7 @@ private fun MutableState.DownloadFailedView(link: String, cha @Composable private fun MutableState.ArchiveImportView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_importing_archive).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_importing_archive)) {} ProgressView() } LaunchedEffect(Unit) { @@ -396,7 +399,7 @@ private fun MutableState.ArchiveImportView(archivePath: Strin @Composable private fun MutableState.ArchiveImportFailedView(archivePath: String, netCfg: NetCfg, networkProxy: NetworkProxy?) { - SectionView(stringResource(MR.strings.migrate_to_device_import_failed).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_import_failed)) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), text = stringResource(MR.strings.migrate_to_device_repeat_import), @@ -417,7 +420,7 @@ private fun MutableState.PassphraseEnteringView(currentKey: S Box { val view = LocalMultiplatformView() - SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase).uppercase()) { + SectionView(stringResource(MR.strings.migrate_to_device_enter_passphrase)) { SavePassphraseSetting( useKeychain.value, false, @@ -489,7 +492,7 @@ private fun MutableState.MigrationConfirmationView(status: DB } else -> Tuple4(generalGetString(MR.strings.error), null, generalGetString(MR.strings.unknown_error), null) } - SectionView(header.uppercase()) { + SectionView(header) { if (button != null && confirmation != null) { SettingsActionItemWithContent( icon = painterResource(MR.images.ic_download), @@ -500,14 +503,14 @@ private fun MutableState.MigrationConfirmationView(status: DB } ) {} } - SectionTextFooter(footer) } + SectionTextFooter(footer) } @Composable private fun MigrationView(passphrase: String, confirmation: MigrationConfirmation, useKeychain: Boolean, netCfg: NetCfg, networkProxy: NetworkProxy?, close: () -> Unit) { Box { - SectionView(stringResource(MR.strings.migrate_to_device_migrating).uppercase()) {} + SectionView(stringResource(MR.strings.migrate_to_device_migrating)) {} ProgressView() } LaunchedEffect(Unit) { 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 index 09372636ab..b5188178fa 100644 --- 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 @@ -66,7 +66,7 @@ fun AddChannelView(chatModel: ChatModel, close: () -> Unit, closeAll: () -> Unit closeAll() withBGApi { openGroupChat(null, gInfo.groupId) - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = true) { close -> GroupLinkView(chatModel, rhId = null, groupInfo = gInfo, groupLink = groupLink.value, onGroupLinkUpdated = null, creatingGroup = true, isChannel = true, shareGroupInfo = gInfo, close = close) } } @@ -361,11 +361,11 @@ private fun ProgressStepView( 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 activeCount = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } val total = groupRelays.value.size fun showCancelAlert() { - val active = groupRelays.value.count { it.relayStatus == RelayStatus.RsActive && relayMemberConnFailed(chatModel, it) == null } + val active = groupRelays.value.count { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null } val tot = groupRelays.value.size AlertManager.shared.showAlertDialog( title = generalGetString(MR.strings.cancel_creating_channel_question), @@ -394,7 +394,7 @@ private fun ProgressStepView( .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 }) { + if (relays.all { it.relayStatus == RelayStatus.Active && relayMemberConnFailed(chatModel, it) == null }) { onLinkReady() ChannelRelaysModel.reset() } @@ -567,7 +567,7 @@ private fun LinkStepView( } } } - ModalView(close = close, showClose = false) { + ModalView(close = close, showClose = false, cardScreen = true) { GroupLinkView( chatModel = chatModel, rhId = null, @@ -596,8 +596,14 @@ fun chatRelayDisplayName(relay: UserChatRelay): String { @Composable fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) { val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) - val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow - val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text + val isRejected = status == RelayStatus.Rejected + val color = if (connFailed || removed || isRejected) Color.Red else if (status == RelayStatus.Active) Color.Green else WarningYellow + val text = + if (connFailed) generalGetString(MR.strings.relay_status_failed) + else if (isRejected) generalGetString(MR.strings.relay_status_rejected) + else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) + else if (removed) generalGetString(MR.strings.relay_conn_status_removed) + else status.text Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) 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 a54d2e42e7..1d8da1690c 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 @@ -53,11 +53,11 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c closeAll.invoke() if (!groupInfo.incognito) { - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true) { close -> AddGroupMembersView(rhId, groupInfo, creatingGroup = true, chatModel, close) } } else { - ModalManager.end.showModalCloseable(true) { close -> + ModalManager.end.showModalCloseable(showClose = true, cardScreen = 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/ContactConnectionInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt index 0f299b5187..f1bd732d87 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ContactConnectionInfoView.kt @@ -7,6 +7,7 @@ import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -130,7 +131,7 @@ private fun ContactConnectionInfoLayout( if (connLink != null && connLink.connFullLink.isNotEmpty() && contactConnection.initiated) { Spacer(Modifier.height(DEFAULT_PADDING)) SectionViewWithButton( - stringResource(MR.strings.one_time_link).uppercase(), + stringResource(MR.strings.one_time_link), titleButton = if (connLink.connShortLink == null) null else {{ ToggleShortLinkButton(showShortLink) }} ) { SimpleXCreatedLinkQRCode(connLink, short = showShortLink.value) @@ -146,7 +147,7 @@ private fun ContactConnectionInfoLayout( } SectionTextFooter(sharedProfileInfo(chatModel, contactConnection.incognito)) - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() DeleteButton(deleteConnection) 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 1eceaf4158..993e1fca01 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 @@ -325,7 +325,7 @@ private fun ModalData.NewChatSheetLayout( item { if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) { SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) - SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {} + SectionView(stringResource(MR.strings.contact_list_header_title), headerBottomPadding = DEFAULT_PADDING_HALF) {} Spacer(Modifier.height(DEFAULT_PADDING_HALF)) } } @@ -410,7 +410,7 @@ private fun ModalData.NewChatSheetLayout( item { if (filteredContactChats.isNotEmpty() && searchText.value.text.isEmpty()) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.contact_list_header_title).uppercase(), headerBottomPadding = DEFAULT_PADDING_HALF) {} + SectionView(stringResource(MR.strings.contact_list_header_title), headerBottomPadding = DEFAULT_PADDING_HALF) {} } } item { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt index 72311cd7fe..5b3fd34c22 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/NewChatView.kt @@ -495,7 +495,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact ) SimpleXCreatedLinkQRCode(connLinkInvitation, short = showShortLink.value, onShare = { chatModel.markShowingInvitationUsed() }) } else { - SectionView(stringResource(MR.strings.share_this_1_time_link).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.share_this_1_time_link), headerBottomPadding = 5.dp) { LinkTextView(connLinkInvitation.simplexChatUri(short = showShortLink.value), true) } @@ -519,7 +519,7 @@ private fun InviteView(rhId: Long?, connLinkInvitation: CreatedConnLink, contact val currentUser = remember { chatModel.currentUser }.value if (currentUser != null) { - SectionView(stringResource(MR.strings.new_chat_share_profile).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.new_chat_share_profile), headerBottomPadding = 5.dp) { SectionItemView( padding = PaddingValues( top = 0.dp, @@ -643,14 +643,14 @@ private fun ConnectView(rhId: Long?, showQRCodeScanner: MutableState, p ) } - SectionView(stringResource(MR.strings.paste_the_link_you_received).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.paste_the_link_you_received), headerBottomPadding = 5.dp) { PasteLinkView(rhId, pastedLink, showQRCodeScanner, close) } if (appPlatform.isAndroid) { Spacer(Modifier.height(10.dp)) - SectionView(stringResource(MR.strings.or_scan_qr_code).uppercase(), headerBottomPadding = 5.dp) { + SectionView(stringResource(MR.strings.or_scan_qr_code), headerBottomPadding = 5.dp) { QRCodeScanner(showQRCodeScanner) { text -> val linkVerified = verifyOnly(text) if (!linkVerified) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt index 2fd77b46a1..d11e396388 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/ChooseServerOperators.kt @@ -55,7 +55,7 @@ fun OnboardingConditionsView(chatModel: ChatModel) { OnboardingConditionsDesktop(selectedOperatorIds) } else { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false, showAppBar = false) { + ModalView({}, showClose = false, showAppBar = false, cardScreen = true) { OnboardingShrinkingLayout( modifier = Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer) .systemBarsPadding() @@ -133,7 +133,7 @@ fun OnboardingConditionsView(chatModel: ChatModel) { @Composable private fun OnboardingConditionsDesktop(selectedOperatorIds: MutableState>) { CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView({}, showClose = false) { + ModalView({}, showClose = false, cardScreen = true) { ColumnWithScrollBar(horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) { Box(Modifier.align(Alignment.CenterHorizontally)) { @@ -184,7 +184,7 @@ fun ModalData.ChooseServerOperators( prepareChatBeforeFinishingOnboarding() } CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) { - ModalView(close, enableClose = selectedOperatorIds.value.isNotEmpty()) { + ModalView(close, enableClose = selectedOperatorIds.value.isNotEmpty(), cardScreen = true) { ColumnWithScrollBar( Modifier .themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer), @@ -373,7 +373,7 @@ private fun ChooseServerOperatorsInfoView() { SectionDividerSpaced() - SectionView(title = stringResource(MR.strings.onboarding_network_about_operators).uppercase()) { + SectionView(title = stringResource(MR.strings.onboarding_network_about_operators)) { chatModel.conditions.value.serverOperators.forEach { op -> ServerOperatorRow(op) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt index e902b7947e..97dfcd34b9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/LinkAMobileView.kt @@ -65,7 +65,7 @@ private fun LinkAMobileLayout( Modifier.weight(0.3f), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + SectionView(generalGetString(MR.strings.this_device_name)) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { ChatModel.controller.appPrefs.offerRemoteMulticast.state }.value) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt index 8bb84060c2..81e1afd22c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectDesktopView.kt @@ -4,10 +4,10 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionItemView import SectionItemViewLongClickable -import SectionSpacer import SectionView import TextIconSpaced import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* import androidx.compose.runtime.* @@ -29,8 +29,7 @@ import chat.simplex.common.model.ChatController.switchToLocalSession import chat.simplex.common.model.ChatModel.connectedToRemote import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.DEFAULT_PADDING -import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.common.views.newchat.QRCodeScanner @@ -53,7 +52,7 @@ fun ConnectDesktopView(close: () -> Unit) { showDisconnectDesktopAlert(close) } } - ModalView(close = closeWithAlert) { + ModalView(close = closeWithAlert, cardScreen = true) { ConnectDesktopLayout( deviceName = deviceName.value!!, close @@ -128,7 +127,7 @@ private fun ConnectDesktopLayout(deviceName: String, close: () -> Unit) { @Composable private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList, sessionAddress: MutableState) { AppBarTitle(stringResource(MR.strings.connect_to_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -139,7 +138,7 @@ private fun ConnectDesktop(deviceName: String, remoteCtrls: SnapshotStateList) { AppBarTitle(stringResource(MR.strings.connecting_to_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -197,10 +196,10 @@ private fun SearchingDesktop(deviceName: String, remoteCtrls: SnapshotStateList< } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.found_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(stringResource(MR.strings.waiting_for_desktop), fontStyle = FontStyle.Italic) } - SectionSpacer() + SectionDividerSpaced() DisconnectButton(stringResource(MR.strings.scan_QR_code).replace('\n', ' '), MR.images.ic_qr_code, ::disconnectDesktop) } @@ -215,7 +214,7 @@ private fun FoundDesktop( sessionAddress: MutableState, ) { AppBarTitle(stringResource(MR.strings.found_desktop)) - SectionView(stringResource(MR.strings.this_device_name).uppercase()) { + SectionView(stringResource(MR.strings.this_device_name)) { DevicesView(deviceName, remoteCtrls) { if (it != "") { setDeviceName(it) @@ -224,7 +223,7 @@ private fun FoundDesktop( } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.found_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.found_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) CtrlDeviceVersionText(session) if (!compatible) { @@ -232,7 +231,7 @@ private fun FoundDesktop( } } - SectionSpacer() + SectionDividerSpaced() if (compatible) { SectionItemView({ withBGApi { confirmKnownDesktop(sessionAddress, rc) } }) { @@ -256,19 +255,19 @@ private fun FoundDesktop( @Composable private fun VerifySession(session: RemoteCtrlSession, rc: RemoteCtrlInfo?, sessCode: String, remoteCtrls: SnapshotStateList) { AppBarTitle(stringResource(MR.strings.verify_connection)) - SectionView(stringResource(MR.strings.connected_to_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.connected_to_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { CtrlDeviceNameText(session, rc) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } - SectionSpacer() + SectionDividerSpaced() - SectionView(stringResource(MR.strings.verify_code_with_desktop).uppercase()) { + SectionView(stringResource(MR.strings.verify_code_with_desktop)) { SessionCodeText(sessCode) } - SectionSpacer() + SectionDividerSpaced() SectionItemView({ verifyDesktopSessionCode(remoteCtrls, sessCode) }) { Icon(painterResource(MR.images.ic_check), generalGetString(MR.strings.confirm_verb), tint = MaterialTheme.colors.secondary) @@ -311,20 +310,20 @@ private fun CtrlDeviceVersionText(session: RemoteCtrlSession) { @Composable private fun ActiveSession(session: RemoteCtrlSession, rc: RemoteCtrlInfo, close: () -> Unit) { AppBarTitle(stringResource(MR.strings.connected_to_desktop)) - SectionView(stringResource(MR.strings.connected_desktop).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.connected_desktop), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(rc.deviceViewName) Spacer(Modifier.height(DEFAULT_PADDING_HALF)) CtrlDeviceVersionText(session) } if (session.sessionCode != null) { - SectionSpacer() - SectionView(stringResource(MR.strings.session_code).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.session_code)) { SessionCodeText(session.sessionCode!!) } } - SectionSpacer() + SectionDividerSpaced() SectionView { DisconnectButton { disconnectDesktop(close) } @@ -355,7 +354,7 @@ private fun DevicesView(deviceName: String, remoteCtrls: SnapshotStateList) { - SectionView(stringResource(MR.strings.scan_qr_code_from_desktop).uppercase()) { + SectionView(stringResource(MR.strings.scan_qr_code_from_desktop)) { QRCodeScanner { text -> sessionAddress.value = text connectDesktopAddress(sessionAddress, text) @@ -366,7 +365,7 @@ private fun ScanDesktopAddressView(sessionAddress: MutableState) { @Composable private fun DesktopAddressView(sessionAddress: MutableState) { val clipboard = LocalClipboardManager.current - SectionView(stringResource(MR.strings.desktop_address).uppercase()) { + SectionView(stringResource(MR.strings.desktop_address)) { if (sessionAddress.value.isEmpty()) { SettingsActionItem( painterResource(MR.images.ic_content_paste), @@ -410,7 +409,7 @@ private fun DesktopAddressView(sessionAddress: MutableState) { private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.linked_desktops)) - SectionView(stringResource(MR.strings.desktop_devices).uppercase()) { + SectionView(stringResource(MR.strings.desktop_devices)) { remoteCtrls.forEach { rc -> val showMenu = rememberSaveable { mutableStateOf(false) } SectionItemViewLongClickable(click = {}, longClick = { showMenu.value = true }) { @@ -427,7 +426,7 @@ private fun LinkedDesktopsView(remoteCtrls: SnapshotStateList) { } SectionDividerSpaced() - SectionView(stringResource(MR.strings.linked_desktop_options).uppercase()) { + SectionView(stringResource(MR.strings.linked_desktop_options)) { PreferenceToggle(stringResource(MR.strings.verify_connections), checked = remember { controller.appPrefs.confirmRemoteSessions.state }.value) { controller.appPrefs.confirmRemoteSessions.set(it) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index 1d01ab11ff..8caf038481 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -92,7 +92,7 @@ fun ConnectMobileLayout( ) { ColumnWithScrollBar { AppBarTitle(stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles)) - SectionView(generalGetString(MR.strings.this_device_name).uppercase()) { + SectionView(generalGetString(MR.strings.this_device_name)) { DeviceNameField(deviceName.value ?: "") { updateDeviceName(it) } SectionTextFooter(generalGetString(MR.strings.this_device_name_shared_with_mobile)) PreferenceToggle(stringResource(MR.strings.multicast_discoverable_via_local_network), checked = remember { controller.appPrefs.offerRemoteMulticast.state }.value) { @@ -100,7 +100,7 @@ fun ConnectMobileLayout( } SectionDividerSpaced() } - SectionView(stringResource(MR.strings.devices).uppercase()) { + SectionView(stringResource(MR.strings.devices)) { if (chatModel.localUserCreated.value == true) { SettingsActionItemWithContent(text = stringResource(MR.strings.this_device), icon = painterResource(MR.images.ic_desktop), click = connectDesktop) { if (connectedHost.value == null) { @@ -215,7 +215,7 @@ private fun ConnectMobileViewLayout( Spacer(Modifier.height(DEFAULT_PADDING)) } if (deviceName != null || sessionCode != null) { - SectionView(stringResource(MR.strings.connected_mobile).uppercase()) { + SectionView(stringResource(MR.strings.connected_mobile)) { SelectionContainer { Text( deviceName ?: stringResource(MR.strings.new_mobile_device), @@ -228,7 +228,7 @@ private fun ConnectMobileViewLayout( } if (sessionCode != null) { - SectionView(stringResource(MR.strings.verify_code_on_mobile).uppercase()) { + SectionView(stringResource(MR.strings.verify_code_on_mobile)) { SelectionContainer { Text( sessionCode.substring(0, 23), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index e24c09afd0..1c9e602f1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -1,11 +1,13 @@ package chat.simplex.common.views.usersettings +import CARD_PADDING +import LocalCardScreen import SectionBottomSpacer import SectionDividerSpaced import SectionItemView +import itemHPadding import SectionItemViewSpaceBetween import SectionItemViewWithoutMinPadding -import SectionSpacer import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource @@ -58,9 +60,9 @@ expect fun AppearanceView(m: ChatModel) object AppearanceScope { @Composable fun ProfileImageSection() { - SectionView(stringResource(MR.strings.settings_section_title_profile_images).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { + SectionView(stringResource(MR.strings.settings_section_title_profile_images), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { val image = remember { chatModel.currentUser }.value?.image - Row(Modifier.padding(top = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.padding(vertical = 10.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically) { val size = 60 Box(Modifier.offset(x = -(size / 12).dp)) { if (!image.isNullOrEmpty()) { @@ -91,9 +93,10 @@ object AppearanceScope { @Composable fun AppToolbarsSection() { BoxWithConstraints { - SectionView(stringResource(MR.strings.appearance_app_toolbars).uppercase()) { + SectionView(stringResource(MR.strings.appearance_app_toolbars)) { SectionItemViewWithoutMinPadding { Box(Modifier.weight(1f)) { + var fontScale by remember { mutableStateOf(1f) } Text( stringResource(MR.strings.appearance_in_app_bars_alpha), Modifier.clickable( @@ -102,7 +105,9 @@ object AppearanceScope { ) { appPrefs.inAppBarsAlpha.set(appPrefs.inAppBarsDefaultAlpha) }, - maxLines = 1 + maxLines = 1, + fontSize = MaterialTheme.typography.body1.fontSize * fontScale, + onTextLayout = { if (it.hasVisualOverflow && fontScale > 0.5f) fontScale -= 0.05f } ) } Spacer(Modifier.padding(end = 10.dp)) @@ -175,7 +180,7 @@ object AppearanceScope { @Composable fun MessageShapeSection() { BoxWithConstraints { - SectionView(stringResource(MR.strings.settings_section_title_message_shape).uppercase()) { + SectionView(stringResource(MR.strings.settings_section_title_message_shape)) { SectionItemViewWithoutMinPadding { Text(stringResource(MR.strings.settings_message_shape_corner), Modifier.weight(1f)) Spacer(Modifier.width(10.dp)) @@ -205,8 +210,8 @@ object AppearanceScope { @Composable fun FontScaleSection() { val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) } - SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + SectionView(stringResource(MR.strings.appearance_font_size), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { + Row(Modifier.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(50.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) .clip(RoundedCornerShape(percent = 22)) @@ -409,26 +414,29 @@ object AppearanceScope { } if (appPlatform.isDesktop) { - val itemWidth = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4 - val itemHeight = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2) / 4 + val gridPadding = 12.dp + val cardPadding = if (LocalCardScreen.current) CARD_PADDING * 2 else 0.dp + val itemSize = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - cardPadding - gridPadding * 5) / 4 val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() LazyVerticalGrid( columns = GridCells.Fixed(4), - Modifier.height(itemHeight * rows + DEFAULT_PADDING_HALF * (rows - 1) + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + Modifier.height(itemSize * rows + gridPadding * (rows + 1)), + contentPadding = PaddingValues(gridPadding), + verticalArrangement = Arrangement.spacedBy(gridPadding), + horizontalArrangement = Arrangement.spacedBy(gridPadding), ) { - gridContent(itemWidth, itemHeight) + gridContent(itemSize, itemSize) } } else { - LazyHorizontalGrid( + val gridPadding = 14.dp + val itemSize = 81.dp + LazyHorizontalGrid( rows = GridCells.Fixed(1), - Modifier.height(80.dp + DEFAULT_PADDING * 2), - contentPadding = PaddingValues(DEFAULT_PADDING), - horizontalArrangement = Arrangement.spacedBy(DEFAULT_PADDING_HALF), + Modifier.height(itemSize + gridPadding * 2), + contentPadding = PaddingValues(gridPadding), + horizontalArrangement = Arrangement.spacedBy(gridPadding), ) { - gridContent(80.dp, 80.dp) + gridContent(itemSize, itemSize) } } } @@ -521,9 +529,7 @@ object AppearanceScope { } SectionView(stringResource(MR.strings.settings_section_title_themes)) { - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) ThemeDestinationPicker(themeUserDestination) - Spacer(Modifier.height(DEFAULT_PADDING_HALF)) val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) @@ -555,7 +561,7 @@ object AppearanceScope { color = if (chatModel.remoteHostId != null && themeUserDestination.value != null) MaterialTheme.colors.secondary else MaterialTheme.colors.primary ) } - SectionSpacer() + SectionDividerSpaced() } val state: State = remember(appPrefs.currentTheme.get()) { @@ -584,23 +590,23 @@ object AppearanceScope { } saveThemeToDatabase(null) } - } - SectionItemView(click = { - val user = themeUserDestination.value - if (user == null) { - ModalManager.start.showModal { - val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> - if (to != null) onImport(to) + SectionItemView(click = { + val user = themeUserDestination.value + if (user == null) { + ModalManager.start.showModal(cardScreen = true) { + val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> + if (to != null) onImport(to) + } + CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + } + } else { + ModalManager.start.showModalCloseable(cardScreen = true) { close -> + UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) } - CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } - } - } else { - ModalManager.start.showModalCloseable { close -> - UserWallpaperEditorModal(chatModel.remoteHostId(), user.first, close) } + }) { + Text(stringResource(MR.strings.customize_theme_title)) } - }) { - Text(stringResource(MR.strings.customize_theme_title)) } } @@ -626,68 +632,70 @@ object AppearanceScope { ) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) - }, - onChooseType = onChooseType - ) - - val type = MaterialTheme.wallpaper.type - if (type is WallpaperType.Image) { - SectionItemView(disabled = chatModel.remoteHostId != null, click = { - val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) - ThemeManager.saveAndApplyWallpaper(baseTheme, null) - ThemeManager.removeTheme(defaultActiveTheme?.themeId) - removeWallpaperFile(type.filename) - saveThemeToDatabase(null) - }) { - Text( - stringResource(MR.strings.theme_remove_image), - color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - } - SectionSpacer() - } - - SectionView(stringResource(MR.strings.settings_section_title_chat_colors).uppercase()) { - WallpaperSetupView( - wallpaperType, - baseTheme, - MaterialTheme.wallpaper, - MaterialTheme.appColors.sentMessage, - MaterialTheme.appColors.sentQuote, - MaterialTheme.appColors.receivedMessage, - MaterialTheme.appColors.receivedQuote, - editColor = { name -> - editColor(name) - }, - onTypeChange = { type -> - ThemeManager.saveAndApplyWallpaper(baseTheme, type) - saveThemeToDatabase(null) + SectionView { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + ThemeManager.currentColors(type, null, null, appPrefs.themeOverrides.get()) }, + onChooseType = onChooseType ) + val type = MaterialTheme.wallpaper.type + if (type is WallpaperType.Image) { + SectionItemView(disabled = chatModel.remoteHostId != null, click = { + val defaultActiveTheme = ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) + ThemeManager.saveAndApplyWallpaper(baseTheme, null) + ThemeManager.removeTheme(defaultActiveTheme?.themeId) + removeWallpaperFile(type.filename) + saveThemeToDatabase(null) + }) { + Text( + stringResource(MR.strings.theme_remove_image), + color = if (chatModel.remoteHostId == null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } + } } SectionDividerSpaced() + WallpaperSetupView( + wallpaperType, + baseTheme, + MaterialTheme.wallpaper, + MaterialTheme.appColors.sentMessage, + MaterialTheme.appColors.sentQuote, + MaterialTheme.appColors.receivedMessage, + MaterialTheme.appColors.receivedQuote, + editColor = { name -> + editColor(name) + }, + onTypeChange = { type -> + ThemeManager.saveAndApplyWallpaper(baseTheme, type) + saveThemeToDatabase(null) + }, + firstSectionTitle = stringResource(MR.strings.settings_section_title_chat_colors), + ) + SectionDividerSpaced() + CustomizeThemeColorsSection(currentTheme) { name -> editColor(name) } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() val currentOverrides = remember(currentTheme) { ThemeManager.defaultActiveTheme(appPrefs.themeOverrides.get()) } val canResetColors = currentTheme.base.hasChangedAnyColor(currentOverrides) if (canResetColors) { - SectionItemView({ - ThemeManager.resetAllThemeColors() - saveThemeToDatabase(null) - }) { - Text(generalGetString(MR.strings.reset_color), color = colors.primary) + SectionView { + SectionItemView({ + ThemeManager.resetAllThemeColors() + saveThemeToDatabase(null) + }) { + Text(generalGetString(MR.strings.reset_color), color = colors.primary) + } } - SectionSpacer() + SectionDividerSpaced() } SectionView { @@ -1007,7 +1015,7 @@ object AppearanceScope { SimpleXThemeOverride(currentColors()) { ChatThemePreview(theme, wallpaperImage, wallpaperType, previewBackgroundColor, previewTintColor) } - SectionSpacer() + SectionDividerSpaced() } var currentColor by remember { mutableStateOf(initialColor) } @@ -1084,7 +1092,7 @@ object AppearanceScope { }) { Text(generalGetString(MR.strings.reset_single_color), color = colors.primary) } - SectionSpacer() + SectionDividerSpaced() } } @@ -1188,75 +1196,82 @@ fun WallpaperSetupView( initialReceivedQuoteColor: Color, editColor: (ThemeColor) -> Unit, onTypeChange: (WallpaperType?) -> Unit, + firstSectionTitle: String? = null, ) { - if (wallpaperType is WallpaperType.Image) { - val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } - val values = remember { - WallpaperScaleType.entries.map { it to generalGetString(it.text) } - } - ExposedDropDownSettingRow( - stringResource(MR.strings.wallpaper_scale), - values, - state, - onSelected = { scaleType -> - onTypeChange(wallpaperType.copy(scaleType = scaleType)) - } - ) - } + val hasWallpaperSettings = wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image - if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { - val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } - Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { - Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) - Slider( - state.value, - valueRange = 0.5f..2f, - onValueChange = { - if (wallpaperType is WallpaperType.Preset) { - onTypeChange(wallpaperType.copy(scale = it)) - } else if (wallpaperType is WallpaperType.Image) { - onTypeChange(wallpaperType.copy(scale = it)) - } + if (hasWallpaperSettings) { + SectionView(firstSectionTitle) { + if (wallpaperType is WallpaperType.Image) { + val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } + val values = remember { + WallpaperScaleType.entries.map { it to generalGetString(it.text) } } - ) + ExposedDropDownSettingRow( + stringResource(MR.strings.wallpaper_scale), + values, + state, + onSelected = { scaleType -> + onTypeChange(wallpaperType.copy(scaleType = scaleType)) + } + ) + } + + if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { + val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } + Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { + Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) + Slider( + state.value, + valueRange = 0.5f..2f, + onValueChange = { + if (wallpaperType is WallpaperType.Preset) { + onTypeChange(wallpaperType.copy(scale = it)) + } else if (wallpaperType is WallpaperType.Image) { + onTypeChange(wallpaperType.copy(scale = it)) + } + } + ) + } + } + + val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { + val title = generalGetString(MR.strings.color_wallpaper_background) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) + } + val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { + val title = generalGetString(MR.strings.color_wallpaper_tint) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + } } + SectionDividerSpaced() } - if (wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image) { - val wallpaperBackgroundColor = initialWallpaper?.background ?: wallpaperType.defaultBackgroundColor(theme, MaterialTheme.colors.background) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_BACKGROUND) }) { - val title = generalGetString(MR.strings.color_wallpaper_background) + SectionView(if (!hasWallpaperSettings) firstSectionTitle else null) { + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { + val title = generalGetString(MR.strings.color_sent_message) Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) } - val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { - val title = generalGetString(MR.strings.color_wallpaper_tint) + SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { + val title = generalGetString(MR.strings.color_sent_quote) Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { + val title = generalGetString(MR.strings.color_received_message) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) + } + SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { + val title = generalGetString(MR.strings.color_received_quote) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) } - SectionSpacer() - } - - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_MESSAGE) }) { - val title = generalGetString(MR.strings.color_sent_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.SENT_QUOTE) }) { - val title = generalGetString(MR.strings.color_sent_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialSentQuoteColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_MESSAGE) }) { - val title = generalGetString(MR.strings.color_received_message) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedColor) - } - SectionItemViewSpaceBetween({ editColor(ThemeColor.RECEIVED_QUOTE) }) { - val title = generalGetString(MR.strings.color_received_quote) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = initialReceivedQuoteColor) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt index dcb71a552d..2c729149d0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/DeveloperView.kt @@ -4,9 +4,12 @@ import SectionBottomSpacer import SectionDividerSpaced import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.runtime.* +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.ui.theme.* import chat.simplex.common.platform.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -29,14 +32,14 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.start.showModalCloseable { TerminalView(false) } } } ResetHintsItem(unchangedHints) SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools) - SectionTextFooter( - generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + - generalGetString(MR.strings.developer_options) - ) } + SectionTextFooter( + generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " + + generalGetString(MR.strings.developer_options) + ) if (devTools.value) { - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.developer_options_section).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.developer_options_section)) { SettingsActionItemWithContent(painterResource(MR.images.ic_breaking_news), stringResource(MR.strings.debug_logs)) { DefaultSwitch( checked = remember { appPrefs.logLevel.state }.value <= LogLevel.DEBUG, @@ -59,15 +62,15 @@ fun DeveloperView(withAuth: (title: String, desc: String, block: () -> Unit) -> SettingsPreferenceItem(painterResource(MR.images.ic_avg_pace), stringResource(MR.strings.show_slow_api_calls), appPreferences.showSlowApiCalls) } } - SectionDividerSpaced(maxTopPadding = true) - SectionView(stringResource(MR.strings.deprecated_options_section).uppercase()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.deprecated_options_section)) { val simplexLinkMode = chatModel.controller.appPrefs.simplexLinkMode SimpleXLinkOptions(chatModel.simplexLinkMode, onSelected = { simplexLinkMode.set(it) chatModel.simplexLinkMode.value = it }) - SectionBottomSpacer() } + SectionBottomSpacer() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt index 55bd796a3b..4a3806ab89 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/HiddenProfileView.kt @@ -68,7 +68,7 @@ private fun HiddenProfileLayout( val passwordValid by remember { derivedStateOf { hidePassword.value == hidePassword.value.trim() } } val confirmValid by remember { derivedStateOf { confirmHidePassword.value == "" || hidePassword.value == confirmHidePassword.value } } val saveDisabled by remember { derivedStateOf { hidePassword.value == "" || !passwordValid || confirmHidePassword.value == "" || !confirmValid } } - SectionView(stringResource(MR.strings.hidden_profile_password).uppercase()) { + SectionView(stringResource(MR.strings.hidden_profile_password)) { SectionItemViewWithoutMinPadding { PassphraseField(hidePassword, generalGetString(MR.strings.password_to_show), isValid = { passwordValid }, showStrength = true) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt index 2fc427cd2e..150b2a38e0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NotificationsSettingsView.kt @@ -4,8 +4,11 @@ import SectionBottomSpacer import SectionTextFooter import SectionView import SectionViewSelectable +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.capitalize @@ -74,9 +77,9 @@ fun NotificationsSettingsLayout( color = MaterialTheme.colors.secondary ) } - if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { - SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) - } + } + if (platform.androidIsXiaomiDevice() && (notificationsMode.value == NotificationsMode.PERIODIC || notificationsMode.value == NotificationsMode.SERVICE)) { + SectionTextFooter(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization)) } SectionBottomSpacer() } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt index fe9137ee35..63f3491d80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Preferences.kt @@ -1,13 +1,15 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.material.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -47,6 +49,7 @@ fun PreferencesView(m: ChatModel, user: User, close: () -> Unit,) { if (preferences == currentPreferences) close() else showUnsavedChangesAlert({ savePrefs(close) }, close) }, + cardScreen = true, ) { PreferencesLayout( preferences, @@ -81,27 +84,27 @@ private fun PreferencesLayout( onTTLUpdated = onTTLUpdated ) - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.allow) } FeatureSection(ChatFeature.FullDelete, allowFullDeletion) { applyPrefs(preferences.copy(fullDelete = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.allow) } FeatureSection(ChatFeature.Reactions, allowReactions) { applyPrefs(preferences.copy(reactions = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.allow) } FeatureSection(ChatFeature.Voice, allowVoice) { applyPrefs(preferences.copy(voice = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(true, maxBottomPadding = false) + SectionDividerSpaced() val allowCalls = remember(preferences) { mutableStateOf(preferences.calls.allow) } FeatureSection(ChatFeature.Calls, allowCalls) { applyPrefs(preferences.copy(calls = SimpleChatPreference(allow = it))) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() ResetSaveButtons( reset = reset, save = savePrefs, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt index 2771b5ac62..7316c9bd82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/PrivacySettings.kt @@ -1,10 +1,11 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced import SectionItemView +import SectionDividerSpaced import SectionTextFooter import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -171,7 +172,7 @@ fun PrivacySettingsView( } if (!chatModel.desktopNoUserNoRemote) { - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() ContacRequestsFromGroupsSection( currentUser = currentUser, setAutoAcceptGrpDirectInvs = { enable -> @@ -179,7 +180,7 @@ fun PrivacySettingsView( } ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() DeliveryReceiptsSection( currentUser = currentUser, setOrAskSendReceiptsContacts = { enable -> @@ -619,7 +620,7 @@ fun SimplexLockView( } if (performLA.value && laMode.value == LAMode.PASSCODE) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.self_destruct_passcode).uppercase()) { + SectionView(stringResource(MR.strings.self_destruct_passcode)) { val openInfo = { ModalManager.start.showModal { SelfDestructInfoView() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt index c826b1dc51..f17d3a6e4b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/SettingsView.kt @@ -1,8 +1,9 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDividerSpaced +import itemHPadding import SectionItemView +import SectionDividerSpaced import SectionView import TextIconSpaced import androidx.compose.desktop.ui.tooling.preview.Preview @@ -46,12 +47,13 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: ( user?.displayName, setPerformLA = setPerformLA, showModal = { modalView -> { ModalManager.start.showModal { modalView(chatModel) } } }, - showSettingsModal = { modalView -> { ModalManager.start.showModal(true) { modalView(chatModel) } } }, + showSettingsModal = { modalView -> { ModalManager.start.showModal(settings = true, cardScreen = true) { modalView(chatModel) } } }, showSettingsModalWithSearch = { modalView -> ModalManager.start.showCustomModal { close -> val search = rememberSaveable { mutableStateOf("") } ModalView( { close() }, + cardScreen = true, showSearch = true, searchAlwaysVisible = true, onSearchValueChanged = { search.value = it }, @@ -295,14 +297,10 @@ fun ChatLockItem( } private fun resetHintPreferences() { - for ((pref, def) in appPreferences.hintPreferences) { - pref.set(def) - } + appPreferences.hintPreferences.forEach { it.reset() } } -fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { (pref, def) -> - pref.state.value == def -} +fun unchangedHintPreferences(): Boolean = appPreferences.hintPreferences.all { it.isUnchanged() } @Composable fun AppVersionItem(showVersion: () -> Unit) { @@ -352,9 +350,9 @@ fun SettingsActionItemWithContent(icon: Painter?, text: String? = null, click: ( click, extraPadding = extraPadding, padding = if (extraPadding && icon != null) - PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING) + PaddingValues(start = DEFAULT_PADDING * 1.7f, end = itemHPadding) else - PaddingValues(horizontal = DEFAULT_PADDING), + PaddingValues(horizontal = itemHPadding), disabled = disabled ) { if (icon != null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index e5c731f3b2..c55eaf6c10 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -8,6 +8,7 @@ import SectionView import SectionViewWithButton import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.ui.layout.ContentScale import androidx.compose.foundation.shape.RoundedCornerShape @@ -171,7 +172,7 @@ fun UserAddressView( ) } - ModalView(close = close) { + ModalView(close = close, cardScreen = true) { showLayout() } @@ -301,16 +302,16 @@ private fun UserAddressLayout( ) { if (userAddress == null) { if (!onboarding) { - SectionView(generalGetString(MR.strings.for_social_media).uppercase()) { + SectionView(generalGetString(MR.strings.for_social_media)) { CreateAddressButton(createAddress) } SectionDividerSpaced() - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + SectionView(generalGetString(MR.strings.or_to_share_privately)) { CreateOneTimeLinkButton() } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() SectionView { LearnMoreButton(learnMore) } @@ -336,7 +337,7 @@ private fun UserAddressLayout( val savedAddressSettingsState = remember { mutableStateOf(addressSettingsState.value) } SectionViewWithButton( - stringResource(MR.strings.for_social_media).uppercase(), + stringResource(MR.strings.for_social_media), titleButton = if (userAddress.connLinkContact.connShortLink != null) {{ ToggleShortLinkButton(showShortLink) }} else null ) { SimpleXCreatedLinkQRCode(userAddress.connLinkContact, short = showShortLink.value) @@ -353,26 +354,25 @@ private fun UserAddressLayout( // ShareViaEmailButton { sendEmail(userAddress) } BusinessAddressToggle(addressSettingsState) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) } AddressSettingsButton(user, userAddress, shareViaProfile, setProfileAddress, saveAddressSettings) - - if (addressSettingsState.value.businessAddress) { - SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) - } + } + if (addressSettingsState.value.businessAddress) { + SectionTextFooter(stringResource(MR.strings.add_your_team_members_to_conversations)) } - SectionDividerSpaced(maxTopPadding = addressSettingsState.value.businessAddress) - SectionView(generalGetString(MR.strings.or_to_share_privately).uppercase()) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.or_to_share_privately)) { CreateOneTimeLinkButton() } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { LearnMoreButton(learnMore) } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { DeleteAddressButton(deleteAddress) - SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) } + SectionTextFooter(stringResource(MR.strings.your_contacts_will_remain_connected)) } } } @@ -495,7 +495,7 @@ private fun ModalData.UserAddressSettings( } } - ModalView(close = { onClose(close) }) { + ModalView(close = { onClose(close) }, cardScreen = true) { ColumnWithScrollBar { AppBarTitle(stringResource(MR.strings.address_settings), hostDevice(user?.remoteHostId)) Column( @@ -512,10 +512,10 @@ private fun ModalData.UserAddressSettings( } SectionDividerSpaced() - SectionView(stringResource(MR.strings.address_welcome_message).uppercase()) { + SectionView(stringResource(MR.strings.address_welcome_message)) { AutoReplyEditor(addressSettingsState) } - SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) + SectionDividerSpaced() saveAddressSettingsButton(addressSettingsState.value == savedAddressSettingsState.value) { saveAddressSettings(addressSettingsState.value, savedAddressSettingsState) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt index d7ddb6b950..ac21fb6b23 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserProfilesView.kt @@ -1,7 +1,6 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer -import SectionDivider import SectionItemView import SectionItemViewSpaceBetween import SectionItemViewWithoutMinPadding @@ -177,7 +176,7 @@ private fun UserProfilesLayout( SectionView { for (user in filteredUsers) { UserView(user, visibleUsersCount, activateUser, removeUser, unhideUser, muteUser, unmuteUser, showHiddenProfile) - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } if (searchTextOrPassword.value.trim().isEmpty()) { SectionItemView(addUser, minHeight = 68.dp) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt index 8c38070c98..42746006a3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/networkAndServers/AdvancedNetworkSettings.kt @@ -8,6 +8,7 @@ import SectionTextFooter import SectionView import SectionViewSelectableCards import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.runtime.* @@ -158,6 +159,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - }, close) } }, + cardScreen = true, ) { AdvancedNetworkSettingsLayout( currentRemoteHost = currentRemoteHost, @@ -234,13 +236,13 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - SettingsPreferenceItem(painterResource(MR.images.ic_arrow_forward), stringResource(MR.strings.private_routing_show_message_status), chatModel.controller.appPrefs.showSentViaProxy) } SectionTextFooter(stringResource(MR.strings.private_routing_explanation)) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_session_mode_transport_isolation).uppercase()) { + SectionView(stringResource(MR.strings.network_session_mode_transport_isolation)) { SessionModePicker(sessionMode, showModal, updateSessionMode) } SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_smp_web_port_section_title).uppercase()) { + SectionView(stringResource(MR.strings.network_smp_web_port_section_title)) { ExposedDropDownSettingRow( stringResource(MR.strings.network_smp_web_port_toggle), SMPWebPortServers.entries.map { it to stringResource(it.text) }, @@ -251,9 +253,9 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - if (smpWebPortServers.value == SMPWebPortServers.Preset) stringResource(MR.strings.network_smp_web_port_preset_footer) else String.format(stringResource(MR.strings.network_smp_web_port_footer), if (smpWebPortServers.value == SMPWebPortServers.All) "443" else "5223") ) - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_option_tcp_connection).uppercase()) { + SectionView(stringResource(MR.strings.network_option_tcp_connection)) { SectionItemView { TimeoutSettingRow( stringResource(MR.strings.network_option_tcp_connection_timeout), networkTCPConnectTimeoutInteractive, @@ -330,7 +332,7 @@ fun ModalData.AdvancedNetworkSettingsView(showModal: (@Composable ModalData.() - } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { SectionItemView(reset, disabled = resetDisabled) { 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 index 1c68e780dc..ab63067226 100644 --- 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 @@ -149,7 +149,8 @@ fun ChatRelayView( text = generalGetString(MR.strings.check_relay_address) ) } - } + }, + cardScreen = true, ) { ChatRelayLayout( relayToEdit, @@ -182,7 +183,7 @@ private fun ChatRelayLayout( @Composable private fun PresetRelay(relay: MutableState, testing: MutableState) { - SectionView(stringResource(MR.strings.preset_relay_address).uppercase()) { + SectionView(stringResource(MR.strings.preset_relay_address)) { SelectionContainer { Text( relay.value.address, @@ -192,7 +193,7 @@ private fun PresetRelay(relay: MutableState, testing: MutableStat } } SectionDividerSpaced() - SectionView(stringResource(MR.strings.preset_relay_name).uppercase()) { + SectionView(stringResource(MR.strings.preset_relay_name)) { SectionItemView { Text(relay.value.displayName) } @@ -291,7 +292,7 @@ private fun UseRelaySection( testing: MutableState ) { val scope = rememberCoroutineScope() - SectionView(stringResource(MR.strings.use_relay).uppercase()) { + SectionView(stringResource(MR.strings.use_relay)) { SectionItemViewSpaceBetween( click = { testing.value = true @@ -377,7 +378,7 @@ fun ModalData.NewChatRelayView( ModalView(close = { addChatRelay(relayToEdit.value, userServers, serverErrors, serverWarnings, rhId, close) - }) { + }, cardScreen = true) { NewChatRelayLayout(relayToEdit) } } 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 a62a58cb10..892a252a9d 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 @@ -9,6 +9,7 @@ import SectionTextFooter import SectionView import SectionViewSelectable import TextIconSpaced +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.KeyboardActions import androidx.compose.material.* @@ -84,7 +85,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { onClose(close = { ModalManager.start.closeModals() }) } } - ModalView(close = { onClose(closeNetworkAndServers) }) { + ModalView(close = { onClose(closeNetworkAndServers) }, cardScreen = true) { NetworkAndServersLayout( currentRemoteHost = currentRemoteHost, networkUseSocksProxy = networkUseSocksProxy, @@ -210,7 +211,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { AppBarTitle(stringResource(MR.strings.network_and_servers)) // TODO: Review this and socks. if (!chatModel.desktopNoUserNoRemote) { - SectionView(generalGetString(MR.strings.network_preset_servers_title).uppercase()) { + SectionView(generalGetString(MR.strings.network_preset_servers_title)) { userServers.value.forEachIndexed { index, srv -> srv.operator?.let { ServerOperatorRow(index, it, currUserServers, userServers, serverErrors, serverWarnings, currentRemoteHost?.remoteHostId) } } @@ -262,14 +263,11 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { UseSocksProxySwitch(networkUseSocksProxy, toggleSocksProxy) SettingsActionItem(painterResource(MR.images.ic_settings_ethernet), stringResource(MR.strings.network_socks_proxy_settings), { showCustomModal { SocksProxySettings(networkUseSocksProxy.value, appPrefs.networkProxy, onionHosts, sessionMode = appPrefs.networkSessionMode.get(), false, it) } }) SettingsActionItem(painterResource(MR.images.ic_cable), stringResource(MR.strings.network_settings), { ModalManager.start.showCustomModal { AdvancedNetworkSettingsView(showModal, it) } }) - if (networkUseSocksProxy.value) { - SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) - SectionDividerSpaced(maxTopPadding = true) - } else { - SectionDividerSpaced(maxBottomPadding = false) - } } } + if (currentRemoteHost == null && networkUseSocksProxy.value) { + SectionTextFooter(annotatedStringResource(MR.strings.socks_proxy_setting_limitations)) + } val saveDisabled = !serversCanBeSaved(currUserServers.value, userServers.value, serverErrors.value) SectionItemView( @@ -303,7 +301,7 @@ fun ModalData.NetworkAndServersView(closeNetworkAndServers: () -> Unit) { if (appPlatform.isAndroid) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.settings_section_title_network_connection).uppercase()) { + SectionView(generalGetString(MR.strings.settings_section_title_network_connection)) { val info = remember { chatModel.networkInfo }.value SettingsActionItemWithContent(icon = null, info.networkType.text) { Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = if (info.online) Color.Green else MaterialTheme.colors.error) @@ -466,10 +464,11 @@ fun SocksProxySettings( ) } }, + cardScreen = true, ) { ColumnWithScrollBar { AppBarTitle(generalGetString(MR.strings.network_socks_proxy_settings)) - SectionView(stringResource(MR.strings.network_socks_proxy).uppercase()) { + SectionView(stringResource(MR.strings.network_socks_proxy)) { Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { DefaultConfigurableTextField( hostUnsaved, @@ -495,9 +494,9 @@ fun SocksProxySettings( SectionTextFooter(annotatedStringResource(MR.strings.disable_onion_hosts_when_not_supported)) } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() - SectionView(stringResource(MR.strings.network_proxy_auth).uppercase()) { + SectionView(stringResource(MR.strings.network_proxy_auth)) { PreferenceToggle( stringResource(MR.strings.network_proxy_random_credentials), checked = proxyAuthRandomUnsaved.value, @@ -526,7 +525,7 @@ fun SocksProxySettings( SectionTextFooter(proxyAuthFooter(usernameUnsaved.value.text, passwordUnsaved.value.text, proxyAuthModeUnsaved.value, sessionMode)) } - SectionDividerSpaced(maxBottomPadding = false, maxTopPadding = true) + SectionDividerSpaced() SectionView { SectionItemView({ 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 9e11b9a932..f5bceabbf1 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* +import androidx.compose.foundation.background import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -181,7 +182,7 @@ fun OperatorViewLayout( val duplicateHosts = findDuplicateHosts(serverErrors.value) Column { - SectionView(generalGetString(MR.strings.operator).uppercase()) { + SectionView(generalGetString(MR.strings.operator)) { SectionItemView({ ModalManager.start.showModalCloseable { _ -> OperatorInfoView(operator) } }) { Row( Modifier.fillMaxWidth(), @@ -238,7 +239,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) SectionDividerSpaced() - SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + SectionView(generalGetString(MR.strings.chat_relays)) { userServers.value[operatorIndex].chatRelays.forEachIndexed { index, relay -> if (!relay.deleted) { ChatRelayViewLink(relay, duplicateRelayAddresses) { @@ -252,7 +253,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_use_for_messages).uppercase()) { + SectionView(generalGetString(MR.strings.operator_use_for_messages)) { SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text( stringResource(MR.strings.operator_use_for_messages_receiving), @@ -306,7 +307,7 @@ fun OperatorViewLayout( // Preset servers can't be deleted if (userServers.value[operatorIndex].smpServers.any { it.preset }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (!server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -340,7 +341,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.preset && !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_added_message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.operator_added_message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted || server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -356,7 +357,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_use_for_files).uppercase()) { + SectionView(generalGetString(MR.strings.operator_use_for_files)) { SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text( stringResource(MR.strings.operator_use_for_sending), @@ -389,7 +390,7 @@ fun OperatorViewLayout( // Preset servers can't be deleted if (userServers.value[operatorIndex].xftpServers.any { it.preset }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + SectionView(generalGetString(MR.strings.media_and_file_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (!server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -423,7 +424,7 @@ fun OperatorViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.preset && !it.deleted}) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.operator_added_xftp_servers).uppercase()) { + SectionView(generalGetString(MR.strings.operator_added_xftp_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (server.deleted || server.preset) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -490,7 +491,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { } } - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() val uriHandler = LocalUriHandler.current SectionView { @@ -507,7 +508,7 @@ fun OperatorInfoView(serverOperator: ServerOperator) { val selfhost = serverOperator.info.selfhost if (selfhost != null) { - SectionDividerSpaced(maxBottomPadding = false) + SectionDividerSpaced() SectionView { SectionItemView { val (text, link) = selfhost 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 01630a2b52..b3326bd2e9 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 @@ -5,6 +5,7 @@ import SectionDividerSpaced import SectionItemView import SectionItemViewSpaceBetween import SectionView +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.* @@ -80,7 +81,8 @@ fun ProtocolServerView( ) } } - } + }, + cardScreen = true, ) { Box { ProtocolServerLayout( @@ -140,7 +142,7 @@ private fun PresetServer( testing: Boolean, testServer: () -> Unit ) { - SectionView(stringResource(MR.strings.smp_servers_preset_address).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_preset_address)) { SelectionContainer { Text( server.value.server, @@ -172,7 +174,7 @@ fun CustomServer( } } SectionView( - stringResource(MR.strings.smp_servers_your_server_address).uppercase(), + stringResource(MR.strings.smp_servers_your_server_address), icon = painterResource(MR.images.ic_error), iconTint = if (!valid.value) MaterialTheme.colors.error else Color.Transparent, ) { @@ -190,13 +192,13 @@ fun CustomServer( } } } - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() UseServerSection(server, valid.value, testing, testServer, onDelete) if (valid.value) { SectionDividerSpaced() - SectionView(stringResource(MR.strings.smp_servers_add_to_another_device).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_add_to_another_device)) { QRCode(serverAddress.value, small = true) } } @@ -210,7 +212,7 @@ private fun UseServerSection( testServer: () -> Unit, onDelete: (() -> Unit)? = null, ) { - SectionView(stringResource(MR.strings.smp_servers_use_server).uppercase()) { + SectionView(stringResource(MR.strings.smp_servers_use_server)) { SectionItemViewSpaceBetween(testServer, disabled = !valid || testing) { Text(stringResource(MR.strings.smp_servers_test_server), color = if (valid && !testing) MaterialTheme.colors.onBackground else MaterialTheme.colors.secondary) ShowTestStatus(server.value) 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 3be2456b72..280cd7bedb 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 @@ -7,9 +7,11 @@ import SectionItemView import SectionTextFooter import SectionView import androidx.compose.foundation.layout.* +import androidx.compose.foundation.background import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import chat.simplex.common.ui.theme.* import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -86,7 +88,7 @@ fun YourServersViewLayout( Column { if (userServers.value[operatorIndex].chatRelays.any { !it.deleted }) { val duplicateRelayAddresses = findDuplicateRelayAddresses(serverErrors.value) - SectionView(generalGetString(MR.strings.chat_relays).uppercase()) { + SectionView(generalGetString(MR.strings.chat_relays)) { userServers.value[operatorIndex].chatRelays.forEachIndexed { i, relay -> if (relay.deleted) return@forEachIndexed ChatRelayViewLink(relay, duplicateRelayAddresses) { @@ -99,7 +101,7 @@ fun YourServersViewLayout( if (userServers.value[operatorIndex].smpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.message_servers).uppercase()) { + SectionView(generalGetString(MR.strings.message_servers)) { userServers.value[operatorIndex].smpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.SMP) }) { @@ -133,7 +135,7 @@ fun YourServersViewLayout( if (userServers.value[operatorIndex].xftpServers.any { !it.deleted }) { SectionDividerSpaced() - SectionView(generalGetString(MR.strings.media_and_file_servers).uppercase()) { + SectionView(generalGetString(MR.strings.media_and_file_servers)) { userServers.value[operatorIndex].xftpServers.forEachIndexed { i, server -> if (server.deleted) return@forEachIndexed SectionItemView({ navigateToProtocolView(i, server, ServerProtocol.XFTP) }) { @@ -170,7 +172,7 @@ fun YourServersViewLayout( userServers.value[operatorIndex].xftpServers.any { !it.deleted } || userServers.value[operatorIndex].chatRelays.any { !it.deleted } ) { - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced() } SectionView { @@ -195,7 +197,7 @@ fun YourServersViewLayout( ServersWarningFooter(serversWarn) } } - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced() SectionView { TestServersButton( 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 95ec53287a..36744ad503 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -1091,7 +1091,7 @@ للاتصال، يمكن لجهة الاتصال مسح رمز QR أو استخدام الرابط في التطبيق. اختبر الخوادم لا معرّفات مُستخدم - دعم SIMPLEX CHAT + دعم SimpleX Chat بدِّل العنوان الرئيسي سيتم وضع علامة على الرسالة على أنها تحت الإشراف لجميع الأعضاء. 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 34a091df31..e5d313e9ed 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -27,6 +27,8 @@ Invalid file path You shared an invalid file path. Report the issue to the app developers. View crashed + App is already running + Another app instance may be running or did not exit properly. Start anyway? connected @@ -207,6 +209,7 @@ Error deleting group Error deleting private notes Error deleting contact request + Error deleting message Error deleting pending contact connection Error changing address Error aborting address change @@ -424,6 +427,7 @@ The messages will be marked as moderated for all members. Delete for me For everyone + From history Stop file Stop sending file? Sending file will be stopped. @@ -1097,7 +1101,7 @@ Off Appearance Customize theme - INTERFACE COLORS + Interface colors App version App version: v%s App build: %s @@ -1533,26 +1537,26 @@ Open clean link - YOU - SETTINGS - CHAT DATABASE - HELP - SUPPORT SIMPLEX CHAT - APP - DEVICE - CHATS - FILES - SEND DELIVERY RECEIPTS TO - CONTACT REQUESTS FROM GROUPS + You + Settings + Chat database + Help + Support SimpleX Chat + App + Device + Chats + Files + Send delivery receipts to + Contact requests from groups Restart Shutdown Developer tools Experimental features - SOCKS PROXY - INTERFACE + SOCKS proxy + Interface LANGUAGE - APP ICON - THEMES + App icon + Themes Profile images Message shape Corner @@ -1560,21 +1564,21 @@ Chat theme Profile theme Chat colors - MESSAGES AND FILES - PRIVATE MESSAGE ROUTING - CALLS + Messages and files + Private message routing + Calls Network connection Incognito mode - EXPERIMENTAL + Experimental Use from desktop Your chat database - RUN CHAT + Run chat Remote mobiles Chat is running Chat is stopped - CHAT DATABASE + Chat database Database passphrase Export database Import database @@ -1883,7 +1887,7 @@ Invite members Add team members Add friends - %1$s MEMBERS + %1$s members you: %1$s Delete group Delete channel @@ -1936,7 +1940,7 @@ Chat relays - FOR CONSOLE + For console Local name Database ID Debug delivery @@ -2006,7 +2010,7 @@ disabled failed inactive - MEMBER + Member Role Change role Change @@ -2023,7 +2027,7 @@ Group Chat Connection - CONNECTION FAILED + Connection failed direct indirect (%1$s) Message queue info @@ -2052,7 +2056,7 @@ Message too large - SERVERS + Servers Receiving via Sending via Network status @@ -2992,6 +2996,9 @@ accepted active inactive + rejected + Status + rejected by relay operator All relays removed @@ -3013,9 +3020,9 @@ Waiting for channel owner to add relays. - RELAY - OWNER - SUBSCRIBER + Relay + Owner + Subscriber Channel Relay link Relay address @@ -3079,4 +3086,16 @@ Link preview will be requested via SOCKS proxy. DNS lookup may still happen locally via your DNS resolver. Enable Disable + + + Minimize to tray? + If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. + Close the app + Minimize to tray + Show SimpleX + Quit SimpleX + SimpleX + SimpleX — %d unread + Close to tray + Runs in background to receive messages \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml index c691447b32..890720a727 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -83,7 +83,7 @@ Android Keystore се използва за сигурно съхраняване на паролата - тоа позволява на услугата за известия да работи. Създаен беше празен профил за чат с предоставеното име и приложението се отвари както обикновено. Приложението може да получава известия само когато работи, няма да се стартира услуга във фонов режим - ИКОНА НА ПРИЛОЖЕНИЕТО + Икона на приложението Идентифицирай Оптимизацията на батерията е активна, изключват се фоновата услуга и периодичните заявки за нови съобщения. Можете да ги активирате отново през настройките. за всеки чат профил, който имате в приложението.]]> @@ -169,13 +169,13 @@ чрез реле видео разговор Вашите обаждания - ПРИЛОЖЕНИЕ + Приложение Резервно копие на данните от приложението Кода за достъп до приложение се заменя с код за самоунищожение. Автоматично приемане на изображения Идентификацията е отменена Изпрати визуализация на линковете - ОБАЖДАНИЯ + Обаждания Android Keystore ще се използва за сигурно съхраняване на паролата, след като рестартирате приложението или промените паролата - това ще позволи получаването на известия. Промяна на паролата на базата данни\? променена ролята от %s на %s @@ -223,7 +223,7 @@ Базата данни е изтрита Чатът работи Чатът е спрян - БАЗА ДАННИ + База данни Базата данни е импортирана Потвърди новата парола… Потвърди актуализаациите на базата данни @@ -314,13 +314,13 @@ Промяна на режима на заключване Промени режима на самоунищожение Промени кода за достъп за самоунищожение - ЧАТОВЕ + Чатове промяна на адреса… В момента максималният поддържан размер на файла е %1$s. ID в базата данни ID в базата данни: %d Контакти - ТЕМИ + Теми Базата данни е криптирана с автоматично генерирана парола. Моля, променете я преди експортиране. Парола за базата данни Изтрий базата данни @@ -406,7 +406,7 @@ Идентификатори в базата данни и опция за изолация на транспорта. Изтрий адрес Изтрий адрес\? - ЦВЕТОВЕ НА ИНТЕРФЕЙСА + Цветове на интерфейса Създай Създай профил Изтрий изображение @@ -446,7 +446,7 @@ Активирай потвърждениeто\? Изпращането на потвърждениe за доставка е деактивирано за %d контакта Изпращането на потвърждениe е активирано за %d контакта - УСТРОЙСТВО + Устройство Деактивиране (запазване на промените) %d файл(а) с общ размер от %s Криптирай @@ -485,7 +485,7 @@ Те могат да бъдат променени в настройките за всеки контакт и група. Инструменти за разработчици Деактивиране за всички - ИЗПРАЩАЙТЕ ПОТВЪРЖДЕНИE ЗА ДОСТАВКА НА + Изпращайте потвърждениe за доставка на Изтрий съобщенията след %s секунда(и) Изтрий съобщенията @@ -622,7 +622,7 @@ Пълно име: Изход без запазване Парола за скрит профил - ЕКСПЕРИМЕНТАЛЕН + Експериментален Файл: %s Разшири избора на роля Поправи връзката\? @@ -632,7 +632,7 @@ Изпратените съобщения ще бъдат изтрити след зададеното време. Групов линк Файлове и медия - ЗА КОНЗОЛАТА + За конзолата Групови настройки Файл Файлът не е намерен @@ -643,7 +643,7 @@ Филтрирайте непрочетените и любимите чатове. Членовете могат да изпращат лични съобщения. помощ - ПОМОЩ + Помощ Здравей, \nСвържи се с мен през SimpleX Chat: %s Членовете могат да добавят реакции към съобщенията. @@ -805,7 +805,7 @@ Когато приложението работи Периодично Постави получения линк - СЪОБЩЕНИЯ И ФАЙЛОВЕ + Съобщения и файлове Няма получени или изпратени файлове Известията ще се доставят само докато приложението не е спряно! Премахване на парола от Keystore\? @@ -987,7 +987,7 @@ Режим на заключване Моля, докладвайте го на разработчиците. Защити екрана на приложението - ЧЛЕН + Член Премахване PING бройка Само вашият контакт може да добавя реакции на съобщенията. @@ -1042,8 +1042,8 @@ Сподели с контактите Спри споделянето Спри споделянето на адреса\? - НАСТРОЙКИ - СТАРТИРАНЕ НА ЧАТ + Настройки + Стартиране на чат Задай име на контакт… Няма информация за доставката Отзови файл\? @@ -1067,7 +1067,7 @@ Изпращането на потвърждениe за доставка е разрешено за %d групи Рестартирайте приложението, за да използвате импортирана база данни. Тази група има над %1$d членове, потвърждениeто за доставка няма да се изпраща. - СЪРВЪРИ + Сървъри %s: %s Доставка Активиране (запазване на груповите промени) @@ -1093,7 +1093,7 @@ Сподели медия… SimpleX адрес Сигурността на SimpleX Chat беше одитирана от Trail of Bits. - SOCKS ПРОКСИ + SOCKS прокси Рестартиране Изключване Рестартирайте приложението, за да създадете нов чат профил. @@ -1152,7 +1152,7 @@ Заглавие (за споделяне с вашия контакт) Тази група вече не съществува. - ПОДКРЕПЕТЕ SIMPLEX CHAT + Подкрепете SimpleX Chat Вашият контакт изпрати файл, който е по-голям от поддържания в момента максимален размер (%1$s). Тази функция все още не се поддържа. Опитайте следващата версия. Докосни за започване на нов чат @@ -1231,7 +1231,7 @@ адреса за получаване е променен Можете да споделите този адрес с вашите контакти, за да им позволите да се свържат с %s. Премахни от любимите - ВИЕ + Вие Вашата база данни Изчаква се получаването на изображението Изчаква се получаването на изображението @@ -1775,7 +1775,7 @@ За да защити вашия IP адрес, поверително рутиране използва вашите SMP сървъри за доставяне на съобщения. Препращане на съобщенията без файловете? Неизвестни сървъри - ФАЙЛОВЕ + Файлове Показване на списъка на чатовете в нов прозорец Системна Тъмна @@ -1800,7 +1800,7 @@ Препращащ сървър: %1$s\nГрешка: %2$s Версията на сървъра е несъвместима с мрежовите настройки. Защити IP адреса - ПОВЕРИТЕЛНО РУТИРАНЕ НА СЪОБЩЕНИЯ + Поверително рутиране на съобщения Приложението ще поиска потвърждение за изтегляния от неизвестни файлови сървъри (с изключение на .onion сървъри или когато SOCKS прокси е активирано). Грешка: %1$s Изтегляне @@ -2038,7 +2038,7 @@ Изтегли %s (%s) Пропусни тази версия Провери за актуализации - БАЗА ДАННИ + База данни Можете да изпращате съобщения до %1$s от архивираните контакти. Достъпен панел Изпращането на съобщения на груповия член не е налично @@ -2501,7 +2501,7 @@ Сподели стар линк Линкът ще бъде кратък и профилът на групата ще бъде споделен чрез него. Обнови групов линк - ЗАЯВКИ ЗА КОНТАКТ ОТ ГРУПИ + Заявки за контакт от групи Членът е изтрит - не може да се приеме заявката заявка за връзка от група %1$s Тази настройка е за текущия профил 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 7ab3f5a381..c53247853f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -126,10 +126,10 @@ S\'eliminaran totes les dades de l\'aplicació. Es crea un perfil de xat buit amb el nom proporcionat i l\'aplicació s\'obre com de costum. La contrasenya de l\'aplicació es substitueix per una contrasenya d\'autodestrucció. - APLICACIÓ - ICONA APLICACIÓ + Aplicació + Icona aplicació Desenfocar els mitjans - TRUCADES + Trucades Android Keystore s\'utilitza per emmagatzemar de manera segura la frase de contrasenya: permet que el servei de notificacions funcioni. Android Keystore s\'utilitzarà per emmagatzemar de manera segura la frase de contrasenya després de reiniciar l\'aplicació o canviar la frase de contrasenya; permetrà rebre notificacions. No es pot accedir a Keystore per desar la contrasenya de la base de dades @@ -383,7 +383,7 @@ Desactivar rebuts? Desactivar rebuts per a grups? Eines per a desenvolupadors - DISPOSITIU + Dispositiu La base de dades es xifra amb una contrasenya aleatòria. Si us plau, canvieu-la abans d\'exportar. Contrasenya de la base de dades Voleu suprimir el perfil? @@ -514,8 +514,8 @@ Canvia el mode l\'autodestrucció Canvia el codi d\'autodestrucció Confirmeu el codi d\'accés - BASE DE DADES DELS XATS - XATS + Base de dades dels xats + Xats Tema del xat Colors del xat Base de dades suprimida @@ -551,7 +551,7 @@ Obre a l\'aplicació mòbil.]]> Error en desar els servidors ICE Error en desar el servidor intermediari - BASE DE DADES DELS XATS + Base de dades dels xats El xat s\'està executant El xat està aturat Error: %s @@ -742,7 +742,7 @@ s\'està connectant… Les condicions s\'acceptaran per als operadors habilitats després de 30 dies. SimpleX no pot funcionar en segon pla. Només rebreu les notificacions quan obriu l\'aplicació. - Trucades de SimpleX chat + Trucades de SimpleX Chat Missatges de xat de SimpleX enviat per llegir @@ -781,7 +781,7 @@ Mostra: Amaga: SimpleX - La seguretat de SimpleX chat ha estat auditada per Trail of Bits. + La seguretat de SimpleX Chat ha estat auditada per Trail of Bits. Parlem a SimpleX Chat El nom no és vàlid! cursiva @@ -794,7 +794,7 @@ inactiu Mode clar Grups d\'incògnit - MEMBRE + Membre Voleu unir-vos al grup? Surt Voleu sortir del xat? @@ -966,8 +966,8 @@ Activar els rebuts? Activar autodestrucció Activar els rebuts per a grups? - FITXERS - EXPERIMENTAL + Fitxers + Experimental Exportar base de dades Xifrar Fitxer: %s @@ -981,7 +981,7 @@ Grup no trobat! es requereix renegociar el xifratge grup esborrat - PER A CONSOLA + Per a consola Arreglar connexió? Correcció no suportada per membre del grup Nom complet del grup: @@ -1055,7 +1055,7 @@ Donar permís(os) per fer trucades Auriculars Xifra fitxers locals - AJUT + Ajut Arxius i mitjans Xifrar base de dades? Base de dades xifrada @@ -1109,7 +1109,7 @@ Missatge El missatge és massa llarg! missatge - MISSATGES I FITXERS + Missatges i fitxers Missatges Estat del missatge Estat del missatge: %s @@ -1266,15 +1266,15 @@ L\'enviament de rebuts està desactivat per a %d contactes L\'enviament de rebuts està habilitat per a %d contactes L\'enviament de rebuts està habilitat per a %d grups - ENVIAR ELS REBUS DE LLIURAMENT A + Enviar els rebus de lliurament a L\'enviament de rebuts està desactivat per a %d grups Reiniciar - SERVIDOR INTERMEDIARI SOCKS + Servidor intermediari SOCKS Imatges de perfil - TEMES + Temes Cua Forma del missatge - EXECUTAR SIMPLEX + Executar SimpleX Usar des d\'ordinador Base de dades de xat Importar base de dades @@ -1899,7 +1899,7 @@ Utilitzar servidor intermediari SOCKS? Si disponibles Les vostres credencials es podrien enviar sense xifrar. - COLORS DE LA INTERFÍCIE + Colors de la interfície Alternativa d\'encaminament de missatges Mode d\'encaminament de missatges Obrir ubicació del fitxer @@ -1949,12 +1949,12 @@ Aquesta configuració és per al vostre perfil actual Es pot canviar a la configuració de contacte i grup. No - CONFIGURACIÓ + Configuració Tou Fort - SUPORT SIMPLEX XAT + Suport SimpleX Chat Connexió a la xarxa - ENCAMINAMENT DE MISSATGES PRIVAT + Encaminament de missatges privat mai No s\'han rebut ni enviats fitxers Reinicieu l\'aplicació per crear un perfil de xat nou. @@ -2024,7 +2024,7 @@ Vista prèvia Rebent via Desa i actualitza el perfil del grup - SERVIDORS + Servidors Missatge de benvinguda El missatge de benvinguda és massa llarg Els teus servidors @@ -2183,7 +2183,7 @@ Servidors SMP Servidors XFTP Altaveu - VÓS + Vós %s segon(s) "Heu estat convidat a un grup" %s connectat @@ -2474,7 +2474,7 @@ Compartir l\'enllaç antic L\'enllaç serà curt i el perfil del grup es compartirà a través d\'ell. Actualitzar l\'enllaç del grup - SOL·LICITUDS DE CONTACTE DE GRUPS + Sol·licituds de contacte de grups Membre eliminat(da); no es pot acceptar la sol·licitud. connexió sol·licitada del grup %1$s Aquesta configuració és per al perfil actual 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 df4907885c..3a97c594b1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -40,11 +40,11 @@ Vytvořit odkaz Smazat odkaz\? Odeslat přímou zprávu - ČLEN + Člen Změnit roli ve skupině\? Připoj nepřímé (%1$s) - SERVERY + Servery Příjímáno přes Vytvoření tajné skupiny Zadejte název skupiny: @@ -259,16 +259,16 @@ Reproduktor zapnut Probíhající hovor Automaticky přijímat obrázky - NASTAVENÍ - NÁPOVĚDA - ZAŘÍZENÍ - KONVERZACE + Nastavení + Nápověda + Zařízení + Konverzace Experimentální funkce - SOCKS PROXY - IKONA APLIKACE - TÉMATA - ZPRÁVY A SOUBORY - VOLÁNÍ + SOCKS proxy + Ikona aplikace + Témata + Zprávy a soubory + Volání Export databáze Import databáze Smazat databázi @@ -700,15 +700,15 @@ \n1. Zprávy vypršely v odesílajícím klientovi po 2 dnech nebo na serveru po 30 dnech. \n2. Dešifrování zprávy se nezdařilo, protože vy nebo váš kontakt jste použili starou zálohu databáze. \n3. Spojení je kompromitováno. - VY - PODPOŘIT SIMPLEX CHAT + Vy + Podpořit SimpleX Chat Nástroje pro vývojáře Inkognito mód Vaše chat databáze - SPUSTIT CHAT + Spustit chat Chat je spuštěn Chat je zastaven - DATABÁZE CHATU + Databáze chatu přístupová fráze k databázi Archiv nové databáze Archiv staré databáze @@ -836,7 +836,7 @@ Chyba při vytváření odkazu skupiny Chyba při odstraňování odkazu skupiny Předvolby skupiny mohou měnit pouze vlastníci skupiny. - PRO KONSOLE + Pro konsole Místní název ID databáze Odstranit člena @@ -991,7 +991,7 @@ Zvýšit a otevřít chat Skrýt: Zobrazit možnosti vývojáře - POKUSNÝ + Pokusný Obrázek bude přijat, až kontakt dokončí jeho nahrání. Zobrazit: ID databáze a možnost Izolace přenosu. @@ -1164,7 +1164,7 @@ Když někdo požádá o připojení, můžete žádost přijmout nebo odmítnout. Uživatelské příručce.]]> Adresa SimpleX - BARVY MOTIVU + Barvy motivu Přizpůsobit motiv Aktualizace profilu bude zaslána vašim kontaktům. Sdílet adresu s kontakty? @@ -1301,7 +1301,7 @@ V odpovědi na Žádná historie Časový limit protokolu na KB - ZASLAT POTVRZENÍ O DORUČENÍ NA + Zaslat potvrzení o doručení na Druhé zaškrtnutí jsme přehlédli! ✅ Přijímací adresa bude změněna na jiný server. Změna adresy bude dokončena po připojení odesílatele. Vybrat soubor @@ -1803,8 +1803,8 @@ \nProsím sdělte jakékoli další problémy vývojářům. Ne NEposílejte zprávy přímo, i když váš nebo cílový server nepodporuje soukromé směrování. - SOUBORY - SOUKROMÉ SMĚROVÁNÍ ZPRÁV + Soubory + Soukromé směrování zpráv Téma profilu Přijata odpověď Obnovit barvu @@ -1878,7 +1878,7 @@ Připomenout později Zkontrolovat aktualizace Vypnuto - CHAT DATABÁZE + Chat databáze vypnut info fronty serveru: %1$s\n\nposlední obdržená zpráva: %2$s Uložit a připojit znovu @@ -2362,7 +2362,7 @@ Členové budou odstraněny ze skupiny - toto nelze zvrátit! Odebrat členy? Členové budou odstraněny z chatu - toto nelze zvrátit! - Použitím SimpleX chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. + Použitím SimpleX Chatu souhlasíte že:\n- ve veřejných skupinách budete zasílat pouze legální obsah.\n- budete respektovat ostatní uživatele – žádný spam. Přijmout Zásady ochrany soukromí a podmínky používání. Soukromé konverzace, skupiny a kontakty nejsou přístupné provozovatelům serverů. @@ -2430,7 +2430,7 @@ Otevřít novou skupinu Připojit Připojte se rychleji! 🚀 - POŽADAVKY NA PŘIPOJENÍ ZE SKUPIN + Požadavky na připojení ze skupin kontakt by měl přijmout… Vytvořit vaši adresu Popis příliš dlouhý 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 38507cc228..9487b85cb3 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -157,7 +157,7 @@ En anden grund Svaropkald Alle kan være vært for servere. - APP + App App løber altid i baggrunden App Build: %s App kan kun modtage meddelelser, når den kører, ingen baggrundstjeneste startes 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 8700ade74e..ebe38b2dc7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -549,26 +549,26 @@ Linkvorschau senden App-Datensicherung - MEINE DATEN - EINSTELLUNGEN - HILFE - UNTERSTÜTZUNG VON SIMPLEX CHAT - GERÄT - CHATS + Meine Daten + Einstellungen + Hilfe + Unterstützung von SimpleX Chat + Gerät + Chats Entwicklertools Experimentelle Funktionen - SOCKS-PROXY - APP-ICON - DESIGN - NACHRICHTEN und DATEIEN - CALLS + SOCKS-Proxy + App-Icon + Design + Nachrichten und Dateien + Calls Inkognito-Modus Chat-Datenbank - CHAT STARTEN + Chat starten Der Chat läuft Der Chat ist beendet - CHAT-DATENBANK + Chat-Datenbank Datenbank-Passwort Datenbank exportieren Datenbank importieren @@ -747,7 +747,7 @@ Sie versuchen, einen Kontakt, mit dem Sie ein Inkognito-Profil geteilt haben, in die Gruppe einzuladen, in der Sie Ihr Hauptprofil verwenden. Mitglieder einladen - %1$s MITGLIEDER + %1$s Mitglieder Sie: %1$s Gruppe löschen Gruppe löschen? @@ -765,7 +765,7 @@ Fehler beim Löschen des Gruppen-Links Gruppen-Präferenzen können nur von Gruppen-Eigentümern geändert werden. - FÜR KONSOLE + Für Konsole Lokaler Name Datenbank-ID @@ -773,7 +773,7 @@ Direktnachricht senden Das Mitglied wird aus der Gruppe entfernt. Dies kann nicht rückgängig gemacht werden! Entfernen - MITGLIED + Mitglied Rolle Rolle ändern Ändern @@ -788,7 +788,7 @@ direkt indirekt (%1$s) - SERVER + Server Empfangen über Senden über Netzwerkstatus @@ -1065,7 +1065,7 @@ Datenbank-Aktualisierungen bestätigen Anzeigen: Entwickleroptionen anzeigen - EXPERIMENTELL + Experimentell Datenbank-Aktualisierung Unterschiedlicher Migrationsstand in der App/Datenbank: %s / %s Datenbank herabstufen und den Chat öffnen @@ -1189,7 +1189,7 @@ Wenn Personen eine Verbindung anfordern, können Sie diese annehmen oder ablehnen. Sie werden Ihre damit verbundenen Kontakte nicht verlieren, wenn Sie diese Adresse später löschen. Design anpassen - INTERFACE-FARBEN + Interface-Farben Fügen Sie die Adresse Ihrem Profil hinzu, damit Ihre SimpleX-Kontakte sie mit anderen Personen teilen können. Es wird eine Profilaktualisierung an Ihre SimpleX-Kontakte gesendet. Alle Ihre Kontakte bleiben verbunden. Es wird eine Profilaktualisierung an Ihre Kontakte gesendet. Erstellen Sie eine Adresse, damit sich Personen mit Ihnen verbinden können. @@ -1309,7 +1309,7 @@ Während des Imports sind nicht schwerwiegende Fehler aufgetreten: Herunterfahren\? Bis zum Neustart der App erhalten Sie keine Benachrichtigungen mehr - APP + App Neustart Herunterfahren Fehler beim Beenden des Adresswechsels @@ -1372,7 +1372,7 @@ Bestätigungen aktivieren\? Das Senden von Bestätigungen an %d Kontakte ist aktiviert Für alle aktivieren - EMPFANGSBESTÄTIGUNGEN SENDEN AN + Empfangsbestätigungen senden an Deaktivieren (vorgenommene Einstellungen bleiben erhalten) Bestätigungen senden Ihre Verbindungen beibehalten @@ -1851,13 +1851,13 @@ Herabstufung erlauben Sie nutzen immer privates Routing. Nachrichten werden nicht direkt versendet, selbst wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. - PRIVATES NACHRICHTEN-ROUTING + Privates Nachrichten-Routing Nachrichten werden direkt versendet, wenn die IP-Adresse geschützt ist, und Ihr oder der Ziel-Server kein privates Routing unterstützt. Nachrichten werden direkt versendet, wenn Ihr oder der Ziel-Server kein privates Routing unterstützt. Zum Schutz Ihrer IP-Adresse, wird für die Nachrichten-Auslieferung privates Routing über Ihre konfigurierten SMP-Server genutzt. Sie nutzen privates Routing mit unbekannten Servern, wenn Ihre IP-Adresse nicht geschützt ist. IP-Adresse schützen - DATEIEN + Dateien Die App wird bei unbekannten Datei-Servern nach einer Download-Bestätigung fragen (außer bei .onion oder wenn ein SOCKS-Proxy aktiviert ist). Unbekannte Server! Ohne Tor- oder VPN-Nutzung wird Ihre IP-Adresse für Datei-Server sichtbar sein. @@ -2126,7 +2126,7 @@ Neue Nachricht Bitte überprüfen Sie, ob der SimpleX-Link korrekt ist. Ungültiger Link - CHAT-DATENBANK + Chat-Datenbank Fehler beim Wechseln des Profils Die Nachrichten werden gelöscht. Dies kann nicht rückgängig gemacht werden! Profil teilen @@ -2583,7 +2583,7 @@ Alten Link teilen Der Link wird gekürzt sein, und das Gruppen-Profil wird über den Link geteilt. Gruppen-Link aktualisieren - KONTAKTANFRAGEN VON GRUPPEN + Kontaktanfragen von Gruppen Mitglied ist gelöscht - Anfrage kann nicht angenommen werden Angefragte Verbindung von Gruppe %1$s Diese Einstellung gilt für Ihr aktuelles Profil @@ -2626,7 +2626,7 @@ Sprachnachrichten suchen Videos Sprachnachrichten - VERBINDUNG FEHLGESCHLAGEN + Verbindung fehlgeschlagen Fehlgeschlagen Kanäle, welche Sie erstellt haben oder denen Sie beigetreten sind, werden dauerhaft deaktiviert. %1$d/%2$d Relais aktiv @@ -2695,12 +2695,12 @@ Es sind nicht alle Relais verbunden Kanal öffnen Neuen Kanal öffnen - EIGENTÜMER + Eigentümer Eigentümer Voreingestellte Relais-Adresse Voreingestellter Relais-Name Relais - RELAIS + Relais Relais-Adresse Relais-Adresse Relais-Verbindung fehlgeschlagen @@ -2711,7 +2711,7 @@ Der Server erfordert eine Autorisierung, um eine Verbindung zum Relais herzustellen. Bitte Passwort überprüfen. Serverwarnung Relais-Adresse teilen - ABONNENT + Abonnent Abonnenten Abonnenten verbinden sich über den Relais‑Link mit dem Kanal.\nDie Relais-Adresse wurde zur Einrichtung dieses Relais für diesen Kanal verwendet. Abonnent wird aus dem Kanal entfernt. Dies kann nicht rückgängig gemacht werden! 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 47cfd90ad6..390913c5f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -86,7 +86,7 @@ Ο ΙCE διακομιστής σου Κωδικός πρόσβασης εφαρμογής Αίτημα σύνδεσης θα σταλεί σε αυτό το μέλος της ομάδας. - ΟΙΚΟΝΑ ΕΦΑΡΜΟΓΗΣ + Εικόνα εφαρμογής Εφαρμογή Οι ρυθμίσεις σου Έκδοση εφαρμογής: v%s @@ -105,7 +105,7 @@ Άλλαξε \nΔιαθέσιμο στην έκδοση 5.1 Τέλος κλήσης - ΚΛΗΣΕΙΣ + Κλήσεις Αυτόματη αποδοχή %1$d αποτυχία κρυπτογράφησης μηνύματος αλλαγή διεύθυνσης για %s… @@ -252,7 +252,7 @@ Eνεργοποίηση ήχου Κακό μήνυμα hash Θάμπωση των μέσων - ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ + Βάση δεδομένων συνομιλίας Το Android Keystore χρησιμοποιείται για την ασφαλή αποθήκευση της φράσης πρόσβασης - επιτρέπει την υπηρεσία ειδοποιήσεων να λειτουργεί. αποκλεισμένος Αποκλεισμένος από τον διαχειριστή @@ -288,7 +288,7 @@ Αρχειοθετημένες επαφές Ακύρωση μεταφοράς Χρώματα συνομιλίας - ΒΑΣΗ ΔΕΔΟΜΕΝΩΝ ΣΥΝΟΜΙΛΙΑΣ + Βάση δεδομένων συνομιλίας Η συνομιλία εκτελείται Παρακαλώ σημείωσε: ΔΕΝ θα μπορείς να ανακτήσεις ή να αλλάξεις τη φράση πρόσβασης εάν τη χάσεις.]]> Αποκλεισμός για όλους @@ -377,7 +377,7 @@ κακό αναγνωριστικό μηνύματος Απάντηση κλήσης Κακό αναγνωριστικό μηνύματος - ΣΥΝΟΜΙΛΙΕΣ + Συνομιλίες Η βάση δεδεδομένων της συνομιλίας εισάχθηκε συμφωνία κρυπτογράφησης για %s… Να επιτραπούν οι κλήσεις; @@ -600,7 +600,7 @@ Κρυμμένη επαφή: Η επαφή διαγράφηκε. η επαφή δεν είναι έτοιμη - ΑΙΤΗΣΕΙΣ ΕΠΑΦΩΝ ΑΠΟ ΟΜΑΔΕΣ + Αιτήσεις επαφών από ομάδες Επαφές η επαφή πρέπει να αποδεχτεί… Η επαφή θα διαγραφεί – αυτή η ενέργεια δεν μπορεί να αναιρεθεί! @@ -759,7 +759,7 @@ Λεπτομέρειες Επιλογές προγραμματιστή Εργαλεία προγραμματιστή - ΣΥΣΚΕΥΗ + Συσκευή Η επαλήθευση συσκευής είναι απενεργοποιημένη. Απενεργοποιείται το SimpleX Lock. Η επαλήθευση συσκευής δεν είναι ενεργοποιημένη. Μπορείς να ενεργοποιήσεις το SimpleX Lock από τις Ρυθμίσεις, αφού πρώτα ενεργοποιήσεις την επαλήθευση συσκευής. Συσκευές @@ -927,7 +927,7 @@ Για όλους τους διαχειριστές για καλύτερη ιδιωτικότητα μεταδεδομένων Για το προφίλ συνομιλίας %s: - ΓΙΑ ΚΟΝΣΟΛΑ + Για κονσόλα Για όλους Για παράδειγμα, αν η επαφή σου λαμβάνει μηνύματα μέσω κάποιου SimpleX Chat διακομιιστή, η εφαρμογή σου θα τα παραδίδει μέσω ενός Flux διακομιστή. Για μένα @@ -986,7 +986,7 @@ Τερματισμός κλήσης Ακουστικά βοήθεια - ΒΟΗΘΕΙΑ + Βοήθεια Βοήθησε τους διαχειριστές να διαχειρίζονται τις ομάδες τους. Γεια σου!\nΣυνδέσου μαζί μου μέσω SimpleX Chat: %s Κρυφό @@ -1069,7 +1069,7 @@ Άμεσες ειδοποιήσεις Άμεσες ειδοποιήσεις! Οι άμεσες ειδοποιήσεις είναι απενεργοποιημένες! - ΧΡΩΜΑΤΑ ΔΙΕΠΑΦΗΣ + Χρώματα διεπαφής Εσωτερικό σφάλμα μη έγκυρη συνομιλία Μη έγκυρος σύνδεσμος @@ -1175,7 +1175,7 @@ Διακομιστές πολυμέσων & αρχείων Μεσαίο μέλος - ΜΕΛΟΣ + Μέλος Μέλος %1$s το μέλος %1$s άλλαξε σε %2$s Εγγραφή μέλους @@ -1219,7 +1219,7 @@ Εναλλακτική δρομολόγηση μηνυμάτων Λειτουργία δρομολόγησης μηνυμάτων Μηνύματα - ΜΗΝΥΜΑΤΑ ΚΑΙ ΑΡΧΕΙΑ + Μηνύματα και αρχεία Διακομιστές μηνυμάτων Θα εμφανιστούν τα μηνύματα από το %s! Θα εμφανιστούν τα μηνύματα από αυτά τα μέλη! @@ -1332,7 +1332,7 @@ Η εικόνα δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε μια άλλη εικόνα ή επικοινώνησε με τους προγραμματιστές. Ο σύνδεσμος θα είναι σύντομος και το προφίλ της ομάδας θα κοινοποιηθεί μέσω αυτού. Θέμα - ΘΕΜΑΤΑ + Θέματα Τα μηνύματα θα διαγραφούν για όλα τα μέλη. Τα μηνύματα θα επισημαίνονται ως ελεγχόμενα για όλα τα μέλη. Το μήνυμα θα διαγραφεί για όλα τα μέλη. @@ -1582,7 +1582,7 @@ Έξοδος χωρίς αποθήκευση Επέκτεινε Επέκταση επιλογής ρόλου - ΠΕΙΡΑΜΑΤΙΚΟ + Πειραματικό Πειραματικά χαρακτηριστικά έληξε Εξαγωγή της βάσης δεδομένων @@ -1604,7 +1604,7 @@ Το αρχείο δεν βρέθηκε - πιθανότατα το αρχείο διαγράφηκε ή ακυρώθηκε. Αρχείο: %s Αρχεία - ΑΡΧΕΙΑ + Αρχεία Αρχεία και πολυμέσα Απαγορεύονται τα αρχεία και τα πολυμέσα. Τα αρχεία και τα πολυμέσα, απαγορεύονται σε αυτήν τη συνομιλία. @@ -1863,7 +1863,7 @@ Ιδιωτικά ονόματα αρχείων Ιδιωτικά ονόματα αρχείων πολυμέσων. Δρομολόγηση ιδιωτικών μηνυμάτων 🚀 - ΔΡΟΜΟΛΟΓΗΣΗ ΙΔΙΩΤΙΚΩΝ ΜΗΝΥΜΑΤΩΝ + Δρομολόγηση ιδιωτικών μηνυμάτων Ιδιωτικές σημειώσεις Ιδιωτικές σημειώσεις Ιδιωτικές ειδοποιήσεις @@ -2032,7 +2032,7 @@ Ανάκληση αρχείου Ανάκληση αρχείου; Ρόλος - ΕΚΚΙΝΗΣΗ ΣΥΝΟΜΙΛΙΑΣ + Εκκίνηση συνομιλίας Εκτελείται όταν η εφαρμογή είναι ανοιχτή Ασφαλής λήψη αρχείων Ασφαλέστερες ομάδες @@ -2098,7 +2098,7 @@ Απέστειλε Στείλε ένα ζωντανό μήνυμα - θα ενημερώνεται για τον παραλήπτη ή τους παραλήπτες καθώς το πληκτρολογείς. Αποστολή αιτήματος επαφής; - ΑΠΟΣΤΟΛΗ ΑΝΑΦΟΡΩΝ ΠΑΡΑΔΟΣΗΣ ΣΕ + Αποστολή αναφορών παράδοσης σε Αποστολή άμεσου μηνύματος Στείλε άμεσο μήνυμα για να συνδεθείς Αποστολή μηνύματος που εξαφανίζεται @@ -2153,7 +2153,7 @@ πληροφορίες ουράς διακομιστή: %1$s\n\nτελευταίο ληφθέν μήνυμα: %2$s Ο διακομιστής απαιτεί εξουσιοδότηση για τη δημιουργία ουρών, έλεγξε τον κωδικό. Ο διακομιστής απαιτεί εξουσιοδότηση για ανέβασμα αρχείων, έλεγξε τον κωδικό. - ΔΙΑΚΟΜΙΣΤΕΣ + Διακομιστές Πληροφορίες διακομιστών Θα γίνει επαναφορά στα στατιστικά στοιχεία των διακομιστών - αυτή η ενέργεια δεν μπορεί να αναιρεθεί! Η δοκιμή του διακομιστή απέτυχε! @@ -2179,7 +2179,7 @@ Όρισε το εμφανιζόμενο μήνυμα για τα νέα μέλη! Ρυθμίσεις Ρυθμίσεις - ΡΥΘΜΙΣΕΙΣ + Ρυθμίσεις Όρισε τη φράση πρόσβασης της βάσης δεδομένων Διαμόρφωση εικόνων προφίλ Διαμοίρασε @@ -2260,7 +2260,7 @@ Διακομιστής SMP Διακομιστές SMP Διακομιστής μεσολάβησης SOCKS - ΔΙΑΚΟΜΙΣΤΗΣ ΜΕΣΟΛΑΒΗΣΗΣ SOCKS + Διακομιστής μεσολάβησης SOCKS Ρυθμίσεις διακομιστή μεσολάβησης SOCKS Απαλό Κάποιο/α αρχείο/α δεν εξήχθησαν @@ -2309,7 +2309,7 @@ Η εγγραφή αγνοήθηκε %s ανεβασμένα Υποστήριξη bluetooth και άλλων βελτιώσεων. - ΥΠΟΣΤΗΡΙΞΗ SIMPLEX CHAT + Υποστήριξη SimpleX Chat Ενάλλαξε Εναλλαγή ήχου και βίντεο κατά τη διάρκεια της κλήσης. Αλλαγή προφίλ συνομιλίας για προσκλήσεις 1-χρήσης. @@ -2414,7 +2414,7 @@ Ναι Ναι εσύ - ΕΣΥ + Εσύ εσύ: %1$s Αποδέχθηκες τη σύνδεση αποδέχθηκες αυτό το μέλος 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 7088c54d9b..6b02074b7d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -20,7 +20,7 @@ Permites que tus contactos envien mensajes de voz. siempre La aplicación sólo puede recibir notificaciones cuando se está ejecutando. No se iniciará ningún servicio en segundo plano. - ICONO DE LA APLICACIÓN + Icono de la aplicación La optimización de la batería está activa, desactivando el servicio en segundo plano y las solicitudes periódicas de nuevos mensajes. Puedes volver a activarlos en Configuración. El servicio está siempre en funcionamiento en segundo plano. Las notificaciones se muestran en cuanto haya mensajes nuevos. Se puede desactivar en la configuración. En ese caso las notificaciones se seguirán mostrando mientras la aplicación esté en funcionamiento.]]> @@ -200,7 +200,7 @@ Eliminar servidor Introduce tu nombre: conectado - DISPOSITIVO + Dispositivo Contraseña base de datos Eliminar base de datos Eliminar todos los archivos @@ -243,7 +243,7 @@ Core versión: v%s Eliminar imagen Editar imagen - CHATS + Chats Cambiar Se realizan comprobaciones de mensajes nuevos periódicas de hasta un minuto de duración cada 10 minutos Limpiar @@ -274,7 +274,7 @@ Preferencias generales cancelado %s SimpleX está parado - LLAMADAS + Llamadas SimpleX está en ejecución está cambiando de servidor… habla con los desarrolladores @@ -295,7 +295,7 @@ Llamadas en la ventana de bloqueo ¡No se pueden invitar contactos! Consola de Chat - BASE DE DATOS DE SIMPLEX + Base de datos de SimpleX Base de datos eliminada Base de datos importada Comprueba la dirección del servidor e inténtalo de nuevo. @@ -393,15 +393,15 @@ Archivo no encontrado Guía de uso finalizado - AYUDA + Ayuda Exportar base de datos Error al exportar base de datos Error al iniciar Chat se ha unido mediante tu enlace de grupo Error al actualizar enlace de grupo - PARA CONSOLA + Para consola Error al cambiar rol - SERVIDORES + Servidores Nombre del grupo: Preferencias del grupo Los miembros pueden enviar mensajes directos. @@ -455,7 +455,7 @@ \n2. El descifrado ha fallado porque tu o tu contacto estáis usando una copia de seguridad antigua de la base de datos. \n3. La conexión ha sido comprometida. Contacto y texto - MIEMBRO + Miembro nunca No se usarán hosts .onion Vista previa de notificaciones @@ -524,7 +524,7 @@ videollamada (sin cifrar) sin cifrar Importar base de datos - MENSAJES Y ARCHIVOS + Mensajes y archivos ¿Importar base de datos\? Sin archivos recibidos o enviados Mensajes @@ -661,7 +661,7 @@ No se permiten mensajes temporales. Sólo tú puedes enviar mensajes de voz. Sólo tu contacto puede enviar mensajes de voz. - EJECUTAR SIMPLEX + Ejecutar SimpleX Reinicia la aplicación para poder usar la base de datos importada. Introduce la contraseña actual correcta. recepción no permitida @@ -703,8 +703,8 @@ Aislamiento de transporte tachado Abrir SimpleX - PROXY SOCKS - TEMAS + Proxy SOCKS + Temas Parar Esta acción es irreversible. Tu perfil, contactos, mensajes y archivos se perderán. Omitir invitación a miembros @@ -786,7 +786,7 @@ El perfil sólo se comparte con tus contactos. inicializando… Mensajes omitidos - CONFIGURACIÓN + Configuración ¿Parar SimpleX? %s segundo(s) Pulsa para unirte @@ -794,7 +794,7 @@ Timeout de la conexión TCP Tema Establece preferencias de grupo - SOPORTE SIMPLEX CHAT + Soporte SimpleX Chat Escribe la contraseña para exportar Actualizar Actualizar contraseña base de datos @@ -899,7 +899,7 @@ Llamadas Servidores ICE Privacidad - MIS DATOS + Mis datos Base de datos Puedes iniciar el chat en Configuración / Base de datos o reiniciando la aplicación. Has enviado una invitación de grupo @@ -990,7 +990,7 @@ Versión de base de datos incompatible Confirmar actualizaciones de la bases de datos la versión de la base de datos es más reciente que la aplicación, pero no hay migración hacia versión anterior para: %s - EXPERIMENTAL + Experimental IDs de la base de datos y opciones de aislamiento de transporte. El archivo se recibirá cuando el contacto termine de subirlo. La imagen se recibirá cuando el contacto termine de subirla. @@ -1142,7 +1142,7 @@ Mensaje enviado Dejar de compartir ¿Dejar de compartir la dirección\? - COLORES DE LA INTERFAZ + Colores de la interfaz Puedes crearla más tarde ¿Compartir la dirección con los contactos SimpleX? Compartir con contactos SimpleX @@ -1229,7 +1229,7 @@ sin texto Han ocurrido algunos errores no críticos durante la importación: ¿Salir de SimpleX? - APLICACIÓN + Aplicación Reiniciar Salir Las notificaciones dejarán de funcionar hasta que vuelvas a iniciar la aplicación @@ -1291,7 +1291,7 @@ Activar para todos Activar (conservar anulaciones) Desactivar para todos - ENVIAR CONFIRMACIONES DE ENTREGA A + Enviar confirmaciones de entrega a ¡Las confirmaciones de entrega están desactivadas! No activar ¡Error al activar confirmaciones de entrega! @@ -1776,7 +1776,7 @@ Usar siempre enrutamiento privado. Aviso de entrega de mensaje Nunca - ENRUTAMIENTO PRIVADO DE MENSAJES + Enrutamiento privado de mensajes La dirección del servidor es incompatible con la configuración de la red. Con IP desprotegida Clave incorrecta o conexión desconocida - probablemente esta conexión fue eliminada @@ -1787,7 +1787,7 @@ \n%1$s. Proteger dirección IP Sin Tor o VPN, tu dirección IP será visible para los servidores de archivos. - ARCHIVOS + Archivos La aplicación pedirá que confirmes las descargas desde servidores de archivos desconocidos (excepto si son .onion o cuando esté habilitado el proxy SOCKS). Colores del chat Tema del chat @@ -2053,7 +2053,7 @@ Por favor, comprueba que el enlace SimpleX es correcto. %1$d archivo(s) se está(n) descargando todavía. %1$d otro(s) error(es) de archivo. - BASE DE DATOS + Base de datos Error en reenvío de mensajes ¿Reenviar %1$s mensaje(s)? Reenviar mensajes… @@ -2508,7 +2508,7 @@ Compartir enlace antiguo El enlace será corto y el perfil del grupo se compartirá mediante el enlace. Actualizar enlace de grupo - SOLICITUDES DE CONTACTO EN GRUPOS + Solicitudes de contacto en grupos conexión solicitada desde el grupo %1$s Esta configuración se aplica al perfil actual Miembro eliminado, no puede aceptar solicitudes @@ -2597,7 +2597,7 @@ perfil del canal actualizado El canal será eliminado para todos los suscriptores. ¡No puede deshacerse! El canal será eliminado para tí. ¡No puede deshacerse! - CONEXIÓN FALLIDA + Conexión fallida Crear canal público Crear canal público Crear canal público (BETA) @@ -2631,12 +2631,12 @@ Hay servidores no conectados Abrir canal Abrir canal nuevo - PROPIETARIO + Propietario Propietarios Direcciones predefinidas Nombres predefinidos servidor - SERVIDOR + Servidor Dirección servidor Dirección del servidor Enlace servidor @@ -2647,7 +2647,7 @@ El servidor requiere autorización para conectar con el servidor, comprueba la contraseña. Alerta del servidor Compartir dirección del servidor - SUSCRIPTOR + Suscriptor Suscriptores Los suscriptores usan el enlace del servidor para conectarse a los canales.\nLa dirección del servidor se usó para establecer el servidor para el canal. El suscriptor será eliminado del canal. ¡No puede deshacerse! 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 3f7d4ff025..a3da005ac6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -781,7 +781,7 @@ خاموش ارسال رسید برای %d گروه فعال است ارسال رسید برای %d گروه غیرفعال است - حمایت از SIMPLEX CHAT + حمایت از SimpleX Chat پروکسی SOCKS استفاده از کامپیوتر آرشیو پایگاه داده جدید diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml index 24634192ec..4753cdb9cf 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -39,9 +39,9 @@ Salli lähetettyjen viestien peruuttamaton poistaminen. Salli katoavien viestien lähettäminen. Kaikki tiedot poistetaan, kun se syötetään. - SOVELLUKSEN KUVAKE + Sovelluksen kuvake Sovelluksen tietojen varmuuskopiointi - PUHELUT + Puhelut Pyydettiin videon vastaanottamista Tunnistaudu Tunnistautuminen ei ole käytettävissä @@ -54,7 +54,7 @@ Tietokannan salauksen tunnuslause päivitetään ja tallennetaan Keystoreen. Poista keskusteluprofiili käyttäjälle poistettu - LAITE + Laite %dh Yhteysvirhe Tiedostoa ei voi vastaanottaa @@ -153,7 +153,7 @@ Vaihda itsetuhotilaa Vaihda itsetuhoutuva pääsykoodi Sovelluksen salasana korvataan itsetuhoutuvalla pääsykoodilla. - KESKUSTELUJEN TIETOKANTA + Keskustelujen tietokanta Kehittäjän työkalut Ei pääsyä Keystoreen tietokannan salasanan tallentamiseksi Tietokannan tunnus: %d @@ -308,7 +308,7 @@ Hyväksy kuvat automaattisesti Virheellinen viestin tunniste Vaihda lukitustilaa - KESKUSTELUT + Keskustelut Kaikki ryhmän jäsenet pysyvät yhteydessä. Kontaktia ei voi kutsua! valmis @@ -453,7 +453,7 @@ Virhe käynnistettäessä keskustelua Virhe keskustelun lopettamisessa Virhe asetuksen muuttamisessa - KOKEELLINEN + Kokeellinen Piilota: Kuinka se toimii e2e-salattu videopuhelu @@ -526,7 +526,7 @@ Kuva tallennettu galleriaan Kuva vastaanotetaan, kun kontaktisi on ladannut sen. Tiedosto - APUA + Apua Virhe tietokannan salauksessa Alenna ja avaa chat Ei-aktiivinen ryhmä @@ -576,7 +576,7 @@ Immuuni roskapostille ja väärinkäytöksille Virhe vietäessä keskustelujen tietokantaa Piilota - KONSOLIIN + Konsoliin poistettu ryhmä ryhmäprofiili päivitetty Vanhentunut kutsu! @@ -655,7 +655,7 @@ PING-väli Profiili- ja palvelinyhteydet Aseta ryhmän asetukset - PALVELIMET + Palvelimet Tallenna ja ilmoita kontaktille Tallenna ja ilmoita kontakteille Ohitetut viestit @@ -715,7 +715,7 @@ Itsetuho Itsetuhoutuva pääsykoodi vaihdettu! Itsetuhoutuva pääsykoodi käytössä! - SUKAT VÄLITYSPALVELIN + SOCKS välityspalvelin Uusi tietokanta-arkisto Ei vastaanotettuja tai lähetettyjä tiedostoja Poistetaanko tunnuslause Keystoresta\? @@ -752,7 +752,7 @@ Tallenna ja ilmoita ryhmän jäsenille Lopeta Pysäytä keskustelut viedäksesi, tuodaksesi tai poistaaksesi keskustelujen tietokannan. Et voi vastaanottaa ja lähettää viestejä, kun keskustelut on pysäytetty. - SUORITA CHAT + Suorita chat Aseta tunnuslause vientiä varten Anna oikea nykyinen tunnuslause. Palauta @@ -836,7 +836,7 @@ Avaa SimpleX Chat hyväksyäksesi puhelun ei e2e-salausta TUE SIMPLEX CHATia - VIESTIT JA TIEDOSTOT + Viestit ja tiedostot Jaa osoite Vain paikalliset profiilitiedot Vastaanotettu viesti @@ -867,7 +867,7 @@ OK ei tietoja Kertakutsulinkki - ASETUKSET + Asetukset Uusi tunnuslause… Palauta tietokannan varmuuskopio Tietokannan tunnuslauseen muuttamista ei suoritettu loppuun. @@ -967,7 +967,7 @@ Päivitetty: %s Lähetetty klo Lähetetty: %s - JÄSEN + Jäsen Moderoitu klo: %s %s (nykyinen) Vaihda @@ -1061,7 +1061,7 @@ Hallitset keskustelujasi! Nykyinen profiilisi Profiilisi tallennetaan laitteeseesi ja jaetaan vain kontaktiesi kanssa. SimpleX -palvelimet eivät näe profiiliasi. - TEEMAT + Teemat Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä Tätä toimintoa ei voi kumota - kaikki vastaanotetut ja lähetetyt tiedostot ja media poistetaan. Matalan resoluution kuvat säilyvät. Tuntematon virhe @@ -1116,7 +1116,7 @@ SMP-palvelimesi XFTP-palvelimesi Käytä SimpleX Chat palvelimia\? - KÄYTTÖLIITTYMÄN VÄRIT + Käyttöliittymän värit Päivitä kuljetuksen eristystila\? Voit luoda sen myöhemmin Voit paljastaa piilotetun profiilisi kirjoittamalla koko salasanan Keskusteluprofiilit-sivun hakukenttään. @@ -1151,7 +1151,7 @@ Video lähetetty Odottaa videota Tämä merkkijono ei ole yhteyslinkki! - SINÄ + Sinä Päivitä ja avaa keskustelu Aikavyöhykkeen suojaamiseksi kuva-/äänitiedostot käyttävät UTC:tä. Videot ja tiedostot 1 Gt asti @@ -1228,7 +1228,7 @@ viikkoa Ilmoitukset lakkaavat toimimasta, kunnes käynnistät sovelluksen uudelleen Sulje - SOVELLUS + Sovellus Käynnistä uudelleen Sulje\? Pois @@ -1305,7 +1305,7 @@ Salli kuittaukset\? Kuittauksien lähettäminen on pois käytöstä %d kontakteilta Kuittauksien lähettäminen on käytössä %d kontakteille - LÄHETÄ TOIMITUSKUITTAUKSET VASTAANOTTAJALLE + Lähetä toimituskuittaukset vastaanottajalle turvakoodi on muuttunut hyväksyy salausta… salauksen uudelleenneuvottelu sallittu %s:lle @@ -1465,7 +1465,7 @@ Kamera Avaa asetukset Suojaa IP-osoite - TIEDOSTOT + Tiedostot Profiilikuvat tuntematon Poista jäsen diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml index d95f8ad500..571823140a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -453,7 +453,7 @@ Consomme davantage de batterie L\'app fonctionne toujours en arrière-plan - les notifications s\'affichent instantanément.]]> %1$d message(s) manqué(s) ID du message incorrect - PARAMÈTRES + Paramètres Cela peut arriver quand : \n1. Les messages ont expiré dans le client expéditeur après 2 jours ou sur le serveur après 30 jours. \n2. Le déchiffrement du message a échoué, car vous ou votre contact avez utilisé une ancienne sauvegarde de base de données. @@ -487,12 +487,12 @@ Appel en cours Appel terminé Votre vie privée - APPAREIL - DISCUSSIONS + Appareil + Discussions Outils du développeur - ICONE DE L\'APP + Icone de l\'app Votre base de données de chat - LANCER LE CHAT + Lancer le chat Arrêter le chat \? Redémarrez l\'application pour utiliser la base de données de chat importée. 1 jour @@ -522,10 +522,10 @@ Appels audio et vidéo chiffré de bout en bout Fonctionnalités expérimentales - SOCKS PROXY - THEMES - MESSAGES ET FICHIERS - APPELS + SOCKS proxy + Themes + Messages et fichiers + Appels Importer la base de données Nouvelle archive de base de données Archives de l\'ancienne base de données @@ -601,13 +601,13 @@ Protéger l\'écran de l\'app Acceptation automatique des images Sauvegarde des données de l\'app - VOUS - AIDE - SOUTENEZ SIMPLEX CHAT + Vous + Aide + Soutenez SimpleX Chat Mode Incognito Le chat est en cours d\'exécution Le chat est arrêté - BASE DE DONNÉES DU CHAT + Base de données du chat Phrase secrète de la base de données Exporter la base de données Arrêter @@ -694,7 +694,7 @@ Créer un lien Modifier le profil du groupe Supprimer - MEMBRE + Membre Message dynamique ! Envoyer un message dynamique Envoyez un message dynamique - il sera mis à jour pour le⸱s destinataire⸱s au fur et à mesure que vous le tapez @@ -708,7 +708,7 @@ Erreur lors de la suppression du lien du groupe Erreur lors de la création du lien du groupe Seuls les propriétaires du groupe peuvent modifier les préférences du groupe. - POUR TERMINAL + Pour terminal Changer le rôle du groupe \? Son rôle est désormais %s. Tous les membres du groupe en seront informés. Contact vérifié⸱e @@ -747,7 +747,7 @@ Messages directs Supprimer pour tous Vous êtes le seul à pouvoir supprimer des messages de manière irréversible (votre contact peut les marquer comme supprimé). (24 heures) - SERVEURS + Serveurs Réception via Système Autoriser l\'envoi de messages directs aux membres. @@ -996,7 +996,7 @@ Afficher les options pour les développeurs Le fichier sera reçu lorsque votre contact aura terminé de le mettre en ligne. IDs de base de données et option d\'isolement du transport. - EXPÉRIMENTALE + Expérimentale Cacher : Dévoiler le profil de chat Dévoiler le profil @@ -1102,7 +1102,7 @@ Vous ne perdrez pas vos contacts si vous supprimez votre adresse ultérieurement. Adresse SimpleX Vous pouvez accepter ou refuser les demandes de contacts. - COULEURS DE L\'INTERFACE + Couleurs de l\'interface Vos contacts resteront connectés. Partager l\'adresse avec vos contacts ? Partager avec vos contacts @@ -1232,7 +1232,7 @@ Arrêt \? Mise à l\'arrêt Redémarrer - APP + App Abandonner Erreur lors de l\'annulation du changement d\'adresse Abandonner le changement d\'adresse \? @@ -1251,7 +1251,7 @@ Les membres peuvent envoyer des fichiers et des médias. Les fichiers et les médias sont interdits. Correction non prise en charge par un membre du groupe - ENVOYER DES ACCUSÉS DE RÉCEPTION AUX + Envoyer des accusés de réception aux Le chiffrement fonctionne et le nouvel accord de chiffrement n\'est pas nécessaire. Cela peut provoquer des erreurs de connexion ! Encore quelques points Justificatifs de réception! @@ -1776,8 +1776,8 @@ Rabattement du routage des messages Afficher le statut du message Protection de l\'adresse IP - FICHIERS - ROUTAGE PRIVÉ DES MESSAGES + Fichiers + Routage privé des messages Erreur au niveau du serveur de destination : %1$s Erreur : %1$s Capacité dépassée - le destinataire n\'a pas pu recevoir les messages envoyés précédemment. @@ -2091,7 +2091,7 @@ Utiliser des identifiants aléatoires Nom d\'utilisateur Les messages seront supprimés - il n\'est pas possible de revenir en arrière ! - BASE DE DONNÉES DU CHAT + Base de données du chat Mode système Serveur De nouveaux identifiants SOCKS seront utilisées pour chaque serveur. @@ -2374,7 +2374,7 @@ Se connecter Se connecter connecté - CONNEXION ÉCHOUÉE + Connexion échouée contact supprimé contact désactivé le contact devrait accepter… @@ -2410,7 +2410,7 @@ rejeté Rejeter le membre? relais - RELAIS + Relais Adresse de relais Adresse de relais Échec de la connexion au relais diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml index 84e806dda0..55d3e14907 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -29,7 +29,7 @@ prihvati poziv Dodeliti dozvolu Slušalice - POMOĆ + Pomoć Grupa će biti obrisana za Vas – ovo ne može da se poništi! Akcenat Grupni linkovi @@ -120,15 +120,15 @@ Greška Napravi jednokratnu poveznicu Nalepiti - PODEŠAVANJE + Podešavanje Profilne slike Razumeo Odstranjeno odstranjeno Napraviti - PORUKE I DATOTEKE + Poruke i datoteke Poruka - SERVERI + Serveri Odstraniti profil razgovora administratori Nasumično @@ -240,7 +240,7 @@ Preuzimanje Napredna podešavanja Poziv u toku - POZIVI + Pozivi Blokiraj članove grupe Nepoznati serveri! Datoteka @@ -253,7 +253,7 @@ %d poruka blokirano povezivanje Povezan telefon - VI + Vi Zamućeno za bolju privatnost. %d meseca(i) Poziv završen @@ -295,7 +295,7 @@ %d minut(a) Proveri ažuriranje Stabilno - DATOTEKE + Datoteke %s otpremljeno Onemogućiti obavještenja %s nije verifikovan @@ -312,7 +312,7 @@ Skenirati QR kod Server Onemogućiti - BAZA PODATAKA CHATA + Baza podataka chata onemogućeno Greška pri uvoženju teme Datoteke i medijski sadržaji su zabranjeni. @@ -328,7 +328,7 @@ Ili skenirati QR kod Onemogućeno Aplikacija - RAZGOVORI + Razgovori Datoteke i medijski sadržaji su zabranjeni! Poruke koje nestaju su zabranjene u ovom razgovoru. Chat je zaustavljen @@ -370,7 +370,7 @@ QR kod Chat je pokrenut Uvesti bazu podataka - BAZA PODATAKA CHATA + Baza podataka chata Chat je zaustavljen %s, %s i %d ostali članovi povezani Uvoz neuspešan @@ -428,7 +428,7 @@ SimpleX adresa SimpleX Logo Prikazati: - UREĐAJ + Uređaj Nova poruka Sekundarni Kontakti @@ -474,7 +474,7 @@ Omiljen Nikada Veza - TEME + Teme Audio/video pozivi ne šifrovanje ok @@ -491,7 +491,7 @@ SimpleX Adresa Sačuvati simplexmq: v%s (%2s) - EKSPERIMENTALNO + Eksperimentalno nikada Očistiti Zahvaljujući korisnicima – doprinesi pomoću Weblate! @@ -694,7 +694,7 @@ Izabrati profil razgovora Skenirati QR kod servera Napredna mrežna podešavanja - SOCKS PROXY + SOCKS proxy Anonimni režim broj PING Obnoviti statistiku? @@ -725,7 +725,7 @@ Arhiviraj bazu podataka Periodično Ukloniti - ČLAN + Član Pristupanje grupi SMP server Pozvati u razgovor @@ -986,7 +986,7 @@ Novi server Prikazati procente Napustiti bez čuvanja - POKRENUTI RAZGOVOR + Pokrenuti razgovor Odblokirati člana za sve? Priprema za preuzimanje Proxied(posredovan) @@ -1260,9 +1260,9 @@ Pin kod postavljen! Svi podaci u aplikaciji su odstranjeni. Pin kod aplikacije je zamenjen pin kodom za samouništenje. - POTPORI SIMPLEX CHAT + Potpori SimpleX Chat Oblik poruke - IKONA APLIKACIJE + Ikona aplikacije Pristupna fraza baze podataka Odrediti pristupnu frazu Baza podataka će biti šifrovana. 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 2df64ae590..e1730a54aa 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -71,7 +71,7 @@ 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. A hívás véget ért - HÍVÁSOK + Hívások és további %d esemény Cím A csatlakozás folyamatban van a csoporthoz! @@ -149,13 +149,13 @@ hívás folyamatban Képek automatikus elfogadása A hívások kezdeményezése engedélyezve van a partnerei számára. - ALKALMAZÁSIKON + Alkalmazásikon Kiszolgáló hozzáadása QR-kód beolvasásával. Az eltűnő üzenetek küldése engedélyezve van. Az eltűnő üzenetek küldése csak abban az esetben van engedélyezve, ha a partnere is engedélyezi. Hang kikapcsolva A közvetlen üzenetek küldése a tagok között engedélyezve van. - ALKALMAZÁS + Alkalmazás Hívás folyamatban Mindkét fél hozzáadhat az üzenetekhez reakciókat. Mindkét fél tud hívásokat kezdeményezni. @@ -300,7 +300,7 @@ Hívás kapcsolása Törli a fájlokat és a médiatartalmakat? kész - CSEVEGÉSI ADATBÁZIS + Csevegési adatbázis Önmegsemmisítő jelkód módosítása Várólista létrehozása színezett @@ -313,7 +313,7 @@ kapcsolódás Egyéni időköz Kapcsolódás inkognitóban - CSEVEGÉSEK + Csevegések Új profil létrehozása a számítógépes alkalmazásban. 💻 kapcsolódás (bejelentve) kapcsolódás… @@ -393,7 +393,7 @@ Ne jelenjen meg újra SimpleX-zár kikapcsolása végpontok között titkosított - ESZKÖZ + Eszköz végpontok között titkosított videóhívás közvetlen Számítógép @@ -522,7 +522,7 @@ Akkor is, ha le van tiltva a beszélgetésben. Gyorsabb csatlakozás és megbízhatóbb üzenetkézbesítés. Zárolás engedélyezése - SÚGÓ + Súgó Teljesen decentralizált – csak a tagok számára látható. Fájl: %s Hívás befejezése @@ -530,7 +530,7 @@ Fájl mentve Kapcsolat javítása? Fájlok és médiatartalmak - KONZOLHOZ + Konzolhoz Nem sikerült a titkosítást újraegyeztetni. Hiba történt a felhasználói profil törlésekor Csoporttag általi javítás nem támogatott @@ -579,7 +579,7 @@ A csoport teljes neve: súgó Önmegsemmisítő jelkód engedélyezése - KÍSÉRLETI + Kísérleti Hiba történt a cím módosításának megszakításakor Hiba történt a fájl fogadásakor titkosítása rendben van @@ -722,7 +722,7 @@ A reakciók hozzáadása az üzenetekhez le van tiltva. Nem nincs szöveg - TAG + Tag Hogyan befolyásolja az akkumulátort Új tag szerepköre Kikapcsolva @@ -842,7 +842,7 @@ Név és üzenet Az értesítések csak az alkalmazás bezárásáig érkeznek! Információ - ÜZENETEK ÉS FÁJLOK + Üzenetek és fájlok tag Privát kapcsolat létrehozása %s moderálta ezt az üzenetet @@ -918,7 +918,7 @@ Üdvözlőüzenet %s, %s és további %d tag kapcsolódott Csak a partnere kezdeményezhet hívásokat. - TÉMÁK + Témák Túl sok videó! Üdvözöljük! Önmegsemmisítő jelkód @@ -963,7 +963,7 @@ Ön elfogadta a kapcsolatot Elutasítás Partner nevének és az üzenet tartalmának megjelenítése - BEÁLLÍTÁSOK + Beállítások Profiljelszó mentése Megállítja a fájlküldést? Leválasztja a számítógépet? @@ -1007,7 +1007,7 @@ QR-kód beolvasása Kiszolgáló tesztelése Küldjön nekünk e-mailt - KISZOLGÁLÓK + Kiszolgálók Kiszolgálók tesztelése Jelkód bevitele Rendszer @@ -1018,12 +1018,12 @@ A reakciók hozzáadása az üzenethez le van tiltva. Véletlenszerű jelmondat használata egyenrangú - CSEVEGÉSI SZOLGÁLTATÁS INDÍTÁSA + Csevegési szolgáltatás indítása Kapott hivatkozás beillesztése Menti a kiszolgálókat? A SimpleX Chat biztonsága a Trail of Bits által lett auditálva. frissítette a csoportprofilt - SIMPLEX CHAT TÁMOGATÁSA + SimpleX Chat támogatása SimpleX Chat szolgáltatás Ön megfigyelő %s ellenőrizve @@ -1072,10 +1072,10 @@ Egyszer használható SimpleX meghívó Hívások nem sikerült elküldeni - KEZELŐFELÜLET SZÍNEI + Kezelőfelület színei Adja meg a korábbi jelszót az adatbázis biztonsági mentésének visszaállítása után. Ez a művelet nem vonható vissza. Másodlagos szín - SOCKS PROXY + SOCKS proxy Mentés Újraindítás SMP-kiszolgálók @@ -1106,7 +1106,7 @@ igen Hangüzenet Társítás számítógéppel - PROFIL + Profil %d-s port Kapcsolódás egy hivatkozáson keresztül Cím megosztása @@ -1457,7 +1457,7 @@ 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 + A kézbesítési jelentéseket a következő címre kell küldeni A következő üzenet azonosítója érvénytelen (kisebb vagy egyenlő az előzővel).\nEz valamilyen hiba vagy sérült kapcsolat esetén fordulhat elő. Az eszköz neve meg lesz osztva a társított hordozható eszközön használt alkalmazással. A címzettek a beírás közben látják a szövegváltozásokat. @@ -1747,11 +1747,11 @@ 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. Az IP-cím védelmének érdekében a privát útválasztás az SMP-kiszolgálókat használja az üzenetek kézbesítéséhez. Üzenet-útválasztási tartalék - PRIVÁT ÜZENET-ÚTVÁLASZTÁS + Privát üzenet-útválasztás Privát útválasztás használata az ismeretlen kiszolgálókkal, ha az IP-cím nem védett. NE küldjön üzeneteket közvetlenül, még akkor sem, ha a saját kiszolgálója vagy a célkiszolgáló nem támogatja a privát útválasztást. Tor vagy VPN nélkül az IP-címe láthatóvá válik a fájlkiszolgálók számára. - FÁJLOK + Fájlok IP-cím védelme Az alkalmazás kérni fogja az ismeretlen fájlkiszolgálókról történő letöltések megerősítését (kivéve, ha az .onion vagy a SOCKS proxy engedélyezve van). Ismeretlen kiszolgálók! @@ -2019,7 +2019,7 @@ Az üzenetek törölve lesznek – ez a művelet nem vonható vissza! Eltávolítja az archívumot? A feltöltött adatbázis-archívum véglegesen el lesz távolítva a kiszolgálókról. - CSEVEGÉSI ADATBÁZIS + Csevegési adatbázis Profil megosztása Rendszerbeállítások használata Csevegési profil kiválasztása @@ -2476,7 +2476,7 @@ A hivatkozás rövid lesz és a csoportprofil meg lesz osztva a hivatkozáson keresztül. Régi cím megosztása Régi (hosszú) hivatkozás megosztása - PARTNERI KAPCSOLATKÉRÉSEK A CSOPORTOKBÓL + Partneri kapcsolatkérések a csoportokból A tag törölve lett – nem lehet elfogadni a kérést a(z) %1$s nevű csoportból partneri kapcsolatot kért Ez a beállítás a jelenlegi profiljára vonatkozik @@ -2519,7 +2519,7 @@ Hangüzenetek keresése Videók Hangüzenetek - NEM SIKERÜLT LÉTREHOZNI A KAPCSOLATOT + Nem sikerült létrehozni a kapcsolatot sikertelen Ha csatornákat hozott létre vagy csak csatlakozott hozzájuk, akkor azok véglegesen le fognak állni. aktív @@ -2545,7 +2545,7 @@ meghíva Csatorna megnyitása Új csatorna megnyitása - TULAJDONOS + Tulajdonos Tulajdonosok Csatorna elhagyása Elhagyja a csatornát? @@ -2555,7 +2555,7 @@ Ön Saját csatorna Saját csatorna - FELIRATKOZÓ + Feliratkozó Feliratkozók %1$d feliratkozó %1$d feliratkozó @@ -2607,7 +2607,7 @@ %1$d/%2$d átjátszó aktív %1$d/%2$d átjátszó kapcsolódva, %3$d hiba %1$d/%2$d átjátszó kapcsolódva - ÁTJÁTSZÓ + Átjátszó Átjátszóhivatkozás Átjátszó címe a következőn keresztül: %1$s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg new file mode 100644 index 0000000000..7e77b31444 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg new file mode 100644 index 0000000000..ea9417f047 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot_light.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg new file mode 100644 index 0000000000..5e8c346fdd --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_light.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + 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 60ed7db384..70c31f5399 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -44,7 +44,7 @@ Tambahkan ke perangkat lain Boleh Selalu - APLIKASI + Aplikasi Tampilan Tentang SimpleX Chat Terima @@ -293,7 +293,7 @@ Terlalu banyak gambar! cari panggilan - PENGATURAN + Pengaturan Untuk semua orang Hentikan berkas Cabut berkas @@ -643,9 +643,9 @@ Kode sandi hapus otomatis Aktifkan hapus otomatis Pasang kode sandi - BANTUAN - DUKUNG SIMPLEX CHAT - PANGGILAN + Bantuan + Dukung SimpleX Chat + Panggilan Mulai ulang aplikasi untuk buat profil obrolan baru. Hapus pesan keluar @@ -729,9 +729,9 @@ Sedang Buram media Kuat - ANDA + Anda Lunak - BASIS DATA OBROLAN + Basis data obrolan Setel frasa sandi untuk diekspor Buka folder basis data menghapus anda @@ -968,7 +968,7 @@ Build aplikasi: %s Versi inti: v%s Ketika IP disembunyikan - WARNA ANTARMUKA + Warna antarmuka Fallback perutean pesan Mode routing pesan Routing pribadi @@ -1003,7 +1003,7 @@ Server ICE Anda Server ICE WebRTC Jika Anda memasukkan kode sandi hapus otomatis saat membuka aplikasi: - IKON APLIKASI + Ikon aplikasi Aplikasi akan meminta untuk mengonfirmasi unduhan dari server berkas yang tidak dikenal (kecuali .onion atau saat proxy SOCKS diaktifkan). Reaksi pesan dilarang dalam obrolan ini. Pindah ke perangkat lain @@ -1020,8 +1020,8 @@ Panggilan tak terjawab Panggilan ditolak ID pesan berikutnya salah (kurang atau sama dengan yang sebelumnya).\nHal ini dapat terjadi karena beberapa bug atau ketika koneksi terganggu. - TEMA - KIRIM TANDA TERIMA KIRIMAN KE + Tema + Kirim tanda terima kiriman ke Hal ini dapat terjadi ketika Anda atau koneksi Anda menggunakan cadangan basis data lama. Android Keystore digunakan untuk menyimpan frasa sandi dengan aman - memungkinkan layanan notifikasi berfungsi. Hapus @@ -1134,9 +1134,9 @@ Dikenal Menunggu gambar Menunggu video - PERANGKAT - OBROLAN - BERKAS + Perangkat + Obrolan + Berkas Reset semua petunjuk Gagal menambah anggota Gagal gabung ke grup @@ -1281,7 +1281,7 @@ Buka blokir anggota untuk semua? Buka untuk semua Diblokir oleh admin - ANGGOTA + Anggota Hapus anggota Status pesan: %s Status berkas: %s @@ -1326,7 +1326,7 @@ Perbaikan tidak didukung oleh kontak Obrolan Terima kondisi - SERVER + Server Buat grup Nama lengkap grup: Simpan profil grup @@ -1466,7 +1466,7 @@ Terbaik untuk baterai. Anda akan menerima notifikasi saat aplikasi sedang berjalan (TANPA layanan latar belakang).]]> Baik untuk baterai. Aplikasi memeriksa pesan setiap 10 menit. Anda mungkin melewatkan panggilan atau pesan penting.]]> Tema obrolan - BASIS DATA OBROLAN + Basis data obrolan Basis data dienkripsi menggunakan frasa sandi acak. Harap ubah frasa sandi sebelum mengekspor. Basis data obrolan diekspor Frasa sandi saat ini… @@ -1685,7 +1685,7 @@ Berkas dan media Enkripsi basis data? Versi basis data tidak kompatibel - UNTUK KONSOL + Untuk konsol Grup sudah ada! Masukkan frasa sandi Aktifkan hapus pesan otomatis? @@ -1703,7 +1703,7 @@ Gagal verifikasi frasa sandi: Gagal hubungkan ulang server Gagal hubungkan ulang server - EKSPERIMENTAL + Eksperimental Ekspor basis data Impor basis data Gagal hentikan obrolan @@ -1830,7 +1830,7 @@ %s diunduh Pesan diterima Catatan diperbarui pada - PESAN DAN BERKAS + Pesan dan berkas Tema profil Gambar profil Harap masukkan frasa sandi saat ini yang benar. @@ -1908,7 +1908,7 @@ Simpan Jadikan profil pribadi! Ponsel jarak jauh - JALANKAN OBROLAN + Jalankan obrolan Harap simpan frasa sandi dengan aman, Anda TIDAK akan dapat mengakses obrolan jika hilang. dihapus %1$s Dikirim pada: %s @@ -1979,7 +1979,7 @@ Server baru Info antrian pesan Bentuk pesan - ROUTING PESAN PRIBADI + Routing pesan pribadi info antrean server: %1$s\n\npesan terakhir diterima: %2$s Hanya data profil lokal Buka perubahan @@ -2024,7 +2024,7 @@ Profil, kontak, dan pesan terkirim Anda disimpan di perangkat Anda. Platform perpesanan dan aplikasi yang melindungi privasi dan keamanan Anda. Untuk melindungi privasi Anda, SimpleX gunakan ID terpisah untuk setiap kontak. - PROXY SOCKS + Proxy SOCKS Tingkatkan dan buka obrolan Ketuk untuk gabung ke samaran Anda memblokir %s @@ -2423,7 +2423,7 @@ Chat dengan anggota sebelum mereka bergabung. Hubungkan Terhubung lebih cepat! 🚀 - PERMINTAAN KONTAK DARI GRUP + Permintaan kontak dari grup kontak harus menerima… Buat alamat Anda Opsi tidak berlaku 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 adce58e804..9db5b3a58f 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -269,7 +269,7 @@ L\'archivio chiavi di Android è usato per memorizzare in modo sicuro la password; permette il funzionamento del servizio di notifica. Permetti ai tuoi contatti di inviare messaggi vocali. Database della chat eliminato - ICONA APP + Icona app Ideale per la batteria. Riceverai notifiche solo quando l\'app è in esecuzione (NESSUN servizio in secondo piano).]]> Consuma più batteria! L\'app funziona sempre in secondo piano: le notifiche vengono mostrate istantaneamente.]]> chiamata… @@ -378,24 +378,24 @@ Attiva le chiamate dalla schermata di blocco tramite le impostazioni. Fotocamera frontale/posteriore Riaggancia - CHIAMATE - DATABASE DELLA CHAT + Chiamate + Database della chat Database della chat importato Chat in esecuzione - CHAT + Chat Il database è crittografato con una password casuale. Cambiala prima di esportare. Password del database Eliminare il profilo di chat\? Elimina database Strumenti di sviluppo - DISPOSITIVO + Dispositivo Errore nell\'eliminazione del database della chat Errore nell\'esportazione del database della chat Errore nell\'avvio della chat Errore nell\'interruzione della chat Funzionalità sperimentali Esporta database - AIUTO + Aiuto Chat fermata Errore del database La password del database è diversa da quella salvata nell\'archivio chiavi. @@ -437,7 +437,7 @@ Errore nella creazione del link del gruppo Errore nell\'eliminazione del link del gruppo Espandi la selezione dei ruoli - PER CONSOLE + Per console Link del gruppo Il gruppo verrà eliminato per tutti i membri. Non è reversibile! Il gruppo verrà eliminato per te. Non è reversibile! @@ -697,23 +697,23 @@ Importare il database della chat\? Importa database Modalità incognito - MESSAGGI E FILE + Messaggi e file Nuovo archivio database Vecchio archivio del database Riavvia l\'app per creare un profilo di chat nuovo. Riavvia l\'app per usare il database della chat importato. - AVVIA CHAT + Avvia chat Invia le anteprime dei link Imposta la password per esportare - IMPOSTAZIONI - PROXY SOCKS + Impostazioni + Proxy SOCKS Ferma Fermare la chat\? Ferma la chat per esportare, importare o eliminare il database della chat. Non potrai ricevere e inviare messaggi mentre la chat è ferma. - SUPPORTA SIMPLEX CHAT - TEMI + Supporta SimpleX Chat + Temi Questa azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. - TU + Tu Il tuo database della chat Il tuo attuale database di chat verrà ELIMINATO e SOSTITUITO con quello importato. \nQuesta azione non può essere annullata: il tuo profilo, i contatti, i messaggi e i file andranno persi in modo irreversibile. @@ -774,7 +774,7 @@ Invita al gruppo Esci dal gruppo Nome locale - MEMBRO + Membro Il membro verrà rimosso dal gruppo, non è reversibile! Nuovo ruolo del membro Nessun contatto selezionato @@ -806,7 +806,7 @@ Salva il profilo del gruppo sec Invio tramite - SERVER + Server Cambia indirizzo di ricezione Sistema Scadenza connessione TCP @@ -994,7 +994,7 @@ Conferma aggiornamenti database migrazione diversa nell\'app/nel database: %s / %s Conferma di migrazione non valida - SPERIMENTALE + Sperimentale L\'immagine verrà ricevuta quando il tuo contatto completerà l\'invio. la versione del database è più recente di quella dell\'app, ma nessuna migrazione downgrade per: %s Il file verrà ricevuto quando il tuo contatto completerà l\'invio. @@ -1102,7 +1102,7 @@ Per connettervi, il tuo contatto può scansionare il codice QR o usare il link nell\'app. Quando le persone chiedono di connettersi, puoi accettare o rifiutare. Indirizzo SimpleX - COLORI DELL\'INTERFACCIA + Colori dell\'interfaccia I tuoi contatti resteranno connessi. Aggiungi l\'indirizzo al tuo profilo, in modo che i tuoi contatti di SimpleX possano condividerlo con altre persone. L\'aggiornamento del profilo verrà inviato ai tuoi contatti di SimpleX. Crea un indirizzo per consentire alle persone di connettersi con te. @@ -1230,7 +1230,7 @@ nessun testo Si sono verificati alcuni errori non fatali durante l\'importazione: Riavvia - APP + App Le notifiche smetteranno di funzionare fino a quando non riavvierai l\'app Spegni Spegnere\? @@ -1261,7 +1261,7 @@ L\'invio delle ricevute di consegna sarà attivo per tutti i contatti. Errore nell\'attivazione delle ricevute di consegna! Puoi attivarle più tardi nelle impostazioni - INVIA RICEVUTE DI CONSEGNA A + Invia ricevute di consegna a concordando la crittografia per %s… Ricevute di consegna! Contatti @@ -1779,7 +1779,7 @@ NON inviare messaggi direttamente, anche se il tuo server o quello di destinazione non supporta l\'instradamento privato. NON usare l\'instradamento privato. No - INSTRADAMENTO PRIVATO DEI MESSAGGI + Instradamento privato dei messaggi Invia messaggi direttamente quando l\'indirizzo IP è protetto e il tuo server o quello di destinazione non supporta l\'instradamento privato. Per proteggere il tuo indirizzo IP, l\'instradamento privato usa i tuoi server SMP per consegnare i messaggi. Non protetto @@ -1787,7 +1787,7 @@ Proteggi l\'indirizzo IP L\'app chiederà di confermare i download da server di file sconosciuti (eccetto .onion o quando il proxy SOCKS è attivo). Senza Tor o VPN, il tuo indirizzo IP sarà visibile ai server di file. - FILE + File Senza Tor o VPN, il tuo indirizzo IP sarà visibile a questi relay XFTP: \n%1$s. Tema della chat @@ -2056,7 +2056,7 @@ Errore nel cambio di profilo Seleziona il profilo di chat Condividi il profilo - DATABASE DELLA CHAT + Database della chat Modalità di sistema Rimuovere l\'archivio? I messaggi verranno eliminati. Non è reversibile! @@ -2512,7 +2512,7 @@ Condividi il link vecchio Il link sarà breve e il profilo del gruppo verrà condiviso attraverso il link. Aggiorna il link del gruppo - RICHIESTE DI CONTATTO DAI GRUPPI + Richieste di contatto dai gruppi Il membro è eliminato - impossibile accettare la richiesta connessione richiesta dal gruppo %1$s Questa impostazione è per il tuo profilo attuale @@ -2555,7 +2555,7 @@ Video Messaggi vocali Filtro - CONNESSIONE FALLITA + Connessione fallita fallito Se sei dentro canali o ne hai creati, essi smetteranno di funzionare definitivamente. %1$d/%2$d relay attivo/i @@ -2620,12 +2620,12 @@ Non tutti i relay sono connessi Apri canale Apri un canale nuovo - PROPRIETARIO + Proprietario Proprietari Indirizzo relay preimpostato Nome relay preimpostato relay - RELAY + Relay Indirizzo del relay Indirizzo del relay Connessione del relay fallita @@ -2636,7 +2636,7 @@ Il server richiede l\'autorizzazione per connettersi al relay, controlla la password. Avviso del server Condividi l\'indirizzo del relay - ISCRITTO + Iscritto Iscritti Gli iscritti usano il link del relay per connettersi al canale.\nL\'indirizzo del relay è stato usato per impostare questo relay per il canale. L\'iscritto verrà rimosso dal canale, non è reversibile! 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 faf69dfd03..61613e63b0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -973,7 +973,7 @@ רמקול כבוי רמקול פעיל הגדרות - תמיכה ב־SIMPLEX CHAT + תמיכה ב־SimpleX Chat לעצור צ׳אט\? עיצרו את הצ׳אט כדי לייצא, לייבא או למחוק את מסד הנתונים. לא תוכלו לקבל ולשלוח הודעות בזמן שהצ׳אט מופסק. עצור 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 5c17946c24..9784befef6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -880,7 +880,7 @@ メールを送る メディア共有… SimpleXリンク - SIMPLEX CHATを支援 + SimpleX Chatを支援 テストサーバ 受信アドレスは別のサーバーに変更されます。アドレス変更は送信者がオンラインになった後に完了します。 あなたのプライバシーを守るために、他のアプリと違って、ユーザーIDの変わりに SimpleX メッセージ束毎にIDを配布し、各連絡先が別々と扱います。 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml index 83f937db32..a60dea56b7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -822,7 +822,7 @@ 설정 링크 미리보기 보내기 SOCKS 프록시 - SIMPLEX CHAT 도와주기 + SimpleX Chat 도와주기 실험적 기능 표시 : diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml index 92985b15be..09c428e48b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ku/strings.xml @@ -282,13 +282,13 @@ Adresa serverê Adres Adresa serverê li eyarên torê nayê. - SERVER + 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 + Eyar Parve bike Lînka 1-carê parve bike Adresê parve bike @@ -337,7 +337,7 @@ xet/xêz/xîşk Biqewet Abonekirî - PIŞT BIDE SIMPLEX CHATÊ + Pişt bide SimpleX Chatê Biguhere Sîstem Sîstem @@ -513,7 +513,7 @@ Kompîter ne aktîv e Girêdana bi kompîterê re qut bû Detay - CIHAZ + Cihaz %d dosya bi mezibnbûniya timam ya %s %d hewadîsên komê %d seet @@ -588,7 +588,7 @@ Tu dihêlî te ev endam qebûl kir tu: %1$s - TU + Tu tu Erê erê @@ -633,11 +633,11 @@ Lînkê veke Lînka timam veke Lînka paqij veke - ARÎKARÎ - APLÎKASYON - DOSYA + Arîkarî + Aplîkasyon + Dosya Ji nû ve veke - PROKSIYA SOCKSÊ + Proksiya SOCKSê Sûretên profîlan Girêdana torê Ji kompîterê bişuxulîne @@ -687,7 +687,7 @@ Ji admîn blokkirî blokkirî ne aktîv - ENDAM + Endam Rol Kom Te standin bi riya @@ -785,10 +785,10 @@ Profîla siḧbetê Tu siḧbeta xwe qontrol dikî! Siḧbetê bişuxulîne - SIḦBET + Siḧbet Rengên siḧbetê Siḧbet sekinandî ye - DATABASA SIḦBETÊ + Databasa siḧbetê Ber siḧbet were sekinandin? Xeletî di sekinandina siḧbetê de Ber profîla siḧbetê were jêbirin? diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml index bccd49eed9..950569c85b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -17,10 +17,10 @@ Skambutis jau baigtas! Atsiliepti Skambutis baigtas - SKAMBUČIAI + Skambučiai Leisti jūsų kontaktams negrįžtamai ištrinti išsiųstas žinutes. (24 valandas) Atgal - PROGRAMĖLĖS PIKTOGRAMA + Programėlės piktograma visada Leisti jūsų kontaktams siųsti balso žinutes. Leisti negrįžtamą žinučių ištrynimą tik tuo atveju, jei jūsų kontaktas jums tai leidžia. (24 valandas) @@ -78,8 +78,8 @@ Apversti kamerą Atmestas skambutis Privatumas ir saugumas - ĮRENGINYS - PAGALBA + Įrenginys + Pagalba Šifruoti Šalinti Ištrinti grupę @@ -241,7 +241,7 @@ Išjungti garsiakalbį Įjungti garsiakalbį Praleistos žinutės - NUSTATYMAI + Nustatymai Sistemos nežinomas žinutės formatas SimpleX kontakto adresas @@ -292,7 +292,7 @@ Išjungti vaizdą Įjungti vaizdą Jūsų privatumas - JŪS + Jūs Neteisinga slaptafrazė! SimpleX jūs @@ -348,10 +348,10 @@ Įrašyti ir pranešti grupės nariams gautas patvirtinimas… Praleistas skambutis - POKALBIAI - APIPAVIDALINIMAI + Pokalbiai + Apipavidalinimai Inkognito veiksena - ŽINUTĖS IR FAILAI + Žinutės ir failai Norėdami naudoti importuotą pokalbio duomenų bazę, paleiskite programėlę iš naujo. Pakviesti narius Išnykstančios žinutės šiame pokalbyje yra uždraustos. @@ -420,7 +420,7 @@ Pokalbio profilis Profilis yra bendrinamas tik su jūsų kontaktais. „GitHub“ saugykloje.]]> - SOCKS ĮGALIOTASIS SERVERIS + SOCKS įgaliotasis serveris Įrašyti slaptafrazę ir atverti pokalbį Atkurti atsarginę duomenų bazės kopiją Atkurti atsarginę duomenų bazės kopiją\? @@ -459,7 +459,7 @@ Kontakto nuostatos Prisijungti Keisti - SERVERIAI + Serveriai Išvalyti Nebeslėpti profilio Per daug vaizdo įrašų! @@ -545,7 +545,7 @@ Išjungti garsą Visi programėlės duomenys bus ištrinti. Sukuriamas tuščias pokalbių profilis nurodytu pavadinimu ir programėlė atveriama kaip įprasta. - PROGRAMĖLĖ + Programėlė Saugiam slaptafrazės saugojimui yra naudojama „Android Keystore“ – tai įgalina pranešimų tarnybą veikti. Papildoma antrinė spalva Papildomas akcentavimas @@ -636,7 +636,7 @@ Sukurti grupę: sukurti naują grupę.]]> Pridėti kontaktą Tinkinti apipavidalinimą - POKALBIO DUOMENŲ BAZĖ + Pokalbio duomenų bazė Naudotojo sąsaja kinų ir ispanų kalbomis Pranešimai apie pristatymą! Išjungti SimpleX užraktą @@ -855,7 +855,7 @@ Prisijungti inkognito režimu Reikalinga slaptafrazė Uždrausti siųsti balso žinutes. - EKSPERIMENTINIS + Eksperimentinis Greitai ir nelaukiant kol siuntėjas prisijungs! Failai ir medija Failai ir medija yra draudžiami šioje grupėje. @@ -1000,7 +1000,7 @@ ištrintas kontaktas pakviestas Ištrinta - KONSOLEI + Konsolei Blokuoti Protokolui skirtas laikas numatyta (%s) @@ -1106,7 +1106,7 @@ Prisijungimas, kurį priėmėte, bus atšauktas! Bakstelėkite, kad įklijuoti nuorodą Testuoti serverį - TEMOS SPALVOS + Temos spalvos Rodyti lėtus API iškvietimus Nustoti bendrinti adresą? Žinučių siuntimo ir programų platforma, apsauganti jūsų privatumą ir saugumą. @@ -1217,7 +1217,7 @@ Nustatyti duomenų slaptafrazę Nustatyti slaptafrazę Rodyti paskutines žinutes - PALAIKYKITE SIMPLEX CHAT + Palaikykite SimpleX Chat Jų galima nepaisyti kontaktų ir grupių nustatymuose. Šis veiksmas negali būti atšauktas - žinutės išsiųstos ir gautos anksčiau nei pasirinkta bus ištrintos. Tai gali užtrukti kelias minutes. %s, %s ir %d kiti nariai prisijungė @@ -1516,7 +1516,7 @@ paslaptis Pranešimai nustos veikti iki tol kol paleisite programėlę iš naujo Galite naudoti markdown, kad formatuoti žinutes: - PALEISTI POKALBIUS + Paleisti pokalbius Naudoti iš darbastalio Sveikinimo žinutė yra per ilga Žinučių reakcijos @@ -1618,7 +1618,7 @@ Galite paleisti pokalbius per programėlės nustatymus/ duomenų bazę arba paleisdami programėlę iš naujo. pašalino jus Moderuota - NARYS + Narys %s %s nėra teksto Meniu ir įspėjimai @@ -1651,7 +1651,7 @@ Naudoti atsiktinę slaptafrazę lygiaverčiai mazgai Pašalinti slaptafrazę iš nustatymų? - SIŲSTI PRISTATYMO KVITUS PAS + Siųsti pristatymo kvitus pas Pristatymo kvitai yra išjungti %d grupėms Turite naudoti pačią naujausią pokalbių duomenų bazės versiją TIK viename įrenginyje, kitaip galite nebegauti žinučių iš kai kurių kontaktų. Nauja slaptafrazė… diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml index a6385a5ce0..1275c31573 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nb-rNO/strings.xml @@ -124,7 +124,7 @@ En annen grunn Svar anrop Hvem som helst kan være vert for servere. - APP + App Appen kjører alltid i bakgrunnen App build: %s Appen kan bare motta varsler når den er åpen, ingen bakgrunnstjeneste vil bli startet. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml index cc81e5365b..53ff325f43 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -5,7 +5,7 @@ Oproepen op vergrendelscherm: oproep bezig Gesprek bezig - OPROEPEN + Oproepen Annuleren Bestandsvoorbeeld annuleren Annuleer afbeeldingsvoorbeeld @@ -23,7 +23,7 @@ Sta toe om spraak berichten te verzenden. Chat is actief Wissen - CHAT DATABASE + Chat database Chat console Chat database geïmporteerd Chat database verwijderd @@ -85,7 +85,7 @@ App build: %s App kan alleen meldingen ontvangen wanneer deze actief is, er wordt geen achtergrondservice gestart Uiterlijk - APP ICON + App icon App versie App versie: v%s voor elk chatprofiel dat je in de app hebt .]]> @@ -119,7 +119,7 @@ Chat is gestopt Chat voorkeuren Chatprofiel - CHATS + Chats Praat met de ontwikkelaars Controleer het server adres en probeer het opnieuw. Bestand @@ -231,7 +231,7 @@ Verwijderen voor iedereen Link verwijderen direct - APPARAAT + Apparaat Verwijder alle bestanden Berichten verwijderen na Directe berichten @@ -309,7 +309,7 @@ e2e versleuteld video gesprek Schakel oproepen vanaf het vergrendelscherm in via Instellingen. Ophangen - HELP + Help Experimentele functies Fout bij het starten van de chat Database exporteren @@ -358,7 +358,7 @@ Fout bij het accepteren van een contactverzoek Groep uitnodiging verlopen Bestand - VOOR CONSOLE + Voor console Groep profiel wordt opgeslagen op de apparaten van de leden, niet op de servers. Verborgen De groep wordt voor u verwijderd, dit kan niet ongedaan worden gemaakt! @@ -473,8 +473,8 @@ Verlaten Lid link voorbeeld afbeelding - LID - BERICHTEN EN BESTANDEN + Lid + Berichten en bestanden Openen in mobiele app en tik vervolgens op Verbinden in de app.]]> Lid wordt uit de groep verwijderd, dit kan niet ongedaan worden gemaakt! Fout bij bezorging van bericht @@ -730,10 +730,10 @@ App scherm verbergen Uw privacy Link voorbeelden verzenden - INSTELLINGEN - ONDERSTEUNING SIMPLEX CHAT - JIJ - CHAT UITVOEREN + Instellingen + Ondersteuning SimpleX Chat + Jij + Chat uitvoeren Uw chat database Wachtwoord instellen om te exporteren Start de app opnieuw om een nieuw chatprofiel aan te maken. @@ -790,7 +790,7 @@ Direct bericht sturen De rol wordt gewijzigd in "%s". De gebruiker ontvangt een nieuwe uitnodiging. Verzenden via - SERVERS + Servers Resetten naar standaardwaarden Ontvangst adres wijzigen Protocol timeout @@ -874,11 +874,11 @@ Bericht delen… SimpleX Vergrendelen Sla het wachtwoord op in Keychain - SOCKS PROXY + SOCKS proxy Dank aan de gebruikers – draag bij via Weblate! De app haalt regelmatig nieuwe berichten op - het gebruikt een paar procent van de batterij per dag. De app maakt geen gebruik van push meldingen, gegevens van uw apparaat worden niet naar de servers verzonden. De afbeelding kan niet worden gedecodeerd. Probeer een andere afbeelding of neem contact op met de ontwikkelaars. - THEMA\'S + Thema\'s Scan server QR-code Deze string is geen verbinding link! Deze actie kan niet ongedaan worden gemaakt, de berichten die eerder zijn verzonden en ontvangen dan geselecteerd, worden verwijderd. Het kan enkele minuten duren. @@ -994,7 +994,7 @@ Database-ID\'s en Transport isolatie optie. Verbergen: Ontwikkelaars opties tonen - EXPERIMENTEEL + Experimenteel Verwijder profiel Profiel wachtwoord Chatprofiel zichtbaar maken @@ -1146,7 +1146,7 @@ Zorg ervoor dat het bestand de juiste YAML-syntaxis heeft. Exporteer het thema om een voorbeeld te hebben van de themabestandsstructuur. Database openen… Gebruikershandleiding.]]> - INTERFACE KLEUREN + Interface kleuren U kunt uw adres delen als een link of QR-code - iedereen kan verbinding met u maken. Alle app-gegevens worden verwijderd. Er wordt een leeg chatprofiel met de opgegeven naam gemaakt en de app wordt zoals gewoonlijk geopend. @@ -1226,7 +1226,7 @@ geen tekst Er zijn enkele niet-fatale fouten opgetreden tijdens het importeren: Afsluiten\? - APP + App Herstarten Afsluiten Meldingen werken niet meer totdat u de app opnieuw start @@ -1285,7 +1285,7 @@ Inschakelen (overschrijvingen behouden) Het verzenden van ontvangst bevestiging is uitgeschakeld voor %d-contactpersonen Uitschakelen voor iedereen - STUUR ONTVANGST BEVESTIGING NAAR + Stuur ontvangst bevestiging naar Ontvangst bevestiging verzenden De tweede vink die we gemist hebben! ✅ Filter ongelezen en favoriete chats. @@ -1779,13 +1779,13 @@ Om uw IP-adres te beschermen, gebruikt privéroutering uw SMP-servers om berichten te bezorgen. Stuur GEEN berichten rechtstreeks, zelfs als uw of de bestemmingsserver geen privéroutering ondersteunt. Terugval op berichtroutering - PRIVÉBERICHT ROUTING + Privébericht routing Stuur berichten rechtstreeks als het IP-adres beschermd is en uw of bestemmingsserver geen privéroutering ondersteunt. Onbekende servers! Zonder Tor of VPN is uw IP-adres zichtbaar voor deze XFTP-relays: \n%1$s. Zonder Tor of VPN is uw IP-adres zichtbaar voor bestandsservers. - BESTANDEN + Bestanden Bescherm het IP-adres De app vraagt om downloads van onbekende bestandsservers te bevestigen (behalve .onion of wanneer SOCKS-proxy is ingeschakeld). Fout bij het initialiseren van WebView. Update uw systeem naar de nieuwe versie. Neem contact op met ontwikkelaars. @@ -2055,7 +2055,7 @@ Selecteer chatprofiel Profiel delen Uw verbinding is verplaatst naar %s, maar er is een onverwachte fout opgetreden tijdens het omleiden naar het profiel. - CHAT DATABASE + Chat database Systeemmodus Archief verwijderen? Berichten worden verwijderd. Dit kan niet ongedaan worden gemaakt! 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 9cc43851d6..51bfacb404 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -495,36 +495,36 @@ Pominięte wiadomości Twoja prywatność Kopia zapasowa danych aplikacji - IKONA APLIKACJI + Ikona aplikacji Automatyczne akceptowanie obrazów - POŁĄCZENIA - BAZA DANYCH CZATU + Połączenia + Baza danych czatu Czat jest uruchomiony Czat jest zatrzymany - CZATY + Czaty Hasło do bazy danych Usuń bazę danych Narzędzia deweloperskie - URZĄDZENIE + Urządzenie Błąd uruchamiania czatu - EKSPERYMENTALNE + Eksperymentalne Funkcje eksperymentalne Eksportuj bazę danych - POMOC + Pomoc Importuj bazę danych Tryb incognito - WIADOMOŚCI I PLIKI + Wiadomości i pliki Nowe archiwum bazy danych Stare archiwum bazy danych Chroń ekran aplikacji - URUCHOM CZAT + Uruchom czat Wyślij podgląd linku - USTAWIENIA - PROXY SOCKS + Ustawienia + Proxy SOCKS Zatrzymać czat\? - WSPIERAJ SIMPLEX CHAT - MOTYWY - TY + Wspieraj SimpleX Chat + Motywy + Ty Twoja baza danych czatu Ustaw hasło do eksportu Zatrzymaj @@ -707,12 +707,12 @@ Błąd tworzenia linku grupy Błąd usuwania linku grupy Błąd usuwania członka - DLA KONSOLI + Dla konsoli Grupa Wprowadź nazwę grupy: Pełna nazwa grupy: Nazwa lokalna - CZŁONEK + Członek Członek zostanie usunięty z grupy - nie można tego cofnąć! Status sieci Tylko właściciele grup mogą zmieniać preferencje grupy. @@ -724,7 +724,7 @@ Zapisać wiadomość powitalną\? Wyślij wiadomość bezpośrednią Wysyłanie przez - SERWERY + Serwery Przełącz Zmień adres odbioru W pełni zdecentralizowana – widoczna tylko dla członków. @@ -1136,7 +1136,7 @@ Kiedy ludzie proszą o połączenie, możesz je zaakceptować lub odrzucić. Nie stracisz kontaktów, jeśli później usuniesz swój adres. Dostosuj motyw - KOLORY INTERFEJSU + Kolory interfejsu Twoje kontakty pozostaną połączone. 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ć. @@ -1229,7 +1229,7 @@ brak tekstu Podczas importu wystąpiły niekrytyczne błędy: Restart - APLIKACJA + Aplikacja Powiadomienia przestaną działać do momentu ponownego uruchomienia aplikacji. Wyłączenie Wyłączyć\? @@ -1262,7 +1262,7 @@ Spraw, aby jedna wiadomość zniknęła Renegocjuj szyfrowanie kod bezpieczeństwa zmieniony - WYŚLIJ POTWIERDZENIA DOSTAWY DO + Wyślij potwierdzenia dostawy do Wysyłanie potwierdzeń dostawy zostanie włączone dla wszystkich kontaktów we wszystkich widocznych profilach czatu. Kontakty Włączyć potwierdzenia\? @@ -1772,7 +1772,7 @@ Nie Gdy IP ukryty Pokaż status wiadomości - TRASOWANIE PRYWATNYCH WIADOMOŚCI + Trasowanie prywatnych wiadomości NIE wysyłaj wiadomości bezpośrednio, nawet jeśli serwer docelowy nie obsługuje prywatnego trasowania. Aby chronić Twój adres IP, prywatne trasowanie używa Twoich serwerów SMP, aby dostarczyć wiadomości. Nieznane serwery @@ -1788,7 +1788,7 @@ Chroń adres IP Aplikacja będzie prosić o potwierdzenie pobierań z nieznanych serwerów plików (z wyjątkiem .onion lub gdy proxy SOCKS jest włączone). Bez Tor lub VPN, Twój adres IP będzie widoczny do serwerów plików. - PLIKI + Pliki Motyw profilu Pokaż listę czatów w nowym oknie Kolory ciemnego trybu @@ -2065,7 +2065,7 @@ %1$d plik(ów/i) dalej są pobierane. %1$d plik(ów/i) nie udało się pobrać. Błąd zmiany profilu - BAZA CZATU + Baza czatu %1$d błędów plików:\n%2$s %1$d innych błędów plików. Wiadomości zostały usunięte po wybraniu ich. @@ -2233,7 +2233,7 @@ kontakt usunięty kontakt wyłączony kontakt nie gotowy - PROŚBY O KONTAKT OD GRUP + Prośby o kontakt od grup kontakt powinien zaakceptować… Stwórz swój adres %d czat(y) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml index c129d68521..2d33731ce1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt-rBR/strings.xml @@ -74,16 +74,16 @@ para cada perfil de bate-papo que você tiver no aplicativo.]]> Melhor para bateria. Você receberá notificações apenas quando o aplicativo estiver em execução (SEM o serviço em segundo plano).]]> Consome mais bateria! O aplicativo em segundo plano está sempre em execução - as notificações são exibidas instantaneamente.]]> - BATE-PAPOS - ÍCONE DO APLICATIVO - BANCO DE DADOS DE BATE-PAPO + Bate-papos + Ícone do aplicativo + Banco de dados de bate-papo O bate-papo está em execução O bate-papo está parado Alterar senha do banco de dados\? endereço alterado para você Você e seu contato podem enviar mensagens temporárias. Backup de dados do aplicativo - CHAMADAS + Chamadas Aceitar solicitações de contato automaticamente Aparência O serviço em segundo plano está sempre em execução - as notificações serão exibidas assim que as mensagens estiverem disponíveis. @@ -247,7 +247,7 @@ Tempo de conexão esgotado Excluir mensagem do membro\? Excluir fila - DISPOSITIVO + Dispositivo Ferramentas de desenvolvedor conectando (introduzido) Tonalidade @@ -378,7 +378,7 @@ Arquivo salvo Os membros podem enviar mensagens de voz. O grupo será excluído para todos os membros - isso não pode ser desfeito! - AJUDA + Ajuda Ocultar contato e mensagem Como usar Como usar markdown @@ -420,7 +420,7 @@ Servidores ICE (um por linha) Ignorar A imagem será recebida quando seu contato estiver online, aguarde ou verifique mais tarde! - SERVIDORES + Servidores Recebendo via Status da conexão seg @@ -495,8 +495,8 @@ Usar proxy SOCKS\? Chamada rejeitada Restaurar o backup do banco de dados - PARA CONSOLE - EXECUTAR BATE-PAPO + Para console + Executar bate-papo Parar Definir senha para exportar Reinicie o aplicativo para usar o banco de dados do chat importado. @@ -571,7 +571,7 @@ você mudou o cargo de %s para %s Novo cargo de membro Remover - MEMBRO + Membro O membro será removido do grupo - isso não pode ser desfeito! Cargo Enviando via @@ -663,8 +663,8 @@ Interface chinesa e espanhola Maior redução no uso da bateria Mais melhorias chegarão em breve! - VOCÊ - MENSAGENS E ARQUIVOS + Você + Mensagens e arquivos Seu banco de dados de bate-papo Você removeu %1$s removido @@ -800,7 +800,7 @@ Ocultar perfil confirmação recebida… O servidor de relay protege seu endereço IP, mas pode observar a duração da chamada. - EXPERIMENTAL + Experimental você alterou o endereço Atualização do banco de dados O cargo será alterado para "%s". O membro receberá um novo convite. @@ -819,7 +819,7 @@ Somente o proprietários de grupo podem ativar mensagens de voz você compartilhou um link de uso único Você será conectado quando sua solicitação de conexão for aceita, aguarde ou verifique mais tarde! - CONFIGURAÇÕES + Configurações Defina a mensagem mostrada aos novos membros! Configurações Alternar endereço de recebimento @@ -848,7 +848,7 @@ Ligar Bem-vindo(a)! O futuro da transmissão de mensagens - PROXY SOCKS + Proxy SOCKS A tentativa de alterar a senha do banco de dados não foi concluída. Pare o bate-papo para exportar, importar ou excluir o banco de dados do chat. Você não poderá receber e enviar mensagens enquanto o chat estiver interrompido. %s segundo(s) @@ -887,7 +887,7 @@ chamada de vídeo Mostrar Servidores ICE WebRTC - TEMAS + Temas Atualizar O app busca novas mensagens periodicamente – ele usa alguns por cento da bateria por dia. O aplicativo não usa notificações por push – os dados do seu dispositivo não são enviados para os servidores. Para receber notificações, por favor, digite a senha do banco de dados @@ -1001,7 +1001,7 @@ desativado Desatualizar e abrir o bate-papo desativado - APOIE SIMPLEX CHAT + Apoie SimpleX Chat Esta ação não pode ser desfeita - as mensagens enviadas e recebidas antes do selecionado serão excluídas. Pode levar vários minutos. Confirme as atualizações do banco de dados Somente o cliente dos dispositivos armazenam perfis de usuários, contatos, grupos e mensagens. @@ -1127,7 +1127,7 @@ Você não perderá seus contatos se, posteriormente, excluir seu endereço. Endereço SimpleX Quando as pessoas solicitam uma conexão, você pode aceitá-la ou rejeitá-la. - CORES DA INTERFACE + Cores da interface compartilhar com os contatos A atualização do perfil será enviada aos seus contatos. Salvar configurações\? @@ -1253,7 +1253,7 @@ Correção não suportada pelo membro do grupo concordando com criptografia… Permitir o envio de arquivos e mídia. - APP + App criptografia OK renegociação de criptografia necessária criptografia concordada para %s @@ -1284,7 +1284,7 @@ Ativar recibos? Encontrar conversas mais rápido Contatos - ENVIAR RECIBOS DE ENTREGA PARA + Enviar recibos de entrega para Enviar confirmações está desativado para %d contatos. Enviar confirmações está ativado para %d contatos. Enviar confirmações @@ -1862,9 +1862,9 @@ Alto falante Headphones Sem Tor ou VPN, seu endereço de IP ficará visível para servidores de arquivo. - ARQUIVOS + Arquivos Fotos de perfil - ROTEAMENTO DE MENSAGEM PRIVADA + Roteamento de mensagem privada criptografia padrão ponta a ponta proprietários Migrar para outro dispositivo @@ -2055,7 +2055,7 @@ Barras de ferramentas de aplicativos acessível Falha no baixar de %1$d arquivo(s). %1$s mensagens não encaminhadas. - DADOS DO BATE-PAPO + Dados do bate-papo Utilize credenciais aleatórias O arquivo de banco de dados enviado será removido permanentemente dos servidores. Use credenciais diferentes de proxy para cada conexão. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml index 5f12e762aa..af292da504 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -28,9 +28,9 @@ Backup de dados da aplicação Aceitar imagens automaticamente Código de acesso definido! - VOCÊ - MENSAGENS E FICHEIROS - ÍCONE DA APLICAÇÃO + Você + Mensagens e ficheiros + Ícone da aplicação 1 mês Mensagens Adicionar mensagem de boas-vindas @@ -137,7 +137,7 @@ Eliminar Eliminar todos os ficheiros Eliminar base de dados - BASE DE DADOS DE CONVERSA + Base de dados de conversa Base de dados de conversa eliminada Nome para Exibição Mostrar: @@ -184,7 +184,7 @@ Colar ligação recebida Senha não encontrada na Keystore, por favor insira-a manualmente. Isto pode ter acontecido se você restaurou os dados da aplicação usando uma ferramenta de backup. Se não for o caso, entre em contato com os desenvolvedores. O servidor requer autorização para criar filas, verifique a senha - SERVIDORES + Servidores O servidor requer autorização para fazer upload, verifique a senha Usar hosts .onion como Não se o proxy SOCKS não o suportar.]]> Usar hosts .onion @@ -226,7 +226,7 @@ Chamada já finalizada! Chamada em curso Chamada finalizada - CHAMADAS + Chamadas Por perfil de conversa (padrão) ou por ligação (BETA). Não é possível aceder à Keystore para salvar a senha da base de dados Não é possível convidar o contato! @@ -251,7 +251,7 @@ Erro ao eliminar ligação de grupo Perfil de conversa Alterar o modo de bloqueio - CONVERSAS + Conversas Conversa em execução Erro ao alterar configuração Alterar a senha da base de dados\? @@ -471,7 +471,7 @@ ID da base de dados Eliminar ficheiro Eliminar contacto? - DISPOSITIVO + Dispositivo Mensagens diretas Descentralizado mensagem duplicada @@ -540,7 +540,7 @@ %1$d mensagens ignoradas. Importar base de dados As suas definições - DEFINIÇÕES + Definições Partilhar Partilhar endereço Definições @@ -554,12 +554,12 @@ \nEsta ação é irreversível - o seu perfil, contactos, mensagens e ficheiros serão irreversivelmente perdidos. Marcar como não lido membro - MEMBRO + Membro Máximo de 40 segundos, recebido instantaneamente. Mais Rede e servidores Configurações avançadas - EXPERIMENTAL + Experimental Você pode iniciar a conversa através das Definições da aplicação / Base de Dados ou reiniciando a aplicação. Atualizar A atualização das definições reconectará o cliente a todos os servidores. @@ -572,10 +572,10 @@ Muito provavelmente este contato eliminou a conexão consigo. Este texto está disponível nas definições Pode ser alterado mais tarde através das definições. - AJUDA - SUPORTE SIMPLEX CHAT + Ajuda + Suporte SimpleX Chat Funcionalidades experimentais - TEMAS + Temas Escuro Tema escuro nunca diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml index 81cf8ed452..b8f8f3f111 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -98,7 +98,7 @@ și %d alte evenimente Răspunde la apel Android Keystore va fi folosit pentru a stoca în siguranță parola după ce repornești aplicația sau schimbi parola — acest lucru va permite primirea de notificări. - APLICAȚIE + Aplicație Creează grup Apeluri audio și video Arhivează și încarcă @@ -109,7 +109,7 @@ Apel audio apel audio Audio oprit - PICTOGRAMĂ APLICAȚIE + Pictogramă aplicație Cod de acces aplicație Creează grup secret Creează coadă @@ -292,11 +292,11 @@ Afișează: Afișează erorile interne secret - SETĂRI + Setări %s conectat setați o nouă poză de profil Trimis la: %s - SERVERE + Servere Trimite mesaj în direct %s descărcat Partajați adresa cu contactele? @@ -376,7 +376,7 @@ Hash mesaj incorect Schimbă adresa de primire Conversația este oprită. Dacă ai folosit deja această bază de date pe alt dispozitiv, ar trebui să o transferi înapoi înainte de a porni conversația. - APELURI + Apeluri v-ați schimbat rolul în %s Capacitate depășită - destinatarul nu a primit mesajele trimise anterior. Schimbă codul de acces autodistructibil @@ -431,8 +431,8 @@ contactul are criptare e2e contactul nu are criptare e2e Contacte - CONVERSAȚII - BAZĂ DE DATE CONVERSAȚIE + Conversații + Bază de date conversație Baza de date a conversației a fost ștearsă Conversația rulează Baza de date a conversațiilor tale @@ -684,7 +684,7 @@ Verifică pentru actualizări Creează Estompează media - BAZĂ DE DATE CONVERSAȚIE + Bază de date conversație Conectează-te cu prietenii mai ușor. încercări Finalizat @@ -719,11 +719,11 @@ Setări apel audio criptat e2e apel video criptat e2e - DISPOZITIV - EXPERIMENTAL + Dispozitiv + Experimental Criptează erori de decriptare - TU + Tu nicio criptare e2e criptat e2e Apel video primit @@ -825,7 +825,7 @@ Notificări și baterie Deschide Activați confirmarea de primire pentru grupuri? - PENTRU CONSOLĂ + Pentru consolă Moderat la Remediați conexiunea Profiluri de conversație multiple @@ -915,7 +915,7 @@ Nicio conversație în lista %s. Nimic selectat deschis - AJUTOR + Ajutor Doar contactul tău poate trimite mesaje care dispar. Se importă arhiva Migrare dispozitiv @@ -1010,7 +1010,7 @@ Luminos Nu Deschizi linkul web? - MESAJE ȘI FIȘIERE + Mesaje și fișiere moderator Rol inițial Doar proprietarii grupului pot modifica preferințele grupului. @@ -1043,7 +1043,7 @@ Cum afectează bateria Activare (păstrați suprascrierile) Activează codul de autodistrugere - MEMBRU + Membru Operator de rețea Desktop-uri conectate Eroare la acceptarea membrului @@ -1148,7 +1148,7 @@ Activați confirmarea de primire? Nume nou afișat: Instrumente pentru dezvoltatori - FIȘIERE + Fișiere Deschide linkurile din lista de conversații Forma mesajului Importați baza de date @@ -1393,7 +1393,7 @@ Șterge Instalați SimpleX Chat pentru terminal Eroare la salvarea serverelor ICE - CULORILE INTERFEȚEI + Culorile interfeței %d fișier(e) cu dimensiunea totală de %s Fișiere și media invitat %1$s @@ -1636,7 +1636,7 @@ Conexiuni de profil și server răspuns primit… Politica de confidențialitate și condițiile de utilizare. - RUTAREA MESAJELOR PRIVATE + Rutarea mesajelor private Te rugăm să stochezi parola în siguranță, altfel NU o vei putea schimba dacă o pierzi. Parola nu a fost găsită în Keystore. Te rugăm să o introduci manual. Acest lucru s-ar putea întâmpla dacă ai restaurat datele aplicației folosind un instrument de backup. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. Parola stocată în Keystore nu poate fi citită. Acest lucru se poate întâmpla după o actualizare a sistemului incompatibilă cu aplicația. Dacă nu este cazul, te rugăm să contactezi dezvoltatorii. @@ -1767,7 +1767,7 @@ revizuit de administratori Operatori de server Serverul de retransmisie protejează adresa IP, dar poate observa durata apelului. - PORNIȚI CHATUL + Porniți chatul te-a eliminat %s la %s Protocolul serverului a fost modificat. @@ -1800,7 +1800,7 @@ Selectează profilul de conversație Salvează setările adresei SimpleX Salvează lista - TRIMITE CONFIRMĂRI DE LIVRARE LA + Trimite confirmări de livrare la Parola de autodistrugere a fost schimbată! Înregistrare actualizată la Selectează operatorii de rețea de utilizat. @@ -1973,9 +1973,9 @@ Aplicația va cere să confirmați descărcările de pe servere de fișiere necunoscute (cu excepția celor .onion sau când proxy-ul SOCKS este activat). Sistem Acestea pot fi ignorate în setările de contact și de grup. - SUPORT SIMPLEX CHAT - PROXY SOCKS - TEME + Suport SimpleX Chat + Proxy SOCKS + Teme În timpul importului au apărut câteva erori non-fatale: Atingeți pentru a vă alătura Atenție: este posibil să pierdeți unele date! @@ -2430,7 +2430,7 @@ Trimis contactului tău după conectare. Actualizezi la o adresă permanentă? Mesaj de bun venit - SOLICITĂRI DE CONTACT DE LA GRUPURI + Solicitări de contact de la grupuri Această setare este pentru profilul tău actual Partajează adresa veche Partajează linkul vechi 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 b5bbf47973..18f9118d3d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -548,26 +548,26 @@ Отправлять картинки ссылок Резервная копия данных - ВЫ - НАСТРОЙКИ - ПОМОЩЬ - ПОДДЕРЖАТЬ SIMPLEX CHAT - УСТРОЙСТВО - ЧАТЫ + Вы + Настройки + Помощь + Поддержать SimpleX Chat + Устройство + Чаты Инструменты разработчика Экспериментальные функции - SOCKS-ПРОКСИ - ЗНАЧОК - ТЕМЫ - СООБЩЕНИЯ И ФАЙЛЫ - ЗВОНКИ + SOCKS-прокси + Значок + Темы + Сообщения и файлы + Звонки Режим Инкогнито База данных - ЗАПУСТИТЬ ЧАТ + Запустить чат Чат запущен Чат остановлен - БАЗА ДАННЫХ + База данных Пароль базы данных Экспорт архива чата Импорт архива чата @@ -767,7 +767,7 @@ Ошибка при удалении ссылки группы Только владельцы группы могут изменять предпочтения группы. - ДЛЯ КОНСОЛИ + Для консоли Локальное имя ID базы данных @@ -775,7 +775,7 @@ Отправить сообщение Член группы будет удалён - это действие нельзя отменить! Удалить - ЧЛЕН ГРУППЫ + Член группы Роль Поменять роль Поменять @@ -790,7 +790,7 @@ прямое непрямое (%1$s) - СЕРВЕРЫ + Серверы Получение через Отправка через Состояние сети @@ -1084,7 +1084,7 @@ Ожидание видео Видео будет получено когда Ваш контакт загрузит его. Скрыть: - ЭКСПЕРИМЕНТАЛЬНЫЕ + Экспериментальные Только 10 видео могут быть отправлены одновременно Раскрыть профиль Видео будет получено, когда Ваш контакт будет онлайн, пожалуйста, подождите или проверьте позже! @@ -1226,7 +1226,7 @@ Запретить реакции на сообщения. Запретить реакции на сообщения. секунд - ЦВЕТА ИНТЕРФЕЙСА + Цвета интерфейса Поделиться адресом с контактами SimpleX? Обновление профиля будет отправлено Вашим SimpleX контактам. Об адресе SimpleX @@ -1347,15 +1347,15 @@ Только владельцы группы могут разрешить файлы и медиа. Файлы и медиа Выключить\? - ПРИЛОЖЕНИЕ + Приложение Перезапустить Выключить Выключить для всех Включить для всех Включить (кроме исключений) Выключить отчёты о доставке\? - ОТПРАВКА ОТЧЁТОВ О ДОСТАВКЕ - ЗАПРОСЫ НА СОЕДИНЕНИЕ ИЗ ГРУПП + Отправка отчётов о доставке + Запросы на соединение из групп шифрование согласовано шифрование согласовано для %s шифрование работает @@ -1829,7 +1829,7 @@ Форма картинок профилей Квадрат, круг и все, что между ними. Будет включено в прямых разговорах! - ФАЙЛЫ + Файлы Новые темы чатов нет Светлая @@ -1898,7 +1898,7 @@ Показать статус сообщения Прямая доставка сообщений Режим доставки сообщений - КОНФИДЕНЦИАЛЬНАЯ ДОСТАВКА СООБЩЕНИЙ + Конфиденциальная доставка сообщений Цвета чата Тема чата Тема профиля @@ -2151,7 +2151,7 @@ Переслать сообщения… Проверьте правильность ссылки SimpleX. Ошибка ссылки - БАЗА ДАННЫХ + База данных Ошибка инициализации WebView. Убедитесь, что у вас установлен WebView и его поддерживаемая архитектура - arm64.\nОшибка: %s Звук отключен Сообщения будут удалены - это нельзя отменить! @@ -2719,9 +2719,9 @@ Открыть канал Открыть новый канал Владельцы - ВЛАДЕЛЕЦ + Владелец релей - РЕЛЕЙ + Релей Адрес релея Адрес релея Ошибка подключения релея @@ -2740,7 +2740,7 @@ Поделиться адресом релея Поделиться в чате ⚠️ Ошибка проверки подписи: %s. - ПОДПИСЧИК + Подписчик Подписчики Подписчик будет удалён из канала - это нельзя отменить! Начните разговор @@ -2801,7 +2801,7 @@ Вы перестанете получать сообщения из этого канала. История чата сохранится. обновлён профиль канала ошибка - ОШИБКА СОЕДИНЕНИЯ + Ошибка соединения Чат с админами Разрешить членам группы общаться с админами. Запретить чаты с админами. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml index c355d8d9fb..df879fe7ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -996,7 +996,7 @@ ข้อความที่ข้ามไป ส่ง ระบบ - สนับสนุน SIMPLEX แชท + สนับสนุน SimpleX Chat พร็อกซี SOCKS หยุด หยุดแชท\? 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 0e9c54fb87..d56d308654 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -50,8 +50,8 @@ Aramayı cevapla Uygulama veri yedekleme Tüm uygulama verileri silinir. - UYGULAMA - UYGULAMA SİMGESİ + Uygulama + Uygulama simgesi 1 hafta şifreleme kabul ediliyor… yönetici @@ -86,7 +86,7 @@ Konuştuğunuz kişinin uygulamasından güvenlik kodunu okut. WebRTC ICE sunucu adreslerinin doğru formatta olduğundan emin olun: Satırlara ayrılmış ve yinelenmemiş şekilde. Kaydet - ARAYÜZ RENKLERİ + Arayüz renkleri SimpleX adres ayarlarını kaydet Ayarlar kaydedilsin mi? Kaydet ve konuştuğun kişilere bildir @@ -97,7 +97,7 @@ Ses kapalı Doğrulama iptal edildi Yeniden başlat - TEMALAR + Temalar İçe aktarılan konuşma veri tabanını kullanmak için uygulamayı yeniden başlat. Yeni bir konuşma profili oluşturmak için uygulamayı yeniden başlatın. Geri Yükle @@ -184,7 +184,7 @@ silindi dosya alma henüz desteklenmiyor sen - geçersi̇z sohbet + geçersiz sohbet bağlantı %1$d Tarayıcı ile %1$s tarafından @@ -287,10 +287,10 @@ Ek ikincil renk Üyeyi çıkar Kaldır - ARAMALAR - SOHBETLER - SEN - SOHBET VERİTABANI + Aramalar + Sohbetler + Sen + Sohbet veritabanı Kaldır Yanlış parola! Veritabanı yükseltmelerini onayla @@ -300,7 +300,7 @@ bağlanılıyor (duyuruldu) sen: %1$s kaldırıldı - ÜYE + Üye Üyeler kendiliğinden yok olan mesajlar gönderebilir. Kendiliğinden yok olan mesaj gönderimini engelle. Yalnızca kişiniz sesli mesaj göndermeye izin veriyorsa sen de ver. @@ -422,7 +422,7 @@ Eğer uygulamayı açarken tüm verileri yok eden erişim kodunu girersen: Eğer uygulamayı açarken bu erişim kodunu kullanırsan uygulama içi tüm veriler kalıcı olarak silinecektir! Erişim kodu belirle - AYGIT + Aygit Veri tabanı parolası Veri tabanı, rastgele bir parola ile şifrelendi. Dışa aktarmadan önce lütfen değiştir. Dosyaları ve medyayı sil\? @@ -600,7 +600,7 @@ ICE sonucuları kaydedilirken hata oluştu Kullanıcı gizliliği güncellenirken hata oluştu Gözde - DENEYSEL + Deneysel Dosya, sunuculardan silinecektir. Yedekleri geri yükledikten sonra şifrelemeyi onar. Fransız arayüzü @@ -622,7 +622,7 @@ Konuşma başlatılırken hata oluştu Deneysel özellikler Veri tabanını dışa aktar - YARDIM + Yardim Veri tabanını içe aktar Konuşma durdulurken hata oluştu İçe aktar @@ -634,7 +634,7 @@ grup profili güncellendi grup silindi Toplu konuşma bağlantısı güncellenirken hata oluştu - UÇBİRİM İÇİN + Uçbirim için Grup bağlantısı Grup dolaylı (%1$s) @@ -801,7 +801,7 @@ arama sona erdi %1$s Kilit ekranında aramalar: Kötü mesaj kimliği - MESAJLAR VE DOSYALAR + Mesajlar ve dosyalar Veri tabanı parolasını değiştir\? Parola Keystore\'da bulunamadı, lütfen manuel olarak girin. Bu, uygulamanın verilerini bir yedekleme aracı kullanarak geri yüklediyseniz olabilir. Eğer durum böyle değilse, lütfen geliştiricilerle iletişime geçin. Ayrıl @@ -1037,7 +1037,7 @@ Sohbeti durdur Mevcut profili kullan Sistem - SIMPLEX CHAT\'İ DESTEKLE + SimpleX Chat\'i destekle Sohbet veri tabanını dışa aktarmak, içe aktarmak veya silmek için sohbeti durdur. Sohbet durdurulduğunda mesaj alamaz ve gönderemezsiniz. Masaüstü Bağlanmak için dokun @@ -1134,7 +1134,7 @@ Bağlantı paylaş SimpleX Ekibi %s, %s ve %s bağlandı - SOCKS VEKİLİ + SOCKS vekili Masaüstür cihazlar SMP sunucuları Uyumlu değil! @@ -1217,7 +1217,7 @@ güvenlik kodu değiştirildi Bluetooth desteği ve diğer iyileştirmeler. Ayarlar - AYARLAR + Ayarlar Bağlanmak için doğrudan mesaj gönderin Güvenlik kodu Daha hızlı gruplara katılma ve daha güvenilir mesajlar. @@ -1352,7 +1352,7 @@ Yönlendirici sunucusu sadece lazım ise kullanılacak. Diğer taraf IP adresini görebilir. %s ın bağlantısı kesildi]]> Sunucuyu test et - SUNUCULAR + Sunucular Sunucuları test et Mesaj taslağı Bir mesajı yok edin @@ -1361,7 +1361,7 @@ Kişi doğrulandı Rasgele parola kullan Sistem yetkilendirilmesi yerine ayarla. - SOHBETİ ÇALIŞTIR + Sohbeti çalıştır Direkt internet bağlantısı kullan? grup profili güncellendi Onion ana bilgisayarları bağlantı için gerekli olacaktır. @@ -1446,7 +1446,7 @@ Yeni sohbet Bir canlı mesaj gönder - bu yazdıklarını anlık olarak alıcıya(lara) güncelleyen bir mesajdır Şifre Yöneticisindeki parola silinsin mi? - LERE GÖNDER + Lere gönder Takma adla bağlan Her zaman yönlendirici kullan. Kilidini aç @@ -1778,7 +1778,7 @@ Sizin veya hedef sunucunun özel yönlendirmeyi desteklememesi durumunda bile mesajları doğrudan GÖNDERMEYİN. IP adresi korumalı olduğunda ve sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. Sizin veya hedef sunucunun özel yönlendirmeyi desteklemediği durumlarda mesajları doğrudan gönderin. - GİZLİ MESAJ YÖNLENDİRME + Gizli mesaj yönlendirme Mesaj durumunu göster IP adresinizi korumak için,gizli yönlendirme mesajları iletmek için SMP sunucularınızı kullanır. Korumasız @@ -1805,7 +1805,7 @@ Karanlık Aydınlık mod IP adresini koru - DOSYALAR + Dosyalar Sohbet renkleri Sığdır Alınan cevap diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml index 4e62631dbb..b61b77ec9d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -33,14 +33,14 @@ Дозволити надсилати зникаючі повідомлення. прийнятий виклик Завжди використовувати реле - ДОДАТОК + Додаток Дозволити надсилання приватних повідомлень учасникам. Дозволити безповоротно видаляти надіслані повідомлення. (24 години) Дозволяйте надсилати голосові повідомлення. Дозволити реакції на повідомлення. Вся інформація стирається при його введенні. Пароль для додатка - ІКОНКА ДОДАТКУ + Іконка додатку Дозволити зникаючі повідомлення тільки за умови, що ваш контакт дозволяє їх. Дозвольте вашим контактам додавати реакції на повідомлення. Дозволити реакції на повідомлення тільки за умови, що ваш контакт дозволяє їх. @@ -278,8 +278,8 @@ Повернути камеру Відхилений виклик %1$d пропущено повідомлень - ЧАТИ - SOCKS-ПРОКСІ + Чати + SOCKS-проксі Помилка при запуску чату Зупинити Імпортувати @@ -416,9 +416,9 @@ Підключення виклику Конфіденційність і безпека Конфіденційність - НАЛАШТУВАННЯ - ДОПОМОГА - ПІДТРИМАЙТЕ SIMPLEX CHAT + Налаштування + Допомога + Підтримайте SimpleX Chat Зупиніть чат, щоб експортувати, імпортувати або видалити базу даних чату. Ви не зможете отримувати та надсилати повідомлення, поки чат зупинено. Помилка видалення бази даних чату Сповіщення будуть доставлятися лише до зупинки додатка! @@ -464,7 +464,7 @@ Перезапустити База даних чату Чат зупинено - БАЗА ДАНИХ ЧАТУ + База даних чату Новий архів бази даних Зупинити чат\? Ваша поточна база даних чату буде ВИДАЛЕНА та ЗАМІНЕНА імпортованою. @@ -658,11 +658,11 @@ Пароль самознищення увімкнено! Пароль самознищення змінено! Ваш профіль, контакти та доставлені повідомлення зберігаються на вашому пристрої. - ВИ - ПРИСТРІЙ + Ви + Пристрій Вимкнути - ТЕМИ - ПОВІДОМЛЕННЯ ТА ФАЙЛИ + Теми + Повідомлення та файли Чат працює Імпортувати базу даних Старий архів бази даних @@ -782,7 +782,7 @@ місяці Ви вже підключені до %1$s через це посилання. Режим інкогніто - СЕРВЕРИ + Сервери Зберегти вітальне повідомлення? Отримання через Приглушено, коли неактивно! @@ -879,7 +879,7 @@ %d день %d днів скасовано %s - ЗАПУСК ЧАТУ + Запуск чату Пароль бази даних Експортувати базу даних Видалити всі файли @@ -930,7 +930,7 @@ змінює адресу… Залишити спостерігач - УЧАСНИК + Учасник Режим інкогніто захищає вашу конфіденційність, використовуючи новий випадковий профіль для кожного контакту. Більше поліпшень незабаром! Тільки власники груп можуть увімкнути голосові повідомлення. @@ -979,7 +979,7 @@ Контакт відмічено Ви намагаєтеся запросити контакт, з яким ви поділилися інкогніто-профілем, до групи, в якій ви використовуєте основний профіль Помилка при створенні посилання на групу - ДЛЯ КОНСОЛІ + Для консолі Учасника буде вилучено з групи - цю дію неможливо скасувати! Змінити роль Ви все ще отримуватимете дзвінки та сповіщення від приглушених профілів, коли вони активні. @@ -1021,7 +1021,7 @@ Хост Порт Обов\'язково - КОЛЬОРИ ІНТЕРФЕЙСУ + Кольори інтерфейсу Створіть адресу, щоб дозволити людям підключатися до вас. Контакти залишатимуться підключеними. Створити SimpleX-адресу @@ -1110,7 +1110,7 @@ Галерея Команда SimpleX хоче підключитися до вас! - ЕКСПЕРИМЕНТАЛЬНІ ФУНКЦІЇ + Експериментальні функції Ви повинні використовувати найновішу версію бази даних чату лише на одному пристрої, інакше ви можете припинити отримання повідомлень від деяких контактів. Цей параметр застосовується до повідомлень у вашому поточному профілі чату Зашифрована база даних @@ -1163,7 +1163,7 @@ Кожен може хостити сервери. Інструменти розробника Експериментальні функції - ДЗВІНКИ + Дзвінки Зберегти ключову фразу в сховищі ключів Помилка шифрування бази даних Вилучити ключову фразу із сховища ключів? @@ -1259,7 +1259,7 @@ Заборонено файли та медіа! Буде відправлено ваш профіль %1$s. Вимкнути для всіх груп - НАДСИЛАТИ ПОВІДОМЛЕННЯ ПРО ДОСТАВКУ + Надсилати повідомлення про доставку Підключитися безпосередньо? %s: %s Запит на підключення буде відправлено учаснику групи. @@ -1734,7 +1734,7 @@ Звуки вхідного дзвінка Світлий режим Запасний варіант маршрутизації повідомлень - МАРШРУТИЗАЦІЯ ПРИВАТНИХ ПОВІДОМЛЕНЬ + Маршрутизація приватних повідомлень переслано Інше Дозволити надсилати посилання SimpleX. @@ -1746,7 +1746,7 @@ Камера та мікрофон Надайте дозвіл(и) на здійснення дзвінків Відкрити налаштування - ФАЙЛИ + Файли Зображення профілів Підключення до мережі адміністратори @@ -2049,7 +2049,7 @@ Скинути всі підказки Доступно оновлення: %s Завантаження оновлення скасовано - БАЗА ДАНИХ ЧАТУ + База даних чату Вибрати профіль чату Помилка при зміні профілю Повідомлення будуть видалені — це не можна скасувати! @@ -2513,7 +2513,7 @@ Дозвольте своїм контактам надсилати файли та медіа. Бот Ви, і ваш контакт можете надсилати файли та медіа. - ЗАПИТИ НА ЗВ’ЯЗОК ВІД ГРУП + Запити на зв’язок від груп Застарілі опції Помилка при відмітці як прочитане Файли та медіа заборонені у цьому чаті. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml index 235158585d..93347fa228 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -93,14 +93,14 @@ Ứng dụng chỉ có thể nhận thông báo khi nó đang chạy, không có dịch vụ nền nào được khởi động Bản dựng ứng dụng: %s Giao diện - ỨNG DỤNG + Ứng dụng Di chuyển dữ liệu ứng dụng Sao lưu dữ liệu ứng dụng Mã truy cập ứng dụng đã được thay thế bằng mã tự hủy. Ứng dụng mã hóa các tệp cục bộ mới (trừ video). Áp dụng Mã truy cập ứng dụng - BIỂU TƯỢNG ỨNG DỤNG + Biểu tượng ứng dụng Mã truy cập Phiên bản ứng dụng: v%s Phiên bản ứng dụng @@ -184,7 +184,7 @@ Cuộc gọi kết thúc cuộc gọi kết thúc %1$s lỗi cuộc gọi - CUỘC GỌI + Cuộc gọi Hủy xem trước ảnh Hủy xem trước tệp Hủy @@ -234,11 +234,11 @@ đang thay đổi địa chỉ cho %s… Tùy chọn trò chuyện Màu trò chuyện - CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Cơ sở dữ liệu trò chuyện Kết nối trò chuyện đã được dừng lại Cơ sở dữ liệu đã được di chuyển! Các cuộc trò chuyện - CÁC CUỘC TRÒ CHUYỆN + Các cuộc trò chuyện Kiểm tra tin nhắn mới mỗi 10 phút trong tối đa 1 phút Giao diện Trung Quốc và Tây Ban Nha Trò chuyện với nhà phát triển @@ -463,7 +463,7 @@ Xóa máy chủ Xóa hàng đợi Công cụ nhà phát triển - THIẾT BỊ + Thiết bị Tùy chọn cho nhà phát triển Xác thực thiết bị đã bị vô hiệu hóa. Tắt Khóa SimpleX. Lỗi máy chủ đích: %1$s @@ -738,7 +738,7 @@ Lỗi cập nhật cấu hình mạng Lỗi cập nhật quyền riêng tư người dùng Mở rộng chọn chức vụ - THỬ NGHIỆM + Thử nghiệm Mở rộng Thoát mà không lưu đã hết hạn @@ -748,7 +748,7 @@ Xuất cơ sở dữ liệu Lỗi tải lên kho lưu trữ Tập tin đã xuất không tồn tại - TẬP TIN + Tập tin Không thể tải các cuộc trò chuyện Không tìm thấy tệp - có thể tập tin đã bị xóa và hủy bỏ. Lỗi tệp @@ -776,7 +776,7 @@ Tệp sẽ được nhận khi liên hệ của bạn hoạt động, vui lòng chờ hoặc kiểm tra lại sau! Trạng thái tệp: %s Lấp đầy - CƠ SỞ DỮ LIỆU TRÒ CHUYỆN + Cơ sở dữ liệu trò chuyện Lỗi chuyển đổi hồ sơ Lọc các cuộc hội thoại chưa đọc và các cuộc hội thoại yêu thích. Cuối cùng, chúng ta đã có chúng! 🚀 @@ -821,7 +821,7 @@ Máy chủ chuyển tiếp %1$s không thể kết nối tới máy chủ đích %2$s. Vui lòng thử lại sau. Địa chỉ máy chủ chuyển tiếp không tương thích với cài đặt mạng: %1$s. Phiên bản máy chủ chuyển tiếp không tương thích với cài đặt mạng: %1$s. - CHO CONSOLE + Cho console Chuyển tiếp tin nhắn… Giảm thiểu sử dụng pin hơn nữa Chuyển tiếp tin nhắn mà không có tệp? @@ -863,7 +863,7 @@ Xin chào! \nKết nối với tôi qua SimpleX Chat: %s Ẩn hồ sơ - TRỢ GIÚP + Trợ giúp Nhóm sẽ bị xóa cho tất cả các thành viên - điều này không thể hoàn tác! Nhóm sẽ bị xóa cho bạn - điều này không thể hoàn tác! Tùy chọn nhóm @@ -952,7 +952,7 @@ Cài đặt cập nhật Cuộc gọi video đến Phiên bản không tương thích - MÀU SẮC GIAO DIỆN + Màu sắc giao diện đã được mời Đường dẫn không hợp lệ cuộc trò chuyện không hợp lệ @@ -1001,7 +1001,7 @@ Mời thành viên Mời vào nhóm Rời nhóm - THÀNH VIÊN + Thành viên Thông tin hàng đợi tin nhắn Chỉ dữ liệu hồ sơ cục bộ Giữ lại các kết nối của bạn @@ -1050,7 +1050,7 @@ Thành viên sẽ bị xóa khỏi nhóm - việc này không thể được hoàn tác! Chế độ sáng UI tiếng Litva - TIN NHẮN VÀ TỆP + Tin nhắn và tệp Việc xóa tin nhắn mà không thể phục hồi là bị cấm. Tham gia vào các cuộc trò chuyện nhóm Chế độ định tuyến tin nhắn @@ -1323,7 +1323,7 @@ Mật khẩu hồ sơ Ghi chú riêng tư Cấm xóa tin nhắn mà không thể phục hồi. - ĐỊNH TUYẾN TIN NHẮN RIÊNG TƯ + Định tuyến tin nhắn riêng tư Tên hồ sơ: ảnh đại diện Hồ sơ và các kết nối máy chủ @@ -1581,7 +1581,7 @@ Đặt lại tất cả số liệu thống kê? Lưu mật khẩu và mở kết nối trò chuyện Gửi - KHỞI CHẠY KẾT NỐI TRÒ CHUYỆN + Khởi chạy kết nối trò chuyện Lưu Quét / Dán đường dẫn Quét mã QR máy chủ @@ -1623,7 +1623,7 @@ Lưu lời chào? gửi thất bại Quét mã bảo mật từ ứng dụng của liên hệ bạn. - GỬI CHỈ BÁO ĐÃ NHẬN TỚI + Gửi chỉ báo đã nhận tới Tìm kiếm Tìm kiếm hoặc dán đường dẫn SimpleX Lưu danh sách @@ -1685,7 +1685,7 @@ đặt ảnh đại diện mới Các tin nhắn đã gửi sẽ bị xóa sau thời gian đã cài. thông tin hàng đợi máy chủ: %1$s\n\ntin nhắn được nhận cuối cùng: %2$s - CÀI ĐẶT + Cài đặt Đã gửi vào Địa chỉ máy chủ Mã phiên @@ -1716,7 +1716,7 @@ Đặt mật khẩu Cài đặt Địa chỉ máy chủ không tương thích với cài đặt mạng: %1$s. - CÁC MÁY CHỦ + Các máy chủ Máy chủ yêu cầu xác thực để tải lên, kiểm tra mật khẩu Máy chủ Đặt mã truy cập @@ -1828,7 +1828,7 @@ Loa ngoài bật Âm thanh đã bị tắt Ổn định - PROXY SOCKS + Proxy SOCKS Các nhóm nhỏ (tối đa 20 thành viên) Một vài lỗi không nghiêm trọng đã xảy ra trong lúc nhập: Loa ngoài tắt @@ -1896,7 +1896,7 @@ Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Xin gửi lời cảm ơn tới các người dùng đã góp công qua Weblate! Chế độ hệ thống - HỖ TRỢ SIMPLEX CHAT + Hỗ trợ SimpleX Chat Lỗi tệp tạm thời Nhấn nút Kết nối TCP @@ -1947,7 +1947,7 @@ ID của tin nhắn tiếp theo là không chính xác (nhỏ hơn hoặc bằng với cái trước).\nViệc này có thể xảy ra do một vài lỗi hoặc khi kết nối bị xâm phạm. Nỗ lực đổi mật khẩu cơ sở dữ liệu đã không được hoàn thành. Tên thiết bị sẽ được chia sẻ với thiết bị di động đã được kết nối. - CÁC CHỦ ĐỀ + Các chủ đề Tên hiển thị này không hợp lệ. Xin vui lòng chọn một cái tên khác. Hồ sơ chỉ được chia sẻ với các liên hệ của bạn. Cuộc trò chuyện này được bảo vệ bằng mã hóa đầu cuối có kháng lượng tử. @@ -2196,7 +2196,7 @@ Bạn có thể thay đổi nói trong cài đặt Giao diện. Bạn đang tham gia nhóm thông qua đường dẫn này. Bạn có thể bật vào lúc sau thông qua Cài đặt - BẠN + Bạn Bạn có thể chia sẻ một đường dẫn hoặc mã QR - bất kỳ ai cũng sẽ có thể tham gia nhóm. Bạn sẽ không mất các thành viên của nhóm nếu sau này bạn xóa nó đi. Bạn có thể thử một lần nữa. Bạn đã kết nối tới máy chủ dùng để nhận tin nhắn từ liên hệ này. 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 1392d7b42b..ff69251bc6 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 @@ -754,7 +754,7 @@ 下一代私密通讯软件 粘贴你收到的链接 已跳过消息 - 支持 SIMPLEX CHAT + 支持 SimpleX Chat 发送链接预览 SOCKS 代理 停止聊天程序? 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 9ec116058a..9e51b9ba9d 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 @@ -668,7 +668,7 @@ 裝置 幫助 設定 - 幫助 SIMPLEX CHAT + 幫助 SimpleX Chat 聊天 開發者工具 SOCKS 代理伺服器 diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt index 136a883035..ba8901793f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt @@ -23,6 +23,7 @@ import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.* +import java.awt.Frame import java.awt.event.WindowEvent import java.awt.event.WindowFocusListener import java.io.File @@ -31,8 +32,11 @@ import kotlin.system.exitProcess val simplexWindowState = SimplexWindowState() fun showApp() { - val closedByError = mutableStateOf(true) - while (closedByError.value) { + // Probe SystemTray off the EDT — the lazy's first read would otherwise block the + // EDT during composition; JDK-8322750's GNOME detection forks a subprocess. + trayIsAvailable + while (true) { + val closedByError = mutableStateOf(false) application(exitProcessOnExit = false) { CompositionLocalProvider( LocalWindowExceptionHandlerFactory provides WindowExceptionHandlerFactory { window -> @@ -43,8 +47,9 @@ fun showApp() { shareText = true ) Log.e(TAG, "App crashed, thread name: " + Thread.currentThread().name + ", exception: " + e.stackTraceToString()) - window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) + // Must precede dispatchEvent — handleCloseRequest reads this flag. closedByError.value = true + window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) includeMoreFailedComposables() // If the left side of screen has open modal, it's probably caused the crash if (ModalManager.start.hasModalsOpen()) { @@ -73,9 +78,11 @@ fun showApp() { } } ) { + SimplexTray() AppWindow(closedByError) } } + if (!closedByError.value) break } exitProcess(0) } @@ -115,7 +122,7 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState) { simplexWindowState.windowState = windowState // Reload all strings in all @Composable's after language change at runtime if (remember { ChatController.appPrefs.appLanguage.state }.value != "") { - Window(state = windowState, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { closedByError.value = false; exitApplication() }, onKeyEvent = { + Window(state = windowState, visible = simplexWindowState.windowVisible.value, icon = painterResource(MR.images.ic_simplex), onCloseRequest = { handleCloseRequest(closedByError) }, onKeyEvent = { if (it.key == Key.Escape && it.type == KeyEventType.KeyUp) { simplexWindowState.backstack.lastOrNull()?.invoke() != null } else { @@ -224,6 +231,41 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState) { } } +// Not invoked for macOS Cmd+Q — that goes through AWT's default QuitHandler and +// exits the process directly. Intentional: Cmd+Q is canonical "always quit" on macOS. +private fun ApplicationScope.handleCloseRequest(closedByError: MutableState) { + // Crash dispatch — bypass user-facing policy and exit; outer loop will restart. + if (closedByError.value) { + exitApplication() + return + } + val pref = ChatController.appPrefs.closeBehavior + when (pref.get()) { + CloseBehavior.Quit -> exitApplication() + CloseBehavior.MinimizeToTray -> if (trayIsAvailable && singleInstanceLock) { + simplexWindowState.windowVisible.value = false + } else exitApplication() + CloseBehavior.Ask -> if (trayIsAvailable && singleInstanceLock) { + requestCloseBehavior() + } else { + // Tray unavailable — Minimize is not a real option; remember Quit and exit. + pref.set(CloseBehavior.Quit) + exitApplication() + } + } +} + +fun showWindow() { + simplexWindowState.windowVisible.value = true + simplexWindowState.window?.apply { + // Clear ICONIFIED so a minimized window un-minimizes; preserves MAXIMIZED_BOTH + // when set. toFront() alone does not un-minimize on any AWT platform. + extendedState = extendedState and Frame.ICONIFIED.inv() + toFront() + requestFocus() + } +} + class SimplexWindowState { lateinit var windowState: WindowState val backstack = mutableStateListOf<() -> Unit>() @@ -232,6 +274,7 @@ class SimplexWindowState { val saveDialog = DialogState() val toasts = mutableStateListOf>() var windowFocused = mutableStateOf(true) + val windowVisible = mutableStateOf(true) var window: ComposeWindow? = null } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt new file mode 100644 index 0000000000..9f75e481f4 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt @@ -0,0 +1,121 @@ +package chat.simplex.common + +import SectionItemView +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.window.* +import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.CloseBehavior +import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.ui.theme.isInDarkTheme +import chat.simplex.common.views.helpers.AlertManager +import chat.simplex.common.views.helpers.generalGetString +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.awt.AWTException +import java.awt.SystemTray +import java.awt.TrayIcon +import java.awt.image.BufferedImage + +// Probed once at startup. False on stock GNOME ≥ JDK 21.0.3 per JDK-8322750, and +// also when SystemTray.add() fails despite isSupported() returning true (an older +// JDK pattern Compose-MP does not catch). When false: the Appearance toggle is +// hidden, the first-close dialog is skipped (Ask migrates silently to Quit), and +// the close handler treats MinimizeToTray as Quit. +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe) + tray.remove(probe) + true + } catch (e: AWTException) { + Log.w(TAG, "SystemTray probe failed: ${e.stackTraceToString()}") + false + } catch (e: SecurityException) { + Log.w(TAG, "SystemTray probe denied: ${e.stackTraceToString()}") + false + } +} + +@Composable +fun ApplicationScope.SimplexTray() { + if (!trayIsAvailable) return + if (remember { appPrefs.closeBehavior.state }.value != CloseBehavior.MinimizeToTray) return + // Sum of per-profile unread (UserInfo.unreadCount, the same field UserPicker renders + // per row). Skip muted profiles unless they're the active one. + val unread by remember { + derivedStateOf { + ChatModel.users.sumOf { + if (!it.user.showNtfs && !it.user.activeUser) 0 else it.unreadCount + } + } + } + val iconRes = if (unread > 0) { + if (isInDarkTheme()) MR.images.ic_simplex_tray_dot_light else MR.images.ic_simplex_tray_dot + } else { + if (isInDarkTheme()) MR.images.ic_simplex_tray_light else MR.images.ic_simplex + } + val tooltip = + if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread) + else stringResource(MR.strings.tray_tooltip) + Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() }) + } + ) +} + +// Renders in the main app window via AlertManager (same surface as e.g. the link +// previews confirmation). Lambdas close over the calling ApplicationScope; if the +// app crashes while the dialog is open, the crash handler's alert replaces it, so +// stale closures never get clicked. +fun ApplicationScope.requestCloseBehavior() { + val pref = appPrefs.closeBehavior + AlertManager.shared.showAlertDialogButtonsColumn( + title = generalGetString(MR.strings.close_behavior_dialog_title), + text = AnnotatedString(generalGetString(MR.strings.close_behavior_dialog_text)), + buttons = { + Column { + SectionItemView({ + AlertManager.shared.hideAlert() + pref.set(CloseBehavior.Quit) + exitApplication() + }) { + Text( + stringResource(MR.strings.close_behavior_dialog_close), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = Color.Red + ) + } + SectionItemView({ + AlertManager.shared.hideAlert() + pref.set(CloseBehavior.MinimizeToTray) + simplexWindowState.windowVisible.value = false + }) { + Text( + stringResource(MR.strings.close_behavior_dialog_minimize), + Modifier.fillMaxWidth(), + textAlign = TextAlign.Center, + color = MaterialTheme.colors.primary + ) + } + } + } + ) +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt new file mode 100644 index 0000000000..19cb7aea91 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/SingleInstance.kt @@ -0,0 +1,133 @@ +package chat.simplex.common + +import chat.simplex.common.platform.Log +import chat.simplex.common.platform.TAG +import chat.simplex.common.platform.dataDir +import java.io.IOException +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.nio.file.* +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import javax.swing.SwingUtilities +import kotlin.concurrent.thread + +private var lockHandle: FileLock? = null +private var watcher: WatchService? = null + +private val lockPath get() = dataDir.resolve("simplex.started").toPath() +private val showPath get() = dataDir.resolve("simplex.show").toPath() + +var singleInstanceLock = false + private set + +private sealed interface LockResult { + class Acquired(val lock: FileLock) : LockResult + object Taken : LockResult + object Failed : LockResult +} + +fun acquireSingleInstance(): Boolean { + dataDir.mkdirs() + when (val result = tryAcquireLock()) { + is LockResult.Acquired -> { + lockHandle = result.lock + singleInstanceLock = true + deleteShowFile() + startShowFileWatcher() + return true + } + LockResult.Failed -> { + return true + } + LockResult.Taken -> { + // Ensure the signal file exists (createShowFile is a no-op if it does) + // and wait up to 1s for the primary's watcher to consume it. If still + // there after the wait, the primary is hung — let the user decide. + createShowFile() + val deadline = System.currentTimeMillis() + 1000 + while (Files.exists(showPath) && System.currentTimeMillis() < deadline) { + try { Thread.sleep(50) } catch (_: InterruptedException) { break } + } + if (!Files.exists(showPath)) return false + val start = showSingleInstanceAlert() + if (start) deleteShowFile() + return start + } + } +} + +private fun tryAcquireLock(): LockResult { + val channel = try { + FileChannel.open(lockPath, READ, WRITE, CREATE) + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot open lock file: ${e.message}") + return LockResult.Failed + } + return try { + val lock = channel.tryLock(0L, 1L, false) + if (lock != null) { + LockResult.Acquired(lock) + } else { + channel.close() + LockResult.Taken + } + } catch (_: OverlappingFileLockException) { + Log.w(TAG, "single-instance: overlapping lock in same JVM") + LockResult.Failed + } catch (e: IOException) { + Log.w(TAG, "single-instance: tryLock failed: ${e.message}") + channel.close(); LockResult.Failed + } +} + +private fun deleteShowFile() { + try { Files.deleteIfExists(showPath) } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot delete show file: ${e.message}") + } +} + +private fun createShowFile() { + try { Files.createFile(showPath) } catch (_: FileAlreadyExistsException) { + // Another duplicate already signalled; primary will pick it up. + } catch (e: IOException) { + Log.w(TAG, "single-instance: cannot create show file: ${e.message}") + } +} + +private fun showSingleInstanceAlert(): Boolean { + val title = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_title) + val message = chat.simplex.common.views.helpers.generalGetString(chat.simplex.res.MR.strings.another_instance_not_responding) + val result = javax.swing.JOptionPane.showConfirmDialog( + null, message, title, + javax.swing.JOptionPane.YES_NO_OPTION, + javax.swing.JOptionPane.WARNING_MESSAGE + ) + return result == javax.swing.JOptionPane.YES_OPTION +} + +private fun startShowFileWatcher() { + if (watcher != null) return + val ws = try { + dataDir.toPath().fileSystem.newWatchService() + } catch (e: IOException) { + Log.w(TAG, "single-instance: WatchService failed: ${e.message}") + return + } + dataDir.toPath().register(ws, StandardWatchEventKinds.ENTRY_CREATE) + watcher = ws + thread(name = "simplex-single-instance", isDaemon = true) { + while (true) { + val key = try { ws.take() } catch (_: ClosedWatchServiceException) { return@thread } catch (_: InterruptedException) { return@thread } + for (event in key.pollEvents()) { + if ((event.context() as? Path)?.fileName?.toString() == "simplex.show") { + deleteShowFile() + SwingUtilities.invokeLater { showWindow() } + } + } + if (!key.reset()) return@thread + } + } +} 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 ed2f6e7859..20fe6a48a3 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 @@ -17,6 +17,7 @@ import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse import org.nanohttpd.protocols.http.response.Status import org.nanohttpd.protocols.websockets.* import java.io.IOException +import java.net.BindException import java.net.URI private const val SERVER_HOST = "localhost" @@ -157,17 +158,18 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( if (call != null) withBGApi { chatModel.callManager.endCall(call) } } val server = remember { - try { - uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") - } catch (e: Exception) { - Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") - AlertManager.shared.showAlertMsg( - title = generalGetString(MR.strings.unable_to_open_browser_title), - text = generalGetString(MR.strings.unable_to_open_browser_desc) - ) - endCall() + startServer(onResponse).apply { + try { + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + } catch (e: Exception) { + Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unable_to_open_browser_title), + text = generalGetString(MR.strings.unable_to_open_browser_desc) + ) + endCall() + } } - startServer(onResponse) } fun processCommand(cmd: WCallCommand) { val apiCall = WVAPICall(command = cmd) @@ -206,8 +208,8 @@ fun WebRTCController(callCommand: SnapshotStateList, onResponse: ( } } -fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { - val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { +fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, port) { override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session) fun resourcesToResponse(path: String): Response { @@ -231,7 +233,14 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { } } } - server.start(60_000_000) + try { + server.start(60_000_000) + } catch (e: BindException) { + if (port == 0) throw e + Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") + server.stop() + return startServer(onResponse, port = 0) + } return server } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index a1f70213d0..c875c0c9da 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -1,6 +1,5 @@ package chat.simplex.common.views.chatlist -import SectionDivider import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* @@ -62,6 +61,6 @@ actual fun ChatListNavLinkLayout( if (selectedChat.value || nextChatSelected.value) { Divider() } else { - SectionDivider() + Divider(Modifier.padding(horizontal = 8.dp)) } } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt index c270bddb73..6b36a3b1b2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt @@ -1,9 +1,11 @@ package chat.simplex.common.views.usersettings +import CARD_PADDING import SectionBottomSpacer import SectionDividerSpaced -import SectionSpacer +import SectionTextFooter import SectionView +import itemHPadding import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -18,9 +20,11 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel +import chat.simplex.common.model.CloseBehavior import chat.simplex.common.model.SharedPreference +import chat.simplex.common.trayIsAvailable import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.DEFAULT_PADDING +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.stringResource @@ -65,6 +69,11 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ThemesSection(systemDarkTheme) + if (trayIsAvailable) { + SectionDividerSpaced() + MinimizeToTraySection() + } + SectionDividerSpaced() AppToolbarsSection() @@ -74,21 +83,36 @@ fun AppearanceScope.AppearanceLayout( SectionDividerSpaced() ProfileImageSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() FontScaleSection() - SectionDividerSpaced(maxTopPadding = true) + SectionDividerSpaced() DensityScaleSection() SectionBottomSpacer() } } +@Composable +private fun MinimizeToTraySection() { + val pref = remember { appPrefs.closeBehavior.state } + val on = pref.value == CloseBehavior.MinimizeToTray + SectionView { + PreferenceToggle( + stringResource(MR.strings.appearance_minimize_to_tray), + checked = on, + ) { checked -> + appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit) + } + } + SectionTextFooter(stringResource(MR.strings.appearance_minimize_to_tray_desc)) +} + @Composable fun DensityScaleSection() { val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) } - SectionView(stringResource(MR.strings.appearance_zoom).uppercase(), contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) { - Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) { + SectionView(stringResource(MR.strings.appearance_zoom), contentPadding = PaddingValues(horizontal = CARD_PADDING)) { + Row(Modifier.padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { Box(Modifier.size(50.dp) .background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22)) .clip(RoundedCornerShape(percent = 22)) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt new file mode 100644 index 0000000000..1b495c1774 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/SingleInstanceTest.kt @@ -0,0 +1,56 @@ +package chat.simplex.app + +import java.nio.channels.FileChannel +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.READ +import java.nio.file.StandardOpenOption.WRITE +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull + +class SingleInstanceTest { + @Test + fun overlappingLockOnSameRegionThrowsWithinOneJvm() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock, "first acquirer must get the lock") + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + assertFailsWith { + second.tryLock(0L, 1L, false) + } + second.close() + firstLock.release() + first.close() + } + + @Test + fun releasedLockCanBeReacquired() = withTempDir { dir -> + val lockPath = dir.resolve("simplex.started") + val first = FileChannel.open(lockPath, READ, WRITE, CREATE) + val firstLock = first.tryLock(0L, 1L, false) + assertNotNull(firstLock) + firstLock.release() + first.close() + + val second = FileChannel.open(lockPath, READ, WRITE, CREATE) + val secondLock = second.tryLock(0L, 1L, false) + assertNotNull(secondLock, "after release, a fresh acquirer must succeed") + secondLock.release() + second.close() + } + + private fun withTempDir(block: (java.nio.file.Path) -> Unit) { + val tmp = Files.createTempDirectory("simplex-singleinstance-test") + try { + block(tmp) + } finally { + Files.walk(tmp).sorted(Comparator.reverseOrder()).forEach { + try { Files.delete(it) } catch (_: java.io.IOException) {} + } + } + } +} diff --git a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt index 0e8a452e08..338660b746 100644 --- a/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt +++ b/apps/multiplatform/desktop/src/jvmMain/kotlin/chat/simplex/desktop/Main.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.* import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.* +import chat.simplex.common.acquireSingleInstance import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.size import chat.simplex.common.platform.* @@ -19,6 +20,7 @@ import kotlinx.coroutines.* import java.io.File fun main() { + if (!acquireSingleInstance()) return // Disable hardware acceleration //System.setProperty("skiko.renderApi", "SOFTWARE") initHaskell() diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 09cf90553f..4d504e069e 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.1 -android.version_code=347 +android.version_name=6.5.2 +android.version_code=349 android.bundle=false -desktop.version_name=6.5.1 -desktop.version_code=142 +desktop.version_name=6.5.2 +desktop.version_code=143 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index da33bf11d7..5d707cf832 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -49,6 +49,7 @@ This document provides a structured mapping between product-level concepts, thei | 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` | +| PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | **Legend for abbreviated paths:** - `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` diff --git a/apps/multiplatform/product/views/group-info.md b/apps/multiplatform/product/views/group-info.md index 65b068adc8..2335de7178 100644 --- a/apps/multiplatform/product/views/group-info.md +++ b/apps/multiplatform/product/views/group-info.md @@ -130,6 +130,30 @@ Shown when `developerTools` preference is enabled: Business chats use alternative labels: "Delete chat" instead of "Delete group". +### Channel Relays View (`ChannelRelaysView`) + +Accessible from channel info; shows relay members (role == `Relay`): + +| Element | Description | +|---|---| +| Relay list | Filtered from `chatModel.groupMembers` by `Relay` role; excludes `MemRemoved` and `MemGroupDeleted` | +| Relay row | Profile image, relay display name, status text (`RelayStatus.text` or connection status via `relayConnStatus`) | +| Relay tap | Navigates to `GroupMemberInfoView` with `groupRelay:` parameter | +| Add relay entry | Owner-only "Add relay" action opens `AddGroupRelayView`; the available-to-add list excludes any `chatRelayId` already present in `groupRelays` (regardless of `relayStatus`), so inactive or rejected relays cannot be re-added without first removing them via the row's long-press menu | +| Long-press menu | Owner-only "Remove relay" action for relays that can be removed | +| 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 Member Info — relay surface (in `GroupMemberInfoView`) + +| Element | Description | +|---|---| +| 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 | +| Status row (rejected) | Shown when `groupRelay?.relayStatus == RelayStatus.RsRejected`: "Status: rejected by relay operator". The relay rejected the invitation to rejoin this channel after a prior `/leave`; the owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left. Clearable only by the relay operator running `/group allow #`. | + ## Source Files | File | Path | @@ -143,3 +167,6 @@ Business chats use alternative labels: "Delete chat" instead of "Delete group". | `WelcomeMessageView.kt` | `views/chat/group/WelcomeMessageView.kt` | | `MemberAdmission.kt` | `views/chat/group/MemberAdmission.kt` | | `MemberSupportView.kt` | `views/chat/group/MemberSupportView.kt` | +| `ChannelRelaysView.kt` | `views/chat/group/ChannelRelaysView.kt` | +| `AddGroupRelayView.kt` | `views/chat/group/AddGroupRelayView.kt` | +| `AddChannelView.kt` (`RelayStatusIndicator`) | `views/newchat/AddChannelView.kt` | diff --git a/apps/multiplatform/spec/api.md b/apps/multiplatform/spec/api.md index 15d5e141a0..4114e9de4f 100644 --- a/apps/multiplatform/spec/api.md +++ b/apps/multiplatform/spec/api.md @@ -352,6 +352,7 @@ Events handled in `processReceivedMsg` include: | `DeletedMember` / `DeletedMemberUser` | A member was removed | | `LeftMember` | A member left voluntarily | | `GroupUpdated` | Group profile changed | +| `GroupRelayUpdated` | Owner-side: a relay's `relayStatus` and/or the member's status changed. Fires on `XGrpRelayReject` with `relayStatus = RsRejected` and `GroupMember.memberStatus = MemLeft` — final on owner side until cleared by the relay operator's `/group allow #` (no event emitted to the owner for that clear). | | `MemberRole` | A member's role changed | | `MemberBlockedForAll` | A member was blocked for all | | `RcvFileStart` / `RcvFileComplete` / `RcvFileError` | File receive progress | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 2819b1e751..728ace4936 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -322,3 +322,11 @@ Key sections: group profile, group link, member list with roles, group preferenc | `MemberSupportChatView.kt` | Member support chat (scoped context) | | `MemberSupportView.kt` | Support chat list for moderators | | `WelcomeMessageView.kt` | Group welcome message editor | +| `ChannelRelaysView.kt` | Channel relay list. Owner-only Add relay entry opens `AddGroupRelayView` with `existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet()` — every relay currently in `groupRelays` is excluded regardless of `relayStatus`, mirroring the backend `APIAddGroupRelays` gate. Long-press menu offers Remove relay for relays that can be removed. | +| `AddGroupRelayView.kt` | Sheet to pick relays to add to a channel | + +### Relay Rejection Surface + +When a relay operator runs `/leave #channel`, the relay sends `x.grp.relay.reject` over the owner-relay direct contact channel. Owner-side handling: the corresponding `GroupRelay.relayStatus` transitions `RSInvited → RSRejected`; the relay's `GroupMember.memberStatus` is set to `MemLeft` so the owner UI renders the rejected relay identically to one that explicitly ran `/leave` (`MemRejected` is reserved for the knocking-admission flow). In `GroupMemberInfoView`, an additional "Status: rejected by relay operator" `InfoRow` appears when `groupRelay?.relayStatus == RelayStatus.RsRejected`. The status is final on the owner side — clearable only by the relay operator running `/group allow #`, which has no owner-facing event. + +The `RelayStatusIndicator` composable in `AddChannelView.kt` renders `RsRejected` with a red dot and "rejected" text, matching the `connFailed`/`removed` rendering. diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index cd0f836585..f808cf31ba 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -40,6 +40,7 @@ | PC28 | Chat Tags | | PC29 | User Address | | PC30 | Member Support Chat | +| PC31 | Channels (Relays) | --- @@ -51,13 +52,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `App.kt` | PC1 through PC30 | High | Root composable — navigation scaffold for all features | +| `App.kt` | PC1 through PC31 | 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/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC31 | 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/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC31 | 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 | @@ -67,7 +68,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `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/Platform.kt` | PC1 through PC31 | 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 | @@ -160,7 +161,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` |-------------|--------------------------|------------|-------| | `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/GroupMemberInfoView.kt` | PC3, PC14, PC16, PC30, PC31 | Medium | Member details and role management; relay-address + rejected-status info rows | +| `views/chat/group/ChannelRelaysView.kt` | PC31 | Medium | Channel relay list, add/remove entries | +| `views/chat/group/AddGroupRelayView.kt` | PC31 | Low | Add relay sheet | | `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 | @@ -189,6 +192,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `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/AddChannelView.kt` | PC31 | Medium | Public channel creation, channel link card, `RelayStatusIndicator` | | `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 | @@ -264,9 +268,9 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | 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/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC31 | 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 | @@ -319,8 +323,8 @@ 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 | +| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC31 | 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 | @@ -334,7 +338,7 @@ 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/AppCommon.android.kt` | PC1 through PC31 | 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 | @@ -400,7 +404,7 @@ 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 | +| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch | ### 3.2 Desktop Platform Implementations (desktopMain) @@ -411,7 +415,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `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/AppCommon.desktop.kt` | PC1 through PC31 | 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 | @@ -473,13 +477,13 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | 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.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here | +| `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 (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | 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 | @@ -489,8 +493,8 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `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.hs` | PC1 through PC31 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | 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 | @@ -519,11 +523,11 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `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.hs` | PC1 through PC31 | 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/Shared.hs` | PC1 through PC31 | 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/View.hs` | PC1 through PC31 | 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) | diff --git a/apps/multiplatform/spec/services/calls.md b/apps/multiplatform/spec/services/calls.md index a8d056ebea..bea1d37f3a 100644 --- a/apps/multiplatform/spec/services/calls.md +++ b/apps/multiplatform/spec/services/calls.md @@ -119,9 +119,9 @@ The `actual` platform implementation of `ActiveCallView()` and supporting compos 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/`. +- **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`. If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL. 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. +- **WebRTCController** ([line 153](../../common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt#L153)): Starts the server, then opens `http://localhost:/simplex/call/` (normally `50395`) 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. diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 900d6593ab..09457c4dd3 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -300,6 +300,21 @@ data class ChatStats( | `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | | `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | +### RelayStatus (Channels) + +`RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`. + +| Case | SerialName | Meaning | +|---|---|---| +| `RsNew` | `"new"` | Allocated locally; not yet sent | +| `RsInvited` | `"invited"` | `XGrpRelayInv` sent, awaiting `XGrpRelayAcpt` | +| `RsAccepted` | `"accepted"` | Accepted, link-data update pending | +| `RsActive` | `"active"` | Listed in channel link data; forwarding | +| `RsInactive` | `"inactive"` | No longer in link data or backend reports it removed | +| `RsRejected` | `"rejected"` | Relay sent `XGrpRelayReject` for the channel link; final on the owner side. Clearable only by the relay operator running `/group allow #`. The owner-side `GroupMember.memberStatus` is also set to `MemLeft` so the relay renders identically to one that explicitly left (`MemRejected` is reserved for the knocking-admission flow). | + +The `text` extension on the enum returns the localized status string (resource key `relay_status_*`, with `relay_status_rejected` = "rejected"). + --- diff --git a/apps/simplex-support-bot/bot.test.ts b/apps/simplex-support-bot/bot.test.ts index de787ae4cc..a1afd7a660 100644 --- a/apps/simplex-support-bot/bot.test.ts +++ b/apps/simplex-support-bot/bot.test.ts @@ -1,9 +1,13 @@ import {describe, test, expect, beforeEach, vi} from "vitest" +import {mkdtempSync, writeFileSync} from "fs" +import {tmpdir} from "os" +import {join} from "path" import {core} from "simplex-chat" import {SupportBot} from "./src/bot.js" import {CardManager} from "./src/cards.js" import {parseConfig} from "./src/config.js" import {GrokApiClient} from "./src/grok.js" +import {loadGrokContext} from "./src/context.js" import {welcomeMessage, queueMessage, grokActivatedMessage, teamLockedMessage, teamAlreadyInvitedMessage} from "./src/messages.js" // Silence console output during tests @@ -212,13 +216,15 @@ const GROK_LOCAL_GROUP_ID = 200 const CUSTOMER_ID = "customer-1" // Commands passed into SupportBot; matches what index.ts constructs when -// Grok is enabled. Tests that disable grokApi still pass the full list -// because the ctor doesn't care; the value is pushed to a group's -// groupPreferences on the first sendToGroup() call. +// Grok is enabled. The ctor uses this to decide which `/keyword` messages +// from customers are commands vs. plain text — tests that disable grokApi +// should pass a list that excludes "grok" to mirror production wiring (see +// index.ts where `grokEnabled` gates that entry). const DESIRED_COMMANDS = [ {type: "command" as const, keyword: "grok", label: "Ask Grok"}, {type: "command" as const, keyword: "team", label: "Switch to team"}, ] +const DESIRED_COMMANDS_NO_GROK = [DESIRED_COMMANDS[1]] // ─── Member factories ─── @@ -729,6 +735,28 @@ describe("Grok Conversation", () => { expect(grokApi.calls.length).toBe(0) }) + test("Grok answers messages containing a slash mid-word", async () => { + // Regression: an unanchored regex in ciBotCommand once parsed `/read` + // inside "follow/read" as a command, causing Grok to skip the message. + grokApi.willRespond("We post on X and Mastodon.") + await bot.onGrokNewChatItems(grokViewCustomerMessage( + "What social media do you use? Anything I can follow/read for updates?" + )) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe( + "What social media do you use? Anything I can follow/read for updates?" + ) + }) + + test("Grok answers an unknown slash-prefixed message", async () => { + // `/help` is not in desiredCommands, so it should be treated as plain + // text and reach Grok rather than being silently dropped. + grokApi.willRespond("Sure, here's what I can do.") + await bot.onGrokNewChatItems(grokViewCustomerMessage("/help me with groups")) + expect(grokApi.calls.length).toBe(1) + expect(grokApi.calls[0].message).toBe("/help me with groups") + }) + test("Grok per-message: history includes prior Grok sent response as assistant", async () => { addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) addBotMessage("To create a group, tap + then New Group.", GROK_LOCAL_GROUP_ID) @@ -866,6 +894,52 @@ describe("Grok Conversation", () => { }) }) +describe("Grok requests /team", () => { + beforeEach(() => setup()) + + test("Grok per-message reply containing /team → team added, teamAddedMessage sent, reply still sent", async () => { + await reachGrok() + await bot.flush() + grokApi.willRespond("I can't help with billing — please send /team for a human.") + addCustomerMessageToHistory("Can you refund me?", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("Can you refund me?")) + + expectAnySent("I can't help with billing") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) + + test("Grok per-message reply without /team → no team members added", async () => { + await reachGrok() + await bot.flush() + grokApi.willRespond("To create a group, tap +, then New Group.") + addCustomerMessageToHistory("How do I create a group?", GROK_LOCAL_GROUP_ID) + await bot.onGrokNewChatItems(grokViewCustomerMessage("How do I create a group?")) + + expect(chat.added.some(a => a.groupId === CUSTOMER_GROUP_ID && a.contactId === TEAM_MEMBER_1_ID)).toBe(false) + }) + + test("/team in Grok's initial reply after /grok → escalates", async () => { + await reachQueue() + addBotMessage("The team will reply to your message") + // Customer's question visible in Grok's view → activateGrok reads it for the initial reply + chat.groups.set(GROK_LOCAL_GROUP_ID, makeGroupInfo(GROK_LOCAL_GROUP_ID)) + addCustomerMessageToHistory("I'm really stuck, please help", GROK_LOCAL_GROUP_ID) + grokApi.willRespond("That sounds urgent — send /team to reach a person.") + + const grokJoinPromise = simulateGrokJoinSuccess() + await bot.onNewChatItems(customerMessage("/grok")) + await grokJoinPromise + await bot.flush() + + expectAnySent("That sounds urgent") + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_1_ID) + expectMemberAdded(CUSTOMER_GROUP_ID, TEAM_MEMBER_2_ID) + expectSentToGroup(CUSTOMER_GROUP_ID, "We will reply within") + }) +}) + describe("/team Activation", () => { beforeEach(() => setup()) @@ -973,6 +1047,17 @@ describe("One-Way Gate with Grok Disabled", () => { // Grok should not respond (grokApi is null) expect(grokApi.calls.length).toBe(0) }) + + test("Grok disabled: customer /grok is treated as text and queued", async () => { + // When Grok is disabled, index.ts excludes "grok" from desiredCommands, + // so /grok from a customer parses as an unknown command → routed as + // plain text → first-message-in-WELCOME transitions to QUEUE. + setup() + bot = new SupportBot(chat as any, null, config as any, MAIN_USER_ID, null, DESIRED_COMMANDS_NO_GROK) + bot.cards = cards + await bot.onNewChatItems(customerMessage("/grok")) + expectSentToGroup(CUSTOMER_GROUP_ID, "The team will reply to your message") + }) }) describe("Team Member Lifecycle", () => { @@ -2427,7 +2512,7 @@ describe("GrokApiClient HTTP timeout", () => { new Response(JSON.stringify({choices: [{message: {content: "ok"}}]}), {status: 200}), ) - const client = new GrokApiClient("test-key", "system prompt") + const client = new GrokApiClient("test-key", [{role: "system", content: "system prompt"}]) await client.chat([], "hello") expect(timeoutSpy).toHaveBeenCalledWith(60_000) @@ -2504,3 +2589,118 @@ describe("Command sync in sendToGroup", () => { expect(prefs.reactions).toEqual({enable: "on"}) }) }) + +// loadGrokContext: documented behavior is "plain text → single system +// message". A `.yaml` / `.yml` extension is an undocumented alternative +// that parses the harness transcript format and surfaces only `system` +// and `assistant` turns; `user` entries are dropped so they don't merge +// with the customer's runtime message. +describe("loadGrokContext", () => { + const dir = mkdtempSync(join(tmpdir(), "support-bot-context-")) + const writeFile = (name: string, content: string): string => { + const p = join(dir, name) + writeFileSync(p, content) + return p + } + + test("plain text (.txt) → single system message with full file content", () => { + const path = writeFile("ctx.txt", "You are Grok.\n\nBe concise.") + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "You are Grok.\n\nBe concise."}, + ]) + }) + + test("no extension → treated as plain text", () => { + const path = writeFile("plain", "raw context") + expect(loadGrokContext(path)).toEqual([{role: "system", content: "raw context"}]) + }) + + test(".md → treated as plain text (does not look like YAML)", () => { + const path = writeFile("ctx.md", "# Heading\n\nbody") + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "# Heading\n\nbody"}, + ]) + }) + + test(".yaml → parses transcript and keeps only system + assistant turns", () => { + const path = writeFile("ctx.yaml", + "- role: system\n message: Be terse.\n" + + "- role: user\n message: What is async?\n" + + "- role: assistant\n message: Cooperative concurrency.\n", + ) + expect(loadGrokContext(path)).toEqual([ + {role: "system", content: "Be terse."}, + {role: "assistant", content: "Cooperative concurrency."}, + ]) + }) + + test(".yml extension also triggers YAML parsing", () => { + const path = writeFile("ctx.yml", + "- role: system\n message: hi\n", + ) + expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}]) + }) + + test("YAML parsing is case-insensitive on extension", () => { + const path = writeFile("ctx.YAML", + "- role: system\n message: hi\n", + ) + expect(loadGrokContext(path)).toEqual([{role: "system", content: "hi"}]) + }) + + test("YAML preserves multi-line literal block scalars verbatim", () => { + const path = writeFile("multiline.yaml", + "- role: assistant\n message: |\n line one\n line two\n", + ) + expect(loadGrokContext(path)).toEqual([ + {role: "assistant", content: "line one\nline two\n"}, + ]) + }) + + test("YAML with only user-role entries → empty array", () => { + const path = writeFile("only-user.yaml", + "- role: user\n message: a\n" + + "- role: user\n message: b\n", + ) + expect(loadGrokContext(path)).toEqual([]) + }) + + test("empty YAML file → empty array", () => { + const path = writeFile("empty.yaml", "") + expect(loadGrokContext(path)).toEqual([]) + }) + + test("YAML non-list top level throws", () => { + const path = writeFile("not-list.yaml", "role: system\nmessage: x\n") + expect(() => loadGrokContext(path)).toThrow(/top-level must be a list/) + }) + + test("YAML entry with unknown role throws", () => { + const path = writeFile("bad-role.yaml", "- role: bogus\n message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/) + }) + + test("YAML entry missing role throws", () => { + const path = writeFile("no-role.yaml", "- message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has invalid role/) + }) + + test("YAML entry with non-string message throws", () => { + const path = writeFile("bad-message.yaml", "- role: user\n message: 42\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 has non-string message/) + }) + + test("YAML entry that is not a mapping throws", () => { + const path = writeFile("bad-entry.yaml", "- just a string\n- role: user\n message: x\n") + expect(() => loadGrokContext(path)).toThrow(/entry 0 is not a mapping/) + }) + + test("malformed YAML throws", () => { + const path = writeFile("malformed.yaml", "- role: user\n message: [unclosed\n") + expect(() => loadGrokContext(path)).toThrow(/failed to parse YAML/) + }) + + test("missing file throws ENOENT", () => { + expect(() => loadGrokContext(join(dir, "does-not-exist.yaml"))).toThrow() + }) +}) diff --git a/apps/simplex-support-bot/package-lock.json b/apps/simplex-support-bot/package-lock.json index 1569c18309..eddbcb2dff 100644 --- a/apps/simplex-support-bot/package-lock.json +++ b/apps/simplex-support-bot/package-lock.json @@ -9,10 +9,11 @@ "version": "0.1.0", "license": "AGPL-3.0", "dependencies": { - "@simplex-chat/types": "^0.5.0", + "@simplex-chat/types": "^0.6.0", "async-mutex": "^0.5.0", "commander": "^14.0.3", - "simplex-chat": "^6.5.0" + "simplex-chat": "^6.5.1", + "yaml": "^2.8.4" }, "devDependencies": { "@types/node": "^22.0.0", @@ -782,9 +783,9 @@ ] }, "node_modules/@simplex-chat/types": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.5.0.tgz", - "integrity": "sha512-f680CRlf+O8WfIaPb7wxVj3PB8mTIOE+HqmetCSe0NBheVAjU3ovg3+zkrWwDlavrHuCLbb7Gmeu4HyNtjDfog==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@simplex-chat/types/-/types-0.6.0.tgz", + "integrity": "sha512-QVYvaRsS6TnS+IROjNkekZYvhvy8QoA8vKCuGe9E6lplXDVutJo9tdDOSWS9NDdtwxT1wRZ29zN4xEZEEG/NHw==", "license": "AGPL-3.0", "dependencies": { "typescript": "^5.9.2" @@ -1675,13 +1676,13 @@ } }, "node_modules/simplex-chat": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.0.tgz", - "integrity": "sha512-QFGI734HhYJ7trSrEKiZ2mbodI0V8CLDGEv2+yt5zsg0FqftxSpFik6zUSezTRZtN1M8WmSlT44qlEt2a1fXQw==", + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/simplex-chat/-/simplex-chat-6.5.1.tgz", + "integrity": "sha512-1cv91iMCqtP+9R1PQM3NKazocoUInKT2/06pIOuzORD5/VzulR6cMZnEQmaT02Jz+8FVkEKTsaAIJfVP6/tJmw==", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { - "@simplex-chat/types": "^0.5.0", + "@simplex-chat/types": "^0.6.0", "extract-zip": "^2.0.1", "fast-deep-equal": "^3.1.3", "node-addon-api": "^8.5.0" @@ -1995,6 +1996,21 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yauzl": { "version": "2.10.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", diff --git a/apps/simplex-support-bot/package.json b/apps/simplex-support-bot/package.json index 0b8a3e25d1..97caee2278 100644 --- a/apps/simplex-support-bot/package.json +++ b/apps/simplex-support-bot/package.json @@ -8,10 +8,11 @@ "start": "node dist/index.js" }, "dependencies": { - "@simplex-chat/types": "^0.6.0", + "@simplex-chat/types": "^0.7.0", "async-mutex": "^0.5.0", "commander": "^14.0.3", - "simplex-chat": "^6.5.1" + "simplex-chat": "^6.5.2", + "yaml": "^2.8.4" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/apps/simplex-support-bot/src/bot.ts b/apps/simplex-support-bot/src/bot.ts index 553602712b..9b534381de 100644 --- a/apps/simplex-support-bot/src/bot.ts +++ b/apps/simplex-support-bot/src/bot.ts @@ -10,6 +10,22 @@ import { } from "./messages.js" import {profileMutex, log, logError, getGroupInfo} from "./util.js" +// Collects the keyword of every "command" entry in the bot's registered +// commands tree, descending into "menu" entries. Used to distinguish real +// commands from arbitrary text that happens to start with `/` (e.g. URLs, +// "/help" the user invented). +function commandKeywords(commands: T.ChatBotCommand[]): Set { + const out = new Set() + const visit = (cmds: T.ChatBotCommand[]): void => { + for (const c of cmds) { + if (c.type === "command") out.add(c.keyword) + else if (c.type === "menu") visit(c.commands) + } + } + visit(commands) + return out +} + // True for any non-terminal status — invited but not yet accepted, through // connected. Used to decide whether a contact is already in the group so we // don't trigger a re-invite (the SimpleX API resends the invitation for a @@ -62,6 +78,11 @@ export class SupportBot { // send to each group. private syncedGroups = new Set() + // Keywords from desiredCommands. A customer message is treated as a + // command only when its parsed keyword is in this set; anything else + // (URLs, "/help", arbitrary slashes) is routed as plain text. + private readonly customerKeywords: ReadonlySet + constructor( private chat: api.ChatApi, private grokApi: GrokApiClient | null, @@ -71,6 +92,12 @@ export class SupportBot { private desiredCommands: T.ChatBotCommand[], ) { this.cards = new CardManager(chat, config, mainUserId, config.cardFlushSeconds * 1000) + this.customerKeywords = commandKeywords(desiredCommands) + } + + private customerCommand(chatItem: T.ChatItem): util.BotCommand | undefined { + const cmd = util.ciBotCommand(chatItem) + return cmd && this.customerKeywords.has(cmd.keyword) ? cmd : undefined } private get grokEnabled(): boolean { @@ -357,7 +384,7 @@ export class SupportBot { if (chatInfo.type !== "group") continue if (chatItem.chatDir.type !== "groupRcv") continue if (!util.ciContentText(chatItem)?.trim()) continue - if (util.ciBotCommand(chatItem)) continue + if (this.customerCommand(chatItem)) continue const bc = chatInfo.groupInfo.businessChat if (!bc) continue if (chatItem.chatDir.groupMember.memberId !== bc.customerId) continue @@ -444,9 +471,7 @@ export class SupportBot { // 8. Customer message → derive state and dispatch const state = await this.cards.deriveState(groupId) - const rawCmd = util.ciBotCommand(chatItem) - // When Grok is disabled, ignore /grok so it behaves like an unknown command - const cmd = rawCmd?.keyword === "grok" && !this.grokEnabled ? null : rawCmd + const cmd = this.customerCommand(chatItem) const text = util.ciContentText(chatItem)?.trim() || null switch (state) { @@ -547,7 +572,7 @@ export class SupportBot { if (!text) return // ignore non-text // Ignore bot commands - if (util.ciBotCommand(chatItem)) return + if (this.customerCommand(chatItem)) return // Only respond in business groups (survives restart without in-memory maps) const bc = groupInfo.businessChat @@ -569,7 +594,7 @@ export class SupportBot { history.push({role: "assistant", content: histText}) } else if (histCi.chatDir.type === "groupRcv" && histCi.chatDir.groupMember.memberId === bc.customerId - && !util.ciBotCommand(histCi)) { + && !this.customerCommand(histCi)) { history.push({role: "user", content: histText}) } } @@ -587,6 +612,9 @@ export class SupportBot { await this.withGrokProfile(() => this.chat.apiSendTextMessage([T.ChatType.Group, grokGroupId], response) ) + + // Grok asked for the team → escalate as if the customer sent /team + if (mainGroupId !== undefined && response.includes("/team")) await this.activateTeam(mainGroupId) } catch (err) { logError(`Grok per-message error for grokGroup ${grokGroupId}`, err) try { @@ -706,7 +734,7 @@ export class SupportBot { if (ci.chatDir.type !== "groupRcv") continue if (!grokBc || ci.chatDir.groupMember.memberId !== grokBc.customerId) continue const t = util.ciContentText(ci)?.trim() - if (t && !util.ciBotCommand(ci)) customerMessages.push(t) + if (t && !this.customerCommand(ci)) customerMessages.push(t) } if (customerMessages.length === 0) { @@ -722,6 +750,9 @@ export class SupportBot { await this.withGrokProfile(() => this.chat.apiSendTextMessage([T.ChatType.Group, grokLocalGId], response) ) + + // Grok asked for the team → escalate as if the customer sent /team + if (response.includes("/team")) await this.activateTeam(groupId) } catch (err) { logError(`Grok initial response failed for group ${groupId}`, err) await this.sendToGroup(groupId, grokUnavailableMessage) diff --git a/apps/simplex-support-bot/src/context.ts b/apps/simplex-support-bot/src/context.ts new file mode 100644 index 0000000000..81f30117e9 --- /dev/null +++ b/apps/simplex-support-bot/src/context.ts @@ -0,0 +1,59 @@ +import {readFileSync} from "fs" +import {parse as parseYaml} from "yaml" +import {GrokMessage} from "./grok.js" + +const ALLOWED_ROLES: ReadonlySet = new Set(["system", "user", "assistant"]) +// Roles surfaced from a YAML transcript. `user` entries from the file are +// validated but dropped — the customer's runtime message is the only +// `user` content sent to Grok. +const PREPEND_ROLES: ReadonlySet = new Set(["system", "assistant"]) + +// Loads --context-file. The flag is documented as "text file with Grok +// system context"; a `.yaml` / `.yml` extension is an undocumented +// alternative that switches to a multi-turn transcript in the harness +// format (a flat list of `{role, message}` entries). +export function loadGrokContext(path: string): GrokMessage[] { + const text = readFileSync(path, "utf-8") + return isYamlPath(path) ? parseYamlTranscript(path, text) : [{role: "system", content: text}] +} + +function isYamlPath(path: string): boolean { + const lower = path.toLowerCase() + return lower.endsWith(".yaml") || lower.endsWith(".yml") +} + +// Parses the harness transcript format. Returns only `system` and +// `assistant` turns; `user` entries are intentionally excluded so they +// don't merge with the customer's runtime message. Malformed YAML, +// unknown roles, or non-string messages throw — operator-supplied +// configuration should fail-fast at startup, not silently degrade. +function parseYamlTranscript(path: string, text: string): GrokMessage[] { + let raw: unknown + try { + raw = parseYaml(text) + } catch (e) { + throw new Error(`${path}: failed to parse YAML: ${(e as Error).message}`) + } + if (raw === null || raw === undefined) return [] + if (!Array.isArray(raw)) { + throw new Error(`${path}: top-level must be a list, got ${typeof raw}`) + } + const context: GrokMessage[] = [] + for (let i = 0; i < raw.length; i++) { + const entry = raw[i] + if (entry === null || typeof entry !== "object" || Array.isArray(entry)) { + throw new Error(`${path}: entry ${i} is not a mapping`) + } + const {role, message} = entry as {role?: unknown; message?: unknown} + if (typeof role !== "string" || !ALLOWED_ROLES.has(role as GrokMessage["role"])) { + throw new Error(`${path}: entry ${i} has invalid role: ${JSON.stringify(role)}`) + } + if (typeof message !== "string") { + throw new Error(`${path}: entry ${i} has non-string message`) + } + if (PREPEND_ROLES.has(role as GrokMessage["role"])) { + context.push({role: role as GrokMessage["role"], content: message}) + } + } + return context +} diff --git a/apps/simplex-support-bot/src/grok.ts b/apps/simplex-support-bot/src/grok.ts index b03108439a..967f20902c 100644 --- a/apps/simplex-support-bot/src/grok.ts +++ b/apps/simplex-support-bot/src/grok.ts @@ -7,11 +7,11 @@ export interface GrokMessage { export class GrokApiClient { private readonly apiKey: string - private readonly systemPrompt: string + private readonly initialContext: readonly GrokMessage[] - constructor(apiKey: string, systemPrompt: string) { + constructor(apiKey: string, initialContext: readonly GrokMessage[]) { this.apiKey = apiKey - this.systemPrompt = systemPrompt + this.initialContext = initialContext } async chatRaw(messages: GrokMessage[]): Promise { @@ -22,7 +22,7 @@ export class GrokApiClient { "Authorization": `Bearer ${this.apiKey}`, }, body: JSON.stringify({ - model: "grok-3-mini", + model: "grok-latest", messages, temperature: 0.3, max_tokens: 1024, @@ -45,9 +45,9 @@ export class GrokApiClient { } async chat(history: GrokMessage[], userMessage: string): Promise { - log(`Grok API call: ${history.length} history msgs, user msg ${userMessage.length} chars`) + log(`Grok API call: ${this.initialContext.length} context msgs, ${history.length} history msgs, user msg ${userMessage.length} chars`) return this.chatRaw([ - {role: "system", content: this.systemPrompt}, + ...this.initialContext, ...history, {role: "user", content: userMessage}, ]) diff --git a/apps/simplex-support-bot/src/index.ts b/apps/simplex-support-bot/src/index.ts index 6f392e9deb..c99b1f5842 100644 --- a/apps/simplex-support-bot/src/index.ts +++ b/apps/simplex-support-bot/src/index.ts @@ -3,7 +3,8 @@ import {api, bot, util} from "simplex-chat" import {T} from "@simplex-chat/types" import {parseConfig} from "./config.js" import {SupportBot} from "./bot.js" -import {GrokApiClient} from "./grok.js" +import {GrokApiClient, GrokMessage} from "./grok.js" +import {loadGrokContext} from "./context.js" import {welcomeMessage} from "./messages.js" import {profileMutex, log, logError, getGroupInfo, getContact} from "./util.js" @@ -319,16 +320,22 @@ async function main(): Promise { // Load Grok context and build API client only if enabled let grokApi: GrokApiClient | null = null if (grokEnabled) { - let contextFile = "" + let initialContext: GrokMessage[] = [] if (config.contextFile) { try { - contextFile = readFileSync(config.contextFile, "utf-8") - log(`Loaded Grok context: ${contextFile.length} chars from ${config.contextFile}`) - } catch { - log(`Warning: context file not found: ${config.contextFile}`) + initialContext = loadGrokContext(config.contextFile) + log(`Loaded Grok context: ${initialContext.length} message(s) from ${config.contextFile}`) + } catch (err) { + const e = err as NodeJS.ErrnoException + if (e.code === "ENOENT") { + log(`Warning: context file not found: ${config.contextFile}`) + } else { + logError(`Failed to load Grok context file ${config.contextFile}`, err) + throw err + } } } - grokApi = new GrokApiClient(config.grokApiKey!, contextFile) + grokApi = new GrokApiClient(config.grokApiKey!, initialContext) } // Create SupportBot diff --git a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js index 97a7b866ca..92e05a4178 100644 --- a/apps/simplex-support-bot/test/__mocks__/simplex-chat.js +++ b/apps/simplex-support-bot/test/__mocks__/simplex-chat.js @@ -9,7 +9,7 @@ function ciContentText(chatItem) { function ciBotCommand(chatItem) { const text = ciContentText(chatItem)?.trim() if (text) { - const r = text.match(/\/([^\s]+)(.*)/) + const r = text.match(/^\/([^\s]+)(.*)/) if (r && r.length >= 3) return {keyword: r[1], params: r[2].trim()} } return undefined diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index ed50cdbb9a..d14435cabd 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -33,6 +33,7 @@ This file is generated automatically. - [APINewPublicGroup](#apinewpublicgroup) - [APIGetGroupRelays](#apigetgrouprelays) - [APIAddGroupRelays](#apiaddgrouprelays) +- [APIAllowRelayGroup](#apiallowrelaygroup) - [APIUpdateGroupProfile](#apiupdategroupprofile) [Group link commands](#group-link-commands) @@ -295,7 +296,7 @@ Send messages. ``` ```python -'/_send ' + str(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python +'/_send ' + ChatRef_cmd_string(sendRef) + (' live=on' if liveMessage else '') + ((' ttl=' + str(ttl)) if ttl is not None else '') + ' json ' + json.dumps(composedMessages) # Python ``` **Responses**: @@ -335,7 +336,7 @@ Update message. ``` ```python -'/_update item ' + str(chatRef) + ' ' + str(chatItemId) + (' live=on' if liveMessage else '') + ' json ' + json.dumps(updatedMessage) # Python +'/_update item ' + ChatRef_cmd_string(chatRef) + ' ' + str(chatItemId) + (' live=on' if liveMessage else '') + ' json ' + json.dumps(updatedMessage) # Python ``` **Responses**: @@ -374,7 +375,7 @@ Delete message. **Syntax**: ``` -/_delete item [,...] broadcast|internal|internalMark +/_delete item [,...] broadcast|internal|internalMark|history ``` ```javascript @@ -382,7 +383,7 @@ Delete message. ``` ```python -'/_delete item ' + str(chatRef) + ' ' + ','.join(map(str, chatItemIds)) + ' ' + str(deleteMode) # Python +'/_delete item ' + ChatRef_cmd_string(chatRef) + ' ' + ','.join(map(str, chatItemIds)) + ' ' + str(deleteMode) # Python ``` **Responses**: @@ -464,7 +465,7 @@ Add/remove message reaction. ``` ```python -'/_reaction ' + str(chatRef) + ' ' + str(chatItemId) + ' ' + ('on' if add else 'off') + ' ' + json.dumps(reaction) # Python +'/_reaction ' + ChatRef_cmd_string(chatRef) + ' ' + str(chatItemId) + ' ' + ('on' if add else 'off') + ' ' + json.dumps(reaction) # Python ``` **Responses**: @@ -1080,6 +1081,43 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APIAllowRelayGroup + +Clear relay rejection for a channel (relay operator). + +*Network usage*: background. + +**Parameters**: +- groupId: int64 + +**Syntax**: + +``` +/_relay allow # +``` + +```javascript +'/_relay allow #' + groupId // JavaScript +``` + +```python +'/_relay allow #' + str(groupId) # Python +``` + +**Responses**: + +RelayGroupAllowed: Relay rejection cleared for a channel. +- type: "relayGroupAllowed" +- user: [User](./TYPES.md#user) +- groupInfo: [GroupInfo](./TYPES.md#groupinfo) + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ### APIUpdateGroupProfile Update group profile. @@ -1386,7 +1424,7 @@ Connect via prepared SimpleX link. The link can be 1-time invitation link, conta ``` ```python -'/_connect ' + str(userId) + ((' ' + str(preparedLink_)) if preparedLink_ is not None else '') # Python +'/_connect ' + str(userId) + ((' ' + CreatedConnLink_cmd_string(preparedLink_)) if preparedLink_ is not None else '') # Python ``` **Responses**: @@ -1644,7 +1682,7 @@ Get chat previews. Supports time-based pagination — use this instead of APILis ``` ```python -'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ' ' + str(pagination) + ' ' + json.dumps(query) # Python +'/_get chats ' + str(userId) + (' pcc=on' if pendingConnections else '') + ' ' + PaginationByTime_cmd_string(pagination) + ' ' + json.dumps(query) # Python ``` **Responses**: @@ -1682,7 +1720,7 @@ Delete chat. ``` ```python -'/_delete ' + str(chatRef) + ' ' + str(chatDeleteMode) # Python +'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) # Python ``` **Responses**: diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 04d8acb84d..1b843bc6e4 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -589,6 +589,7 @@ ChatBanner: - "broadcast" - "internal" - "internalMark" +- "history" --- @@ -1373,7 +1374,7 @@ ChatType.cmdString(chatType) + chatId + (chatScope ? GroupChatScope.cmdString(ch ``` ```python -str(chatType) + str(chatId) + ((str(chatScope)) if chatScope is not None else '') # Python +ChatType_cmd_string(chatType) + str(chatId) + ((GroupChatScope_cmd_string(chatScope)) if chatScope is not None else '') # Python ``` @@ -3347,6 +3348,7 @@ ParseError: - "accepted" - "active" - "inactive" +- "rejected" --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 6c3224c56e..756cf8c10e 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -120,6 +120,7 @@ chatCommandsDocsData = ("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "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"), ("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"), + ("APIAllowRelayGroup", [], "Clear relay rejection for a channel (relay operator).", ["CRRelayGroupAllowed", "CRChatCmdError"], [], Just UNBackground, "/_relay allow #" <> Param "groupId"), ("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile") ] ), @@ -134,6 +135,7 @@ chatCommandsDocsData = ( "Connection commands", "These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled.", [ ("APIAddContact", [], "Create 1-time invitation link.", ["CRInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False)), + -- `Maybe` in `connectionLink :: Maybe AConnectionLink` is used to signal link parsing error to the runtime (the handler returns CEInvalidConnReq on Nothing); it is NOT API-level optionality. The parameter is required from callers. ("APIConnectPlan", [], "Determine SimpleX link type and if the bot is already connected via this link.", ["CRConnectionPlan", "CRChatCmdError"], [], Just UNInteractive, "/_connect plan " <> Param "userId" <> " " <> Param "connectionLink"), ("APIConnect", [], "Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/_connect " <> Param "userId" <> Optional "" (" " <> Param "$0") "preparedLink_"), ("Connect", [], "Connect via SimpleX link as string in the active user profile.", ["CRSentConfirmation", "CRContactAlreadyExists", "CRSentInvitation", "CRChatCmdError"], [], Just UNInteractive, "/connect" <> Optional "" (" " <> Param "$0") "connLink_"), @@ -202,6 +204,7 @@ cliCommands = "AcceptMember", "AddContact", "AddMember", + "AllowRelayGroup", "BlockForAll", "ChatHelp", "ClearContact", diff --git a/bots/src/API/Docs/Generate.hs b/bots/src/API/Docs/Generate.hs index 99886bf222..8bc4cbe6ee 100644 --- a/bots/src/API/Docs/Generate.hs +++ b/bots/src/API/Docs/Generate.hs @@ -74,7 +74,7 @@ syntaxText r syntax = "\n**Syntax**:\n" <> "\n```\n" <> docSyntaxText r syntax <> "\n```\n" <> (if isConst syntax then "" else "\n```javascript\n" <> jsSyntaxText False "" r syntax <> " // JavaScript\n```\n") - <> (if isConst syntax then "" else "\n```python\n" <> pySyntaxText r syntax <> " # Python\n```\n") + <> (if isConst syntax then "" else "\n```python\n" <> pySyntaxText "" r syntax <> " # Python\n```\n") camelToSpace :: String -> String camelToSpace [] = [] diff --git a/bots/src/API/Docs/Generate/Python.hs b/bots/src/API/Docs/Generate/Python.hs new file mode 100644 index 0000000000..64aa1d1062 --- /dev/null +++ b/bots/src/API/Docs/Generate/Python.hs @@ -0,0 +1,358 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +module API.Docs.Generate.Python where + +import API.Docs.Commands +import API.Docs.Events +import API.Docs.Generate +import API.Docs.Responses +import API.Docs.Syntax +import API.Docs.Syntax.Types +import API.Docs.Types +import API.TypeInfo +import Data.Char (isAlphaNum, toUpper) +import qualified Data.List.NonEmpty as L +import Data.Text (Text) +import qualified Data.Text as T + +commandsCodeFile :: FilePath +commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py" + +responsesCodeFile :: FilePath +responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py" + +eventsCodeFile :: FilePath +eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py" + +typesCodeFile :: FilePath +typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py" + +-- | Replace dashes with underscores so Python identifiers stay valid. +pyIdent :: String -> Text +pyIdent = T.replace "-" "_" . T.pack + +-- | Python class name for a union member tag. +pyConstrName :: String -> Text +pyConstrName = pyIdent . fstToUpper + +commandsCodeText :: Text +commandsCodeText = + ("# API Commands\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "import json\n" + <> "from typing import NotRequired, TypedDict\n" + <> "from . import _types as T\n" + <> "from . import _responses as CR\n" + <> foldMap commandCatCode chatCommandsDocs + where + commandCatCode CCCategory {categoryName, categoryDescr, commands} = + (T.pack $ "\n# " <> categoryName <> "\n# " <> categoryDescr <> "\n") + <> foldMap commandCode commands + where + commandCode CCDoc {commandType = ATUnionMember tag params, commandDescr, syntax, responses, network} = + ("\n# " <> commandDescr <> "\n") + <> ("# Network usage: " <> networkUsage network <> ".\n") + <> classDef + <> (if syntax == "" then "" else cmdStringFunc) + <> respAliasLine + where + constrName = T.pack $ fstToUpper tag + classDef = + ("class " <> constrName <> "(TypedDict):\n") + <> bodyOrPass (fieldsCodePy " " "T." params) + <> "\n" + cmdStringFunc = + ("\ndef " <> constrName <> "_cmd_string(self: " <> constrName <> ") -> str:\n") + <> " return " <> pySelfSyntaxText "T." (fstToUpper tag, params) syntax <> "\n" + respAliasLine = + "\n" <> constrName <> "_Response = " <> respUnion <> "\n" + respUnion = unionAliasRhs "" (responseRef . responseType) responses + responseRef (ATUnionMember rtag _) = "CR." <> pyConstrName rtag + +responsesCodeText :: Text +responsesCodeText = + ("# API Responses\n# " <> autoGenerated <> "\n") + <> pythonImports + <> unionTypeCodePy moduleMember "T." "ChatResponse" chatRespConstrs + where + chatRespConstrs = L.fromList $ map responseType chatResponsesDocs + +eventsCodeText :: Text +eventsCodeText = + ("# API Events\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "from collections.abc import Awaitable, Callable\n" + <> "from typing import Literal, NotRequired, Protocol, TypedDict, overload\n" + <> "from . import _types as T\n" + <> unionTypeCodePy moduleMember "T." "ChatEvent" chatEventConstrs + <> onEventProtocolCode chatEventConstrs + where + chatEventConstrs = L.fromList $ concatMap catEvents chatEventsDocs + catEvents CECategory {mainEvents, otherEvents} = map eventType $ mainEvents ++ otherEvents + +-- | Render the `OnEventDecorator` Protocol — one `__call__` overload per +-- event tag, narrowing the handler's event parameter from the unnarrowed +-- `ChatEvent` union to the specific tagged TypedDict. Plus a fallback +-- overload for `event: str` that keeps the unnarrowed shape so non-literal +-- tags don't trigger a type error. +-- +-- `Client.on_event` is typed as a `OnEventDecorator` (via a property) so +-- callers get per-tag narrowing without per-tag handwritten overloads +-- in client.py. +onEventProtocolCode :: L.NonEmpty ATUnionMember -> Text +onEventProtocolCode members = + "\n\nclass OnEventDecorator(Protocol):\n" + <> " \"\"\"Per-tag narrowing protocol for ``Client.on_event``.\n" + <> "\n" + <> " ``@client.on_event(\"contactConnected\")`` types the handler's\n" + <> " ``evt`` parameter as :class:`ContactConnected` rather than the\n" + <> " unnarrowed :data:`ChatEvent` union.\n" + <> " \"\"\"\n" + <> foldMap overloadCode (L.toList members) + <> "\n @overload\n" + <> " def __call__(self, event: str, /) -> Callable[\n" + <> " [Callable[[\"ChatEvent\"], Awaitable[None]]],\n" + <> " Callable[[\"ChatEvent\"], Awaitable[None]],\n" + <> " ]: ...\n" + where + overloadCode (ATUnionMember tag _) = + "\n @overload\n" + <> " def __call__(self, event: Literal[\"" <> T.pack tag <> "\"], /) -> Callable[\n" + <> " [Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]]],\n" + <> " Callable[[\"" <> pyConstrName tag <> "\"], Awaitable[None]],\n" + <> " ]: ...\n" + +typesCodeText :: Text +typesCodeText = + ("# API Types\n# " <> autoGenerated <> "\n") + <> "from __future__ import annotations\n" + <> "from typing import Literal, NotRequired, TypedDict\n" + <> foldMap typeCode chatTypesDocs + where + typeCode ctd@CTDoc {typeDef = APITypeDef {typeName' = name, typeDef}, typeDescr} = + (if T.null typeDescr then "" else "\n# " <> typeDescr <> "\n") + <> typeDefCode + <> typeCmdStringCode ctd + where + name' = T.pack name + enumValue m = case name of + "ConnectionMode" -> map toUpper m + "FileProtocol" -> map toUpper m + _ -> m + typeDefCode = case typeDef of + ATDRecord fields -> + ("\nclass " <> name' <> "(TypedDict):\n") + <> bodyOrPass (fieldsCodePy " " "" fields) + ATDEnum cs -> + "\n" <> name' <> " = Literal[" + <> T.intercalate ", " (map (\m -> "\"" <> T.pack (enumValue m) <> "\"") $ L.toList cs) + <> "]\n" + ATDUnion cs -> unionTypeCodePy typeMember "" name cs + +-- | For types with non-empty `typeSyntax`, emit a top-level +-- `_cmd_string(self: ) -> str` helper that mirrors the +-- Choice/Param expression. Records access fields via `self['']`; +-- enums and unions dispatch on `self` (a literal string) or `self['type']` +-- respectively. Required so generated `_commands.py` produces valid CLI +-- syntax for ChatRef/ChatType/ChatDeleteMode/GroupChatScope/PaginationByTime +-- params instead of stringifying the wire dict. +typeCmdStringCode :: CTDoc -> Text +typeCmdStringCode CTDoc {typeDef = td@APITypeDef {typeName' = name, typeDef}, typeSyntax} + | typeSyntax == "" = "" + | otherwise = + "\n\ndef " <> T.pack name <> "_cmd_string(self: " <> T.pack name <> ") -> str:\n" + <> " return " <> body <> ignore <> "\n" + where + body = pyTypeSyntaxText "" (name, fields) typeSyntax + -- Unions and enums use self/self['type'] to dispatch. Pyright cannot + -- narrow TypedDict access by string-literal key, so suppress per-branch + -- complaints with one ignore on the return. + ignore = case typeDef of + ATDUnion _ -> " # type: ignore[typeddict-item]" + _ -> "" + -- typeFields mirrors TS funcCode: include `self` so Choice "self" + -- resolves; for unions add `type` and flatten member fields. + self = APIRecordField "self" (ATDef td) + fields = case typeDef of + ATDRecord fs -> fs + ATDUnion ms -> + self : APIRecordField "type" tagType : concatMap (\(ATUnionMember _ fs) -> fs) (L.toList ms) + where + tagType = ATDef $ APITypeDef (name <> ".type") $ ATDEnum tags + tags = L.map (\(ATUnionMember tag _) -> tag) ms + ATDEnum _ -> [self] + +-- | Like `pySelfSyntaxText` but excludes `self` from the param-rewrite list +-- so `self == 'tag'` (enum dispatch) and `self['type']` (union dispatch) +-- survive verbatim. Used only for type-level cmd_string functions inside +-- @_types.py@, where peer type cmd_string calls don't need a namespace. +pyTypeSyntaxText :: String -> TypeAndFields -> Expr -> Text +pyTypeSyntaxText typeNamespace r expr = + rewriteParams accessors (pySyntaxText typeNamespace r expr) + where + accessors = filter ((/= "self") . fst) (paramAccessors r) + +-- | Member class name within the multi-type @_types.py@ module: prefix the +-- tag with the union type name so members from different unions don't +-- collide. +typeMember :: String -> String -> Text +typeMember typeName tag = T.pack typeName <> "_" <> pyIdent tag + +-- | Member class name within a single-union module (responses/events): just +-- the PascalCase tag, so commands can reference them as @CR.@. +moduleMember :: String -> String -> Text +moduleMember _ tag = pyConstrName tag + +-- | Common imports for the responses/events modules. +pythonImports :: Text +pythonImports = + "from __future__ import annotations\n" + <> "from typing import Literal, NotRequired, TypedDict\n" + <> "from . import _types as T\n" + +-- | Render a tagged-union type: one TypedDict per member, plus union alias +-- and `_Tag` Literal alias. The member class names are produced by +-- @memberName@ given the union type name and the member tag. +unionTypeCodePy :: + (String -> String -> Text) -> + Text -> + String -> + L.NonEmpty ATUnionMember -> + Text +unionTypeCodePy memberName typesNamespace name cs = + foldMap memberClass (L.toList cs) + <> "\n" <> name' <> " = " <> unionAliasRhs name' constrTypeRef (L.toList cs) + <> "\n" <> name' <> "_Tag = Literal[" <> tagLiterals <> "]\n" + where + name' = T.pack name + constrTypeRef (ATUnionMember tag _) = memberName name tag + tagLiterals = T.intercalate ", " $ map (\(ATUnionMember tag _) -> "\"" <> T.pack tag <> "\"") $ L.toList cs + memberClass (ATUnionMember tag fields) = + ("\nclass " <> memberName name tag <> "(TypedDict):\n") + <> (" type: Literal[\"" <> T.pack tag <> "\"]\n") + <> fieldsCodePy " " typesNamespace fields + +-- | Render the right-hand side of a union alias: either inline (one line) or +-- multi-line wrapped in parentheses with `|` separators between alternatives. +unionAliasRhs :: Text -> (a -> Text) -> [a] -> Text +unionAliasRhs lhs constr cs + | T.length (lhs <> " = " <> oneLine) <= 100 = oneLine <> "\n" + | otherwise = "(\n" <> T.intercalate "\n" (map (" " <>) lines') <> "\n)\n" + where + oneLine = T.intercalate " | " cs' + lines' = case cs' of + [] -> [] + (h : t) -> h : map ("| " <>) t + cs' = map constr cs + +-- | Emit a body of `pass` if there are no fields, otherwise the rendered +-- fields as-is. +bodyOrPass :: Text -> Text +bodyOrPass body + | T.null body = " pass\n" + | otherwise = body + +-- | Render record fields for a TypedDict body. Each field becomes +-- `: [ # ]`. Optional fields wrap the type in +-- `NotRequired[...]`. +fieldsCodePy :: Text -> Text -> [APIRecordField] -> Text +fieldsCodePy indent namespace = foldMap render + where + render (APIRecordField name t) = + indent <> T.pack name <> ": " <> wrapOptional t (typeText t) <> typeComment t <> "\n" + wrapOptional t inner = case t of + ATOptional _ -> "NotRequired[" <> inner <> "]" + _ -> inner + typeText = \case + ATPrim (PT t) -> primName t + ATDef (APITypeDef t _) -> quoted (namespace <> T.pack t) + ATRef t -> quoted (namespace <> T.pack t) + ATOptional t -> typeText t + ATArray {elemType} -> "list[" <> typeText elemType <> "]" + ATMap (PT k) v -> "dict[" <> primName k <> ", " <> typeText v <> "]" + primName = \case + TBool -> "bool" + TString -> "str" + TInt -> "int" + TInt64 -> "int" + TWord32 -> "int" + TDouble -> "float" + TJSONObject -> "dict[str, object]" + TUTCTime -> "str" + t -> T.pack t + quoted s = "\"" <> s <> "\"" + typeComment t = let c = typeComment' t in if T.null c then "" else " # " <> c + typeComment' = \case + ATPrim (PT t) -> typeComment_ t + ATOptional inner -> typeComment' inner + ATArray {elemType, nonEmpty} + | nonEmpty -> if T.null c then "non-empty" else c <> ", non-empty" + | otherwise -> c + where + c = typeComment' elemType + ATMap (PT k) v -> + let kc = typeComment_ k + vc = typeComment' v + tc t c = if T.null c then t else c + in if T.null kc && T.null vc then "" else tc (primName k) kc <> " : " <> tc (typeText v) vc + _ -> "" + typeComment_ = \case + TInt -> "int" + TInt64 -> "int64" + TWord32 -> "word32" + TDouble -> "double" + TUTCTime -> "ISO-8601 timestamp" + _ -> "" + +-- | Wrap `pySyntaxText` so each parameter access uses `self['']`. The +-- output of `pySyntaxText` references params as bare Python identifiers +-- (e.g. `str(userId)`); we rewrite those identifiers — but only outside +-- string literals — into TypedDict subscript accesses. The +-- @typeNamespace@ is prepended to any `_cmd_string(...)` calls +-- emitted for params whose type has its own syntax (e.g. @"T."@ from +-- @_commands.py@, or @""@ from within @_types.py@). +-- +-- Unlike the JS variant, we do NOT collapse adjacent string literals via +-- `T.replace "' + '" ""`: that pattern incorrectly matches `' ' + ','` +-- (the space-then-comma sequence between a literal and `','.join(...)`), +-- producing `' ,'.join(...)` which uses ` ,` as the join separator and +-- swallows the leading space. The `intercalate " + "` output is correct +-- without further string fixups. +pySelfSyntaxText :: String -> TypeAndFields -> Expr -> Text +pySelfSyntaxText typeNamespace r expr = + rewriteParams (paramAccessors r) (pySyntaxText typeNamespace r expr) + +-- | Map field name to the Python access expression: `self['']` for +-- required fields, `self.get('')` for optional ones (since +-- TypedDict's `NotRequired` allows the key to be absent and `[...]` would +-- raise `KeyError`). Used by the rewriter so the same name is substituted +-- consistently in Optional `is not None` checks and in the value position. +paramAccessors :: TypeAndFields -> [(String, String)] +paramAccessors (_, fields) = map mk fields + where + mk (APIRecordField n t) = (n, accessor n t) + accessor n = \case + ATOptional _ -> "self.get('" ++ n ++ "')" + _ -> "self['" ++ n ++ "']" + +-- | Replace bare identifiers (matching a key in @accessors@) with the +-- corresponding accessor expression, skipping characters inside +-- single-quoted string literals and respecting identifier word boundaries. +rewriteParams :: [(String, String)] -> Text -> Text +rewriteParams accessors = T.pack . go False . T.unpack + where + go _ [] = [] + -- Toggle in/out of single-quoted string on every unescaped quote. + go inStr ('\'' : rest) = '\'' : go (not inStr) rest + go True (c : rest) = c : go True rest + go False s@(c : rest) + | isIdentStart c = case takeIdent s of + (ident, after) -> case lookup ident accessors of + Just expr -> expr ++ go False after + Nothing -> ident ++ go False after + | otherwise = c : go False rest + isIdentStart c = isAlphaNum c || c == '_' + takeIdent = span (\c -> isAlphaNum c || c == '_') diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 55f12f0a0a..ddd127241b 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -73,6 +73,7 @@ chatResponsesDocsData = ("CRGroupRelays", ""), ("CRGroupRelaysAdded", ""), ("CRGroupRelaysAddFailed", ""), + ("CRRelayGroupAllowed", "Relay rejection cleared for a channel"), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/bots/src/API/Docs/Syntax.hs b/bots/src/API/Docs/Syntax.hs index f96ec03b02..64c848f310 100644 --- a/bots/src/API/Docs/Syntax.hs +++ b/bots/src/API/Docs/Syntax.hs @@ -157,8 +157,8 @@ escapeChar c s | c `elem` s = concatMap (\c' -> if c' == c then ['\\', c] else [c]) s | otherwise = s -pySyntaxText :: TypeAndFields -> Expr -> Text -pySyntaxText r = T.pack . go Nothing True +pySyntaxText :: String -> TypeAndFields -> Expr -> Text +pySyntaxText typeNamespace r = T.pack . go Nothing True where go param top = \case Concat exs -> intercalate " + " $ map (go param False) $ L.toList exs @@ -167,7 +167,13 @@ pySyntaxText r = T.pack . go Nothing True withParamType r param p $ \case ATPrim (PT TString) -> paramName param p ATOptional (ATPrim (PT TString)) -> paramName param p + ATDef td -> toStringSyntax td + ATOptional (ATDef td) -> toStringSyntax td _ -> "str(" <> paramName param p <> ")" + where + toStringSyntax (APITypeDef typeName _) + | typeHasSyntax typeName = typeNamespace <> typeName <> "_cmd_string(" <> paramName param p <> ")" + | otherwise = "str(" <> paramName param p <> ")" Optional exN exJ p -> open <> "(" <> go (Just p) False exJ <> ") if " <> n <> " is not None else " <> nothing <> close where n = paramName param p diff --git a/cabal.project b/cabal.project index 0318f6ea42..22eeebe714 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: 8bd3193280da6b4decf790bb57b470780c2576ba + tag: f0b7a4be7325cb787297a881076299c5ffbe26e7 source-repository-package type: git diff --git a/docs/LINKS.md b/docs/LINKS.md new file mode 100644 index 0000000000..46de314a75 --- /dev/null +++ b/docs/LINKS.md @@ -0,0 +1,4174 @@ +# Links to Community Publications + +## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging + +Help Net Security + +Review + +Help Net Security showcases SimpleX Chat as a free, private, open-source messenger that eliminates traditional user identifiers and stores data locally on devices. The article highlights end-to-end encrypted communications, contact addition through one-time links or QR codes, encrypted audio/video calls via WebRTC with hidden IP addresses, and security verification through comparable security codes between contacts. + +Image: help-net-security-product-showcase.jpg + +Language: English + +Date: Apr 29, 2026 + +https://www.helpnetsecurity.com/2026/04/29/product-showcase-simplex-chat-secure-messaging/ + +## Best Secure Messaging Apps: Signal vs Session vs SimpleX vs Briar + +State of Surveillance + +Comparison + +This secure messaging comparison guide evaluates Signal, Session, SimpleX, Briar, WhatsApp, and Telegram across criteria including phone number requirements, architecture, and metadata protection. SimpleX is highlighted as requiring no phone number, using a decentralized architecture with no metadata collection, and being best suited for maximum privacy, though the guide notes its small user base as a practical limitation. + +Image: state-of-surveillance-comparison.jpg + +Language: English + +Date: May 2026 + +https://stateofsurveillance.org/guides/basic/secure-messaging-comparison/ + +## Evgeny Poberezkin on SimpleX Private Chat + +Citadel Dispatch + +Podcast + +This Citadel Dispatch podcast episode features Evgeny Poberezkin discussing SimpleX Chat's radically different approach to user identity, where addresses are assigned to connections rather than endpoints. The conversation covers critiques of the MLS protocol, upcoming scalable channels to rival Telegram, and a sustainability model where large channels fund network operations. + +Image: citadel-dispatch-cd196.jpg + +Language: English + +Date: Mar 20, 2026 + +https://podcasts.apple.com/is/podcast/cd196-evgeny-poberezkin-simplex-private-chat/id1546393840?i=1000756411661 + +## The Messaging App With No User IDs + +(SimpleX Interview) + +Techlore + +Podcast + +Image: techlore-talks-simplex-interview.jpg + +Language: English + +Date: Jan 24, 2026 + +https://www.youtube.com/watch?v=hfzf0t8ZCK4 + +## Which Encrypted Messenger Is Best Secured? Signal, Session and SimpleX + +(Quelle messagerie chiffree est la mieux securisee? Signal, Session et SimpleX mais pas Whatsapp!) + +Nicolas Forcet + +Comparison + +This French-language article positions SimpleX between Signal and Session in a security hierarchy, noting its protocol design prevents servers from mapping social connections by using separate channels for incoming and outgoing messages. SimpleX is praised for stronger privacy protections including app-level locking, ephemeral messages, and Tor/VPN compatibility, though the author notes lower adoption rates may be a practical drawback. + +Image: nicolas-forcet-comparison.jpg + +Language: French + +Date: Jan 2, 2026 + +https://nicolasforcet.com/messagerie-chiffree-2026-signal-simplex-session/ + +## SimpleX Chat Review 2026: Open-Source Secure Messaging + +Darwin Dynamic + +Review + +This 2026 review describes SimpleX Chat as a strong privacy option that eliminates unique user IDs and features end-to-end encryption with decentralized routing. The reviewer notes a learning curve and ongoing development issues as drawbacks, but ultimately recommends it for privacy-conscious individuals willing to navigate a more complex interface than mainstream alternatives. + +Image: darwin-dynamic-review-2026.jpg + +Language: English + +Date: 2026 + +https://darwindynamic.com/simplex-chat-review-2026/ + +## The 5 Best Private Chat and Message Apps for 2026 + +Darwin Dynamic + +Comparison + +SimpleX Chat ranks third among five recommended private messaging apps for 2026. The article emphasizes that it requires no user identification, making it highly secure and anonymous, while noting that initial configuration can be challenging and occasional technical issues may occur. + +Image: darwin-dynamic-top5-2026.jpg + +Language: English + +Date: 2026 + +https://darwindynamic.com/5-best-private-chat-message-apps-for-2026/ + +## Best Decentralized Private Messengers in 2026 + +Factually + +Comparison + +This product comparison ranks SimpleX as the best choice for maximal anonymity and minimal metadata, describing its peer-to-peer design that avoids server-mediated trust and emphasizes anonymity. The review notes SimpleX's UI is minimal and focused on anonymity features, but acknowledges the app is younger, less battle-tested, and has a smaller user base compared to Signal and Matrix. + +Image: factually-decentralized-2026.jpg + +Language: English + +Date: 2026 + +https://factually.co/product-reviews/electronics-tech/best-decentralized-private-messengers-2026-signal-session-simplex-matrix-a6216a + +## 8 Best Secure Messaging Apps for Encrypted Chats in 2026 + +CloudSEK + +Comparison + +CloudSEK positions SimpleX as the best phone-number-free messaging app, highlighting its relay-based routing, zero metadata approach, and private invitation links instead of identity-based profiles. The article notes a trade-off between SimpleX's strong metadata protection and its smaller user community compared to more established platforms like Signal or Telegram. + +Image: cloudsek-best-secure-2026.jpg + +Language: English + +Date: 2026 + +https://www.cloudsek.com/knowledge-base/best-secure-messaging-apps + +## 10 Best Secure Messaging Apps You Should Check Out in 2026 + +Beebom + +Comparison + +Beebom lists SimpleX Chat third among the best secure messaging apps, calling it the "Best Minimal Secure Messaging App." The article highlights its lack of user IDs, incognito mode with random usernames, live message typing preview, screenshot blocking, and contact verification, noting it requires no email or phone number to use. + +Image: beebom-best-secure-2026.jpg + +Language: English + +Date: 2026 + +https://beebom.com/best-secure-messaging-apps/ + +## Decentralized Messengers: 8 WhatsApp and Telegram Alternatives 2026 + +(Децентрализованные мессенджеры: 8 альтернатив WhatsApp и Telegram в 2026 году) + +itforprof.com + +Comparison + +This Russian-language article describes SimpleX Chat as a decentralized platform that uses cryptographic keys instead of accounts for identification and employs Double Ratchet encryption with quantum-resistant extensions. The article notes that servers cannot access metadata about who communicates with whom, but warns that SimpleX is blocked by Roskomnadzor in Russia and requires a VPN to access. + +Image: itforprof-alternatives-2026.jpg + +Language: Russian + +Date: 2026 + +https://itforprof.com/blog/decentralizovannye-messendzhery/ + +## Release of SimpleX Chat 6.5 + +(Выпуск SimpleX Chat 6.5, ориентированный на консорциум и краудфандинг для независимости) + +OpenNet.ru + +News + +This Russian tech news site covers the SimpleX Chat 6.5 release, highlighting the introduction of channels with stateful messaging capabilities and the establishment of the SimpleX Network Consortium for network independence and governance. The update also brings improved web access features and SOCKS proxy support. + +Image: opennet-simplex-65.jpg + +Language: Russian + +Date: 2026 + +https://opennet.ru/65337/ + +## Vitalik Buterin Donates $765K in Ethereum to Privacy Messaging Apps + +Yahoo Finance + +News + +Yahoo Finance reports that Vitalik Buterin donated approximately $765,000 in Ethereum to privacy messaging apps Session and SimpleX. Buterin praised both apps for advancing permissionless account creation and metadata privacy, while acknowledging neither is perfect and both need improvements in user experience and security. + +Image: yahoo-finance-buterin.jpg + +Language: English + +Date: Nov 2025 + +https://finance.yahoo.com/news/vitalik-buterin-donates-765k-ethereum-190102367.html + +## Vitalik Buterin Supports Privacy-Focused Messaging Platforms With Significant Ethereum Donation + +Bitcoin.com News + +News + +Bitcoin.com reports that Vitalik Buterin donated 128 ETH (approximately $256,000) to SimpleX Chat and an equal amount to Session, totaling 256 ETH. Buterin emphasized the importance of permissionless account creation and metadata privacy, aiming to advance truly private messaging technologies that protect users from surveillance. + +Image: bitcoin-com-buterin.jpg + +Language: English + +Date: Nov 2025 + +https://news.bitcoin.com/vitalik-buterin-supports-privacy-focused-messaging-platforms-with-significant-ethereum-donation/ + +## Inside Vitalik's 256 ETH Grants: When Ethereum Falls, Privacy Rises + +CryptoSlate + +News + +CryptoSlate covers Vitalik Buterin's 256 ETH total grants to Session and SimpleX Chat, framing it as a signal that privacy infrastructure deserves funding when designed as a foundational architectural feature. The article highlights Buterin's support for metadata-resistant communication systems that operate entirely outside Ethereum's blockchain. + +Image: cryptoslate-buterin-analysis.jpg + +Language: English + +Date: Dec 2, 2025 + +https://cryptoslate.com/inside-vitaliks-256-eth-grants-when-eth-falls-privacy-rises/ + +## SimpleX Chat: The First Messaging App with No User Identifiers - Privacy by Design + +BrightCoding + +Review + +Bright Coding presents SimpleX Chat as a privacy-by-design platform that eliminates user identifiers entirely, using pairwise temporary identifiers for each conversation and end-to-end encryption with the double ratchet algorithm. The article highlights decentralized communication through user-hosted relay servers and spam prevention through opt-in contact invitations. + +Image: brightcoding-privacy-by-design.jpg + +Language: English + +Date: Sep 18, 2025 + +https://www.blog.brightcoding.dev/2025/09/18/simplex-chat-the-first-messaging-app-with-no-user-identifiers-privacy-by-design/ + +## SimpleX Chat 6.4.3: New Features and Jack Dorsey's Support + +(SimpleX Chat publie sa version 6.4.3 : une messagerie privee toujours plus aboutie) + +SysKB + +News + +This French tech site covers SimpleX Chat version 6.4.3, noting new features including bot support via commands, hypertext links in Markdown, and automatic removal of link tracking parameters. The article also mentions the project's $1.3 million funding round backed by Jack Dorsey in August 2024. + +Image: syskb-simplex-643.jpg + +Language: French + +Date: Aug 20, 2025 + +https://syskb.com/simplex-chat-6-4-3-nouveautes/ + +## Discover the Best Secure Messaging Apps in 2025 + +(Decouvrez les Meilleures Applications de Messagerie Securisee en 2025) + +Ca Marche Ca Fonctionne + +Comparison + +This French-language guide on secure messaging apps describes SimpleX Chat as minimalist while offering advanced features, particularly its incognito mode that generates random identifiers for each conversation. SimpleX receives less detailed coverage than Signal, which is positioned as the top security leader in the article. + +Image: camarchecafonctionne-secure-2025.jpg + +Language: French + +Date: Aug 26, 2025 + +https://www.camarchecafonctionne.com/decouvrez-les-meilleures-applications-de-messagerie-securisee-en-2025/ + +## SimpleX Chat Review - Secure and Private Messaging? + +HTR + +Review, Video + +Image: htr-simplex-review.jpg + +Language: English + +Date: Feb 10, 2025 + +https://www.youtube.com/watch?v=rGrF1M7x0Nk + +## Improving SimpleX With Evgeny From SimpleX and Daniel Keller From Flux + +Opt Out Podcast + +Podcast + +This Opt Out Podcast episode features Evgeny from SimpleX and Dan Keller from Flux discussing improvements to SimpleX Chat, including quantum-resistant encryption and privacy-preserving content moderation. The conversation also covers a new chat relay approach developed collaboratively between SimpleX and Flux, along with SimpleX's network operator monetization plans. + +Image: optout-improving-simplex.jpg + +Language: English + +Date: Jan 24, 2025 + +https://optoutpod.com/episodes/improving-simplex/ + +## Best WhatsApp Alternatives 2025 + +Tuta Blog + +Comparison + +Tuta designates SimpleX as the "best decentralized" WhatsApp alternative, highlighting that it requires no phone number or ID for registration and uses a decentralized network where each chat creates unique fingerprints to prevent connection mapping. The article calls it a must-try for those whose personal well-being and safety demand a greater amount of privacy, while noting its smaller user base compared to competitors. + +Image: tuta-whatsapp-alternatives.jpg + +Language: English + +Date: 2025 + +https://tuta.com/blog/best-whatsapp-alternatives-privacy + +## Best WhatsApp Alternatives for Privacy + +Proton Blog + +Comparison + +Proton's blog highlights SimpleX Chat's radical privacy approach of requiring no phone number, email, or username to create an account, using one-time invitation links instead of a central user directory. The article notes that SimpleX encrypts both messages and metadata and has undergone independent security audits, but faces limitations including a small user base and reports of misuse by extremist groups. + +Image: proton-whatsapp-alternatives.jpg + +Language: English + +Date: 2025 + +https://proton.me/blog/whatsapp-alternatives + +## What Is SimpleX Chat? + +(Qu'est-ce que SimpleX Chat) + +No Trust Verify + +Article + +This French-language article from NoTrustVerify describes SimpleX Chat as the first messaging platform with no identifiers that respects privacy by default. It explains the decentralized architecture using message queues instead of user accounts, and highlights features like Incognito Mode, Live Messages, and separate Chat Profiles for different conversations. + +Image: notrustverify-simplex-explainer.jpg + +Language: French + +Date: May 5, 2025 + +https://blog.notrustverify.ch/quest-ce-que-simplex-chat + +## SimpleX Chat: Privacy-Friendly Messenger for Maximum Privacy + +(SimpleX Chat - Datenschutzfreundlicher Messenger fur maximale Privatsphare) + +Digital Unplug Schweiz + +Guide + +This German-language privacy site describes SimpleX Chat as an open-source messenger that completely eliminates user IDs and stores data locally on devices. It details the Double-Ratchet Protocol encryption, temporary connection links, decentralized proxy server routing with optional Tor integration, and cross-platform availability across iOS, Android, Windows, macOS, and Linux. + +Image: digitalunplug-simplex-guide.jpg + +Language: German + +Date: 2025 (estimated) + +https://digitalunplug.ch/simplex.php + +## SimpleX Chat - Next Level Private Messaging + +Mental Outlaw + +Review, Video + +Image: mental-outlaw-simplex-review.jpg + +Language: English + +Date: Oct 1, 2024 + +https://www.youtube.com/watch?v=0cRu98XSap0 + +## Why We Recommend SimpleX Now + +Techlore + +Review, Video + +Image: techlore-recommend-simplex.jpg + +Language: English + +Date: Oct 7, 2024 + +https://www.youtube.com/watch?v=DVKe8U-n8fU + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +Notebookcheck + +News + +Notebookcheck argues that SimpleX Chat addresses privacy failures inherent in Telegram, highlighting that SimpleX requires no phone number, uses end-to-end encryption with onion routing, and allows users to select their own servers. The article notes that SimpleX operates in incognito mode by default and that even SimpleX itself cannot determine where messages originate. + +Image: notebookcheck-simplex-succeeds.jpg + +Language: English + +Date: Oct 2, 2024 + +https://www.notebookcheck.net/Open-source-SimpleX-Chat-succeeds-where-Telegram-failed.896988.0.html + +## SimpleX Chat Group Chat Tested in Practice + +(SimpleX: Gruppenchat-Funktion im Praxistest) + +Kuketz IT-Security Blog + +Review + +German privacy blogger Mike Kuketz documents significant performance issues with SimpleX's group chat functionality, including substantial message delays where one message sent at 19:39 arrived the next day at 11:50. The founder acknowledged that groups were never designed for more than 50 users, and the article concludes SimpleX is unsuitable for group chats exceeding that limit. + +Image: kuketz-group-chat-test.jpg + +Language: German + +Date: Oct 14, 2024 + +https://www.kuketz-blog.de/simplex-gruppenchat-funktion-im-praxistest/ + +## My Experience With SimpleX Chat: Is It the Ultimate Open Source Private Messaging App? + +It's FOSS + +Review + +It's FOSS presents a highly positive experience with SimpleX Chat, praising its no-ID signup, quantum-resistant encryption, and intuitive messaging features. The main drawback noted was buggy video calls with audio and quality issues, but overall the reviewer concludes SimpleX sets a new standard for secure communication. + +Image: itsfoss-simplex-review.jpg + +Language: English + +Date: Dec 2024 + +https://itsfoss.com/news/simplex-chat/ + +## SimpleX Chat: A Decentralized Messaging App + +(SimpleX Chat, un'app di messaggistica decentralizzata) + +Le Alternative + +Review + +This Italian article reviews SimpleX Chat as a completely decentralized messaging app emphasizing anonymity and privacy, with no user IDs and end-to-end encrypted messages that remain on servers only until delivery. It notes support for audio/video calls, message editing, and self-hosted servers, while flagging drawbacks like high battery consumption and account recovery difficulties without backups. + +Image: lealternative-simplex-review.jpg + +Language: Italian + +Date: Sep 18, 2024 + +https://blog.lealternative.net/2024/09/18/simplex-chat-unapp-di-messaggistica-decentralizzata/ + +## SimpleX: The Revolution of Private Messaging + +(SimpleX: La Rivoluzione della Messaggistica Privata) + +aiutocomputerhelp.it + +Review + +This Italian article describes SimpleX as a radical messaging platform that eliminates permanent user identities entirely. Communication occurs through encrypted invitation links creating isolated, non-traceable channels, with no central server knowing anything about users and the ability to self-host relay servers. + +Image: aiutocomputerhelp-simplex-revolution.jpg + +Language: Italian + +Date: Jul 16, 2025 + +https://www.aiutocomputerhelp.it/simplex-la-rivoluzione-della-messaggistica-privata/ + +## SimpleX: Messaging With Total Privacy + +(SimpleX: mensajeria con total privacidad) + +VeraSoul + +Review + +This Spanish-language article presents SimpleX Chat as a highly secure messaging app that requires no user IDs, unlike Telegram or Signal. It highlights the app's use of its own SMP protocol, end-to-end encryption, decentralized architecture, and the option for users to choose between SimpleX servers or self-host alternatives. + +Image: verasoul-simplex-review.jpg + +Language: Spanish + +Date: Nov 19, 2024 + +https://verasoul.com/simplex-mensajeria-con-total-privacidad.html + +## SimpleX Chat: The Messaging App Every Privacy Enthusiast Should Use + +(SimpleX Chat la aplicacion de mensajeria que todo entusiasta de la privacidad deberia utilizar) + +GatoOscuro + +Review + +This Spanish-language article recommends SimpleX Chat as an exceptional privacy platform that eliminates metadata surveillance risks present in alternatives like Signal. The author highlights out-of-band key exchange making man-in-the-middle attacks practically impossible, anonymous peer identifiers, self-destructing messages, and a decentralized client-centric architecture. + +Image: gatooscuro-simplex-review.jpg + +Language: Spanish + +Date: 2022 (estimated) + +https://gatooscuro.xyz/simplex-chat-la-aplicacion-de-mensajeria-que-todo-entusiasta-de-la-privacidad-deberia-utilizar/ + +## Interview With the Author of SimpleX Chat: The Most Secure Messaging by Design + +(Entrevista con el autor de SimpleX Chat: la mensajeria mas segura por diseno) + +GatoOscuro + +Interview + +This Spanish-language interview with SimpleX founder Evgeny Poberezkin covers the project's approach to eliminating user identifiers, addresses concerns about government surveillance and backdoors, and discusses funding from Village Global without compromising independence. Poberezkin emphasizes that adoption, word-of-mouth promotion, and user donations are essential for the project's survival. + +Image: gatooscuro-simplex-interview.jpg + +Language: Spanish + +Date: 2024 + +https://gatooscuro.xyz/entrevista-con-el-autor-de-simplex-chat-la-mensajeria-mas-segura-por-diseno/ + +## SimpleX Chat Messaging App + +(App de mensajeria SimpleX Chat) + +Alt43.es + +Review + +This Spanish-language Medium article reviews SimpleX Chat's security features including end-to-end encryption, open-source code, and the ability to register without a phone number or email. It explains that users exchange temporary anonymous identifiers via QR codes or one-time links, with all data stored only on client devices using an encrypted, portable database format. + +Image: burp-simplex-review.jpg + +Language: Spanish + +Date: Aug 1, 2023 + +https://medium.com/@burp.es/app-de-mensajer%C3%ADa-simplex-chat-c0ad46b50f1f + +## SimpleX Is a Revolutionary Messaging Platform That Redefines Privacy + +(O SimpleXchat e uma plataforma de mensagens revolucionarias que redefinem a privacidade) + +Alex Emidio + +Review + +This Portuguese-language Medium article describes SimpleX Chat as a revolutionary privacy platform that eliminates user IDs entirely, requiring no phone numbers or email addresses. It highlights end-to-end encrypted messages, group chats, file sharing, disappearing messages, and encrypted audio/video calls, all operating on decentralized servers using unidirectional message queues. + +Image: alex-emidio-simplex-review.jpg + +Language: Portuguese + +Date: Oct 10, 2023 + +https://medium.com/@alexemidio/o-simplexchat-%C3%A9-uma-plataforma-de-mensagens-revolucion%C3%A1rias-que-redefinem-a-privacidade-sendo-o-4690f2a1b2d4 + +## SimpleX: The Chat Network That Preserves Metadata Privacy + +(SimpleX, a rede de bate-papo que preserva a privacidade de metadados) + +Edivaldo Brito + +Review + +This Portuguese article describes SimpleX as a decentralized, open-source chat network that preserves metadata privacy by using disposable relay nodes and assigning no user identifiers for message routing. It explains that SimpleX employs two layers of end-to-end encryption - double ratchet for forward secrecy and a second layer to protect metadata - along with unidirectional (simplex) message queues that combine the advantages of P2P and server-based architectures. + +Image: edivaldo-brito-simplex-review.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.edivaldobrito.com.br/simplex-a-rede-de-bate-papo-que-preserva-a-privacidade-de-metadados/ + +## SimpleX Is an Alternative to Telegram Focused on Privacy + +(SimpleX e uma alternativa ao Telegram com foco na privacidade) + +Midia Segura + +Review + +This Portuguese-language article presents SimpleX as a privacy-focused alternative to Telegram, noting increased user migration following Pavel Durov's arrest and Telegram's policy changes. It highlights SimpleX as the first messaging app without a user ID, email, or phone number, with backing from Jack Dorsey. + +Image: midia-segura-simplex-review.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://midiasegura.com/simplex-e-uma-alternativa-ao-telegram-com-foco-na-privacidade/ + +## SimpleX Chat: The First Messenger Without User ID + +(Simplex Chat - O primeiro mensageiro sem ID de usuario) + +Rafael Mesquita / TabNews + +Article + +This Portuguese-language article highlights SimpleX Chat as a privacy-focused messenger that uses temporary anonymous pairwise message queue identifiers instead of user IDs, phone numbers, or usernames. It explains that this design prevents metadata correlation attacks and that user profiles are stored only on devices, with dual-layer end-to-end encryption and optional Tor connections. + +Image: tabnews-simplex-first-messenger.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.tabnews.com.br/RafaelMesquita/simplex-chat-o-primeiro-mensageiro-sem-id-de-usuario + +## SimpleX Chat: A Revolutionary Tool for Private and Even Anonymous Communications + +(SimpleX Chat: A Ferramenta Revolucionaria para Comunicacoes Privadas e ate Anonimas) + +Coach De Osasco + +Review, Video + +Image: portuguese-simplex-revolutionary.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=fUwPGhSYlLY + +## Exploring SimpleX Chat: A Scriptable, Decentralized Messaging App + +Rick Carlino + +Article + +The author explores SimpleX Chat's bot-building capabilities using its CLI application with WebSocket support, finding the process remarkably straightforward and reminiscent of writing IRC scripts with a better UX. The article includes a functional TypeScript example demonstrating how to build chatbots through JSON commands, requiring minimal setup of just downloading an executable and connecting via WebSocket. + +Image: rickcarlino-simplex-bots.jpg + +Language: English + +Date: 2024 + +https://rickcarlino.com/2024/simplex-chat-bots.html + +## Session and SimpleX: Encrypted Messenger Comparison + +FreedomNode + +Comparison + +FreedomNode compares SimpleX and Session, noting that SimpleX takes privacy further by eliminating user identifiers entirely and using temporary anonymous message queue identifiers for each conversation. The article notes SimpleX represents a more experimental privacy frontier while Session provides greater stability and cross-device compatibility, with SimpleX having limitations around desktop support and requiring both parties to be online to establish connections. + +Image: freedomnode-session-simplex.jpg + +Language: English + +Date: Oct 29, 2023 + +https://freedomnode.com/blog/session-and-simplex-encrypted-messenger-comparison/ + +## SimpleX: The First Messenger Without User Identifiers + +(SimpleX - первый мессенджер без идентификаторов пользователей) + +Habr / Privacy Accelerator + +Article + +This Russian-language Habr article introduces SimpleX Chat as a privacy-focused messenger that operates without user identifiers, using separate message queue identifiers for each contact. It highlights the SimpleX Messaging Protocol with end-to-end encryption, Tor routing support, open-source code, and a professional security audit by Trail of Bits. + +Image: habr-simplex-first-messenger.jpg + +Language: Russian + +Date: Dec 2022 + +https://habr.com/ru/companies/privacyaccelerator/articles/705778/ + +## An Anonymous Messenger: A Mandatory Standard for Every Person + +(Анонимный мессенджер - обязательный стандарт для каждого человека) + +Habr + +Article + +This Russian-language article argues that anonymous messaging with strong encryption should be a standard necessity for everyone, as governments increasingly surveil communications. SimpleX Chat is highlighted as innovative for being the first messenger without user identifiers, using temporary anonymous paired identifiers for each connection that make it impossible to correlate anonymous profiles with real identities. + +Image: habr-anonymous-standard.jpg + +Language: Russian + +Date: Nov 2024 + +https://habr.com/ru/articles/851866/ + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(Чат SimpleX с открытым исходным кодом преуспел там, где Telegram потерпел неудачу) + +Notebookcheck Russia + +News + +This Russian-language Notebookcheck article highlights how SimpleX Chat addresses privacy concerns that Telegram failed to handle, including not requiring phone numbers, using one-way onion routing, allowing users to select their own servers, and providing end-to-end encryption with local device encryption. Unlike Telegram, which made changes after government pressure, SimpleX maintains stronger anonymity protections. + +Image: notebookcheck-ru-simplex.jpg + +Language: Russian + +Date: Oct 3, 2024 + +https://www.notebookcheck-ru.com/CHat-SimpleX-s-otkrytym-iskhodnym-kodom-preuspel-tam-gde-Telegram-poterpel-neudachu.897103.0.html + +## Messenger for Paranoids: Configuring SimpleX Chat + +(Мессенджер для параноиков. Настраиваем SimpleX Chat) + +Первый отдел + +Guide, Video + +Image: russian-paranoid-messenger-tutorial.jpg + +Language: Russian + +Date: Nov 3, 2024 + +https://www.youtube.com/watch?v=3UcejFJ3TY0 + +## SimpleX Chat: A Damn Private Messaging Service + +(SimpleX Chat une messagerie sacrement privee) + +Siksik + +Review + +Image: siksik-simplex-review.jpg + +Language: French + +Date: 2024 (estimated) + +https://siksik.org/simplex-chat-une-messagerie-sacrement-privee/ + +## SimpleX Chat: A New Instant Messaging Application + +(SimpleX Chat: Une Nouvelle Application de Messagerie Instantanee) + +AEKONE + +Review + +Image: aekone-simplex-review.jpg + +Language: French + +Date: 2024 (estimated) + +https://aek.one/simplex-chat-une-nouvelle-application-de-messagerie-instantannee/ + +## SimpleX Chat Review + +Freedom.Tech + +Review + +Freedom.tech's review praises SimpleX Chat's beautiful UI, metadata protection through its peer-to-relay architecture, double ratchet encryption, and incognito mode for pseudonymous communication. The review identifies key weaknesses including intermittent notifications on both iOS and Android and flaky audio/video calls in beta, but concludes it is a fantastic tool ideal for activists, journalists, and privacy-focused users. + +Image: freedom-tech-simplex-review.jpg + +Language: English + +Date: Sep 26, 2023 + +https://freedom.tech/simplex-chat-review/ + +## SimpleX Raises $1.3M From Jack Dorsey and Asymmetric VC, Releases Chat v6.0 + +NoBs Bitcoin + +News + +No BS Bitcoin reports that SimpleX Chat secured $1.3 million in pre-seed investment led by Jack Dorsey and Asymmetric Capital Partners. The v6.0 release includes protocol improvements reducing the messages needed for users to connect by half, a redesigned mobile interface, image blur options, and improved moderation tools supporting up to 20 message selections. + +Image: nobsbitcoin-funding-v60.jpg + +Language: English + +Date: Aug 2024 + +https://www.nobsbitcoin.com/simplex-chat-v6-0/ + +## SimpleX Chat v6.1: Better Calls, iOS Notifications, UX Improvements + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v6.1, which enhances call functionality with camera activation and screen sharing from the desktop app during voice calls. The release also resolves iOS notification delivery problems, improves connectivity, and features a redesigned conversation layout with faster message operations. + +Image: nobsbitcoin-v61.jpg + +Language: English + +Date: Oct 2024 + +https://www.nobsbitcoin.com/simplex-chat-v6-1-0/ + +## SimpleX Chat v5.7: Quantum Resistant End-to-End Encryption + +NoBs Bitcoin + +News + +No BS Bitcoin reports that SimpleX Chat v5.7 introduced quantum-resistant end-to-end encryption for all contacts and message forwarding without revealing the source. The article notes planned third-party security audits and the team's request for community donations to help cover audit costs exceeding $50,000. + +Image: nobsbitcoin-v57-quantum.jpg + +Language: English + +Date: Apr 2024 + +https://www.nobsbitcoin.com/simplex-chat-v5-7-0/ + +## SimpleX Chat v5.8: Private Message Routing + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v5.8's introduction of private message routing, described as a 2-hop onion routing protocol inspired by Tor that protects both users' IP addresses and transport sessions. The update also adds file reception protections from unknown servers, chat themes with wallpapers, and improved group permissions for admins. + +Image: nobsbitcoin-v58-routing.jpg + +Language: English + +Date: Jun 2024 + +https://www.nobsbitcoin.com/simplex-chat-v5-8/ + +## SimpleX Launches Quantum-Resistant Encryption in Beta + +Reclaim the Net + +News + +Reclaim The Net reports that SimpleX Chat launched a beta version of quantum-resistant encryption, implementing post-quantum cryptography with an improved quantum-resistant double ratchet algorithm. The article notes the use of fixed 16kb block sizes and lossless compression to prevent traffic analysis, with the feature currently optional for users. + +Image: reclaimthenet-quantum-beta.jpg + +Language: English + +Date: Apr 2, 2024 + +https://reclaimthenet.org/simplex-chat-launches-quantum-resistant-encryption-in-beta + +## SimpleX Introduces Enhanced IP Privacy Measures + +Reclaim the Net + +News + +Reclaim The Net covers SimpleX Chat v5.8's private message routing protocol where the forwarding relay is chosen by the sender and the second by the recipient, ensuring neither party can observe the other's IP address. The developers rejected embedding Tor due to latency and metadata correlation issues, instead building their own solution that provides IP protection by default. + +Image: reclaimthenet-ip-privacy.jpg + +Language: English + +Date: Jun 12, 2024 + +https://reclaimthenet.org/simplex-introduces-enhanced-ip-privacy-measures + +## We Found the Most Incognito iPhone Messaging App + +(On a trouve l'app de messagerie iPhone la plus incognito) + +iPhon.fr + +Article + +This French iPhone blog highlights SimpleX Chat as the most confidential messaging application on iOS, featuring no user identification requirements, encrypted metadata and profile hiding, and decentralized infrastructure allowing users to run it on personal servers. Contacts are established via unique QR codes rather than phone numbers or usernames. + +Image: iphon-fr-most-incognito.jpg + +Language: French + +Date: Jul 15, 2023 + +https://www.iphon.fr/post/on-a-trouve-lapp-de-messagerie-iphone-la-plus-incognito + +## SimpleX: Secure Messaging With Self-Hosted Server + +(Simplex - Messagerie securisee avec serveur auto-heberge) + +Paf LeGeek + +Guide, Video + +Image: paflegeek-simplex-selfhost.jpg + +Language: French + +Date: Jun 19, 2023 + +https://www.youtube.com/watch?v=66URjJ1RkeM + +## SimpleX Chat and How Privacy Aligns With the Future of Computing + +Opt Out Podcast + +Podcast + +This Opt Out Podcast episode features SimpleX Chat founder Evgeny discussing how privacy aligns with the future of computing. The conversation covers data sovereignty, SimpleX's decentralized architecture, and the project's emphasis on user control, with resources provided on running independent servers. + +Image: optout-simplex-s3e02.jpg + +Language: English + +Date: Feb 27, 2023 + +https://optoutpod.com/episodes/s3e02-simplexchat/ + +## SimpleX Chat: Messenger Without UserID With Maximum Protection + +(SimpleX Chat: мессенджер без UserID с максимальной защитой переписки) + +Теплица социальных технологий + +Review, Video + +Image: russian-simplex-max-protection.jpg + +Language: Russian + +Date: Aug 8, 2023 + +https://www.youtube.com/watch?v=g9HGLNCkEyk + +## SimpleX Chat: Overview of Main Functions + +(SimpleX Chat #2: обзор основных функций) + +Теплица социальных технологий + +Review, Video + +Image: russian-simplex-overview-functions.jpg + +Language: Russian + +Date: Aug 10, 2023 + +https://www.youtube.com/watch?v=fp1QUPNkxKI + +## SimpleX Chat: Anonymous Messenger Without ID + +(SimpleX CHAT - анонимный мессенджер без ID) + +Чёрный Треугольник + +Review, Video + +Image: russian-simplex-anonymous-no-id.jpg + +Language: Russian + +Date: Aug 25, 2023 + +https://www.youtube.com/watch?v=Ecx5jGUn-hQ + +## SimpleX Chat: The Ultra-Private Messaging App Almost Nobody Knows + +(SimpleX Chat la app de mensajeria ultra privada que casi nadie conoce) + +RD CIPHER + +Review, Video + +Image: spanish-simplex-ultra-private.jpg + +Language: Spanish + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=5vroKXgVZ3Q + +## SimpleX: Messaging WITHOUT Identifiers + +(SimpleX: mensajeria SIN identificadores) + +Elurk Informatica + +Review, Video + +Image: spanish-simplex-sin-identificadores.jpg + +Language: Spanish + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=Uit79EFxTAs + +## An Overview of Privacy-Focused, Decentralized Instant Messengers + +Marius + +Article + +This privacy-focused blog describes SimpleX as an open-source, decentralized instant messenger that lacks fixed user identifiers, requiring neither a phone number nor a username. However, the author withdrew their recommendation after SimpleX received venture capital funding from Jack Dorsey in August 2024, citing concerns about the investment's influence. + +Image: marius-privacy-messengers-overview.jpg + +Language: English + +Date: 2024 (estimated) + +https://xn--gckvb8fzb.com/an-overview-of-privacy-focused-decentralized-instant-messengers/ + +## What Is SimpleX Chat? + +No Trust Verify / Medium + +Article + +NoTrustVerify describes SimpleX Chat as the first messaging platform that requires no login and respects privacy by default, operating through a decentralized architecture using one-way message queues rather than centralized servers. The article discusses incognito mode, live message typing indicators, and separate chat profiles, while noting challenges around multi-device synchronization and large group management. + +Image: notrustverify-what-is-simplex.jpg + +Language: English + +Date: Jun 2023 + +https://medium.com/notrustverify/what-is-simplex-chat-11124d39a318 + +## SimpleX Chat v5.4: Link Mobile and Desktop Apps via Quantum-Resistant Protocol + +NoBs Bitcoin + +News + +No BS Bitcoin covers SimpleX Chat v5.4, which introduced the ability to link mobile and desktop apps via a secure quantum-resistant protocol on local networks. The update also improved group functionality with faster joining, incognito profile support for groups, and added screen sharing for video calls on desktop. + +Image: nobsbitcoin-v54-desktop.jpg + +Language: English + +Date: Nov 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-4/ + +## SimpleX Chat v5.3: Desktop App, Local File Encryption and Improved Groups + +NoBs Bitcoin + +News + +No BS Bitcoin reports on SimpleX Chat v5.3, which introduced a new desktop app, a directory service with group improvements, and encrypted local files and media with forward secrecy. The release also achieved a 40% reduction in memory usage and added new privacy controls for message visibility. + +Image: nobsbitcoin-v53-desktop.jpg + +Language: English + +Date: Sep 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-3/ + +## Haskell in Production: SimpleX + +Serokell + +Interview + +Serokell interviews SimpleX Chat creator Evgeny Poberezkin about building the platform in Haskell, chosen for its strength in highly concurrent communication applications with features like green threads and transactional memory. The article discusses challenges of compiling Haskell to mobile devices and the project's exploration of monetization through optional subscriptions while remaining fully open-source. + +Image: serokell-haskell-simplex.jpg + +Language: English + +Date: May 17, 2022 + +https://serokell.io/blog/haskell-in-production-simplex + +## SimpleX: Impressions From the Messenger Without Identifiers + +(SimpleX: Eindrucke vom Messenger ohne Identifier) + +Kuketz IT-Security Blog + +Review + +German privacy blogger Mike Kuketz rates SimpleX as "conditionally recommended," praising its innovative no-identifier design and Tor support. He identifies weaknesses including the absence of contact verification, high battery consumption, and client instability, though notes the developer indicated these issues are being addressed. + +Image: kuketz-simplex-review.jpg + +Language: German + +Date: Dec 2, 2022 + +https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/ + +## Decentralized, Anonymous, Encrypted: SimpleX Messenger Now Also for Smartphones + +(Dezentral, anonym, verschlusselt: SimpleX-Messenger jetzt auch furs Smartphone) + +Heise Online + +News + +Heise reports on SimpleX Chat releasing smartphone apps for iPhone and Android after initially being available only as a command-line application. The article highlights the decentralized architecture where servers cannot know who communicated with whom, with users sharing QR codes to establish encrypted connections. + +Image: heise-simplex-smartphone.jpg + +Language: German + +Date: Mar 9, 2022 + +https://www.heise.de/news/Dezentral-anonym-verschluesselt-SimpleX-Messenger-jetzt-auch-fuers-Smartphone-6544488.html + +## So Apple Won't Read Along: SimpleX Chat Updated to Version 3 + +(Damit Apple nichts mitliest: SimpleX-Chat aktualisiert auf Version 3) + +Heise Online + +News + +Heise covers SimpleX Chat version 3's privacy-focused iOS push notifications that contain no information about contacts or chat content, preventing any data from reaching Apple's servers. The update also introduced database export/import functionality and faster message transmission while maintaining backward compatibility. + +Image: heise-simplex-v3-apple.jpg + +Language: German + +Date: Jul 12, 2022 + +https://www.heise.de/news/Damit-Apple-nichts-mitliest-SimpleX-Chat-aktualisiert-auf-Version-3-7170288.html + +## Open-Source Messenger SimpleX: Anonymous and Now Also Private Chatting + +(Open-Source-Messenger SimpleX: Anonym und jetzt auch privat chatten) + +Heise Online + +News + +Heise reports on SimpleX Chat version 4.0 introducing private server authentication through password protection, allowing server operators to control who can receive messages while maintaining the platform's anonymous design. Previously all chat servers were public, but administrators can now restrict access by sharing passwords with intended users. + +Image: heise-simplex-v4-private.jpg + +Language: German + +Date: Nov 29, 2022 + +https://www.heise.de/news/Open-Source-Messenger-SimpleX-anonym-und-jetzt-auch-privat-chatten-7359889.html + +## SimpleX 1.0.0: Decentralized, Privacy-Respecting and Encrypted Chat + +(SimpleX 1.0.0: Dezentraler, Privatsphare achtender und verschlusselter Chat) + +Heise Online + +News + +Heise covers SimpleX Chat reaching version 1.0.0, marking its protocol as stable with guaranteed compatibility for future releases. The article explains the dual-layer end-to-end encryption using a double-ratchet mechanism that changes keys for each message, with decentralized servers routing messages without storing user data or metadata. + +Image: heise-simplex-100-release.jpg + +Language: German + +Date: Jan 13, 2022 + +https://www.heise.de/news/SimpleX-1-0-0-Dezentraler-Privatsphaere-achtender-und-verschluesselter-Chat-6325990.html + +## SimpleX Chat Wants to Offer Complete Privacy + +(SimpleX Chat will vollstandige Privatsphare bieten) + +iphone-ticker.de + +News + +German iPhone blog iphone-ticker covers SimpleX Chat's privacy approach of establishing direct connections through shared QR codes or links for end-to-end encrypted messaging. The article highlights version 3.0's push notifications that reveal nothing about chat content or contacts, and the addition of encrypted audio and video calling. + +Image: iphone-ticker-simplex-privacy.jpg + +Language: German + +Date: Jul 13, 2022 + +https://www.iphone-ticker.de/simplex-chat-will-vollstaendige-privatsphaere-bieten-194389/ + +## SimpleX Chat Now Also for Smartphones + +(SimpleX-Chat jetzt auch fur Smartphones) + +GNU/Linux.ch + +News + +This Swiss GNU/Linux community site reports on SimpleX Chat's release of smartphone applications for iPhone and Android, expanding beyond its command-line interface. The article emphasizes end-to-end encryption, decentralized architecture, and QR code-based peer-to-peer connections without routing data through SimpleX servers. + +Image: gnulinux-ch-simplex-smartphones.jpg + +Language: German + +Date: Mar 10, 2022 + +https://gnulinux.ch/simplex-chat-smartphones + +## SimpleX Chat: A Star on the Open-Source Messenger Horizon + +(SimpleX Chat - Frohlockender Stern am Open-Source Messenger Himmel) + +hackspoiler.de + +Review + +This German security blog highlights SimpleX Chat as a privacy-focused open-source messenger requiring no user ID, with end-to-end encryption for all communications including voice messages, audio/video calls, and self-destructing messages. The article positions it as a secure alternative to proprietary platforms, comparing it to Signal and Session under the AGPL V3 license. + +Image: hackspoiler-simplex-star.jpg + +Language: German + +Date: 2023 (estimated) + +https://hackspoiler.de/simplex-chat-verschluesselter-opensource-messenger/ + +## Good Messengers Instead of WhatsApp + +(Gute Messenger statt WhatsApp) + +Digitalcourage + +Guide + +German digital rights organization Digitalcourage describes SimpleX as a relatively new open-source messenger launched in 2022 that uses distributed servers with temporary connection identifiers rather than phone numbers or usernames. While it functions reliably and supports voice messages, file transfers, and video calls, the organization only conditionally recommends it due to its youth as a project and limited track record. + +Image: digitalcourage-simplex-recommendation.jpg + +Language: German + +Date: 2023 (estimated) + +https://digitalcourage.de/digitale-selbstverteidigung/messenger + +## Messenger SimpleX Protects Privacy and Is Open Source + +(Messenger SimpleX schutzt Privatsphare und ist Open Source) + +Linux-Magazin + +News + +German Linux Magazin reports on SimpleX as an anonymous, encrypted open-source messenger (AGPLv3) that uses no central server to track connections - servers only relay messages, and anyone can self-host a message broker using SimpleXMQ. The article highlights SimpleX's double ratchet end-to-end encryption with forward secrecy and metadata concealment, noting that at the time of writing only a command-line client was available with mobile apps in development. + +Image: linux-magazin-simplex-privacy.jpg + +Language: German + +Date: 2022 (estimated) + +https://www.linux-magazin.de/news/messenger-simplex-schuetzt-privatsphaere-und-ist-open-source/ + +## SimpleX + +freie-messenger.de + +Article + +This German free messenger comparison site provides a comprehensive overview of SimpleX Chat, noting its distinctive lack of user identifiers and end-to-end encryption with data stored only on client devices. The assessment concludes that while SimpleX is an innovative privacy-focused messenger suitable for individuals and activists, it has limitations for business use and lacks interoperability with standard protocols like XMPP. + +Image: freie-messenger-simplex.jpg + +Language: German + +Date: updated regularly + +https://www.freie-messenger.de/simplex/ + +## The Best Private Instant Messengers + +Privacy Guides + +Review + +Privacy Guides recommends SimpleX Chat as an instant messenger that does not depend on any unique identifiers such as phone numbers or usernames. The page notes its double ratchet encryption with quantum resistance, metadata protection through unidirectional message delivery queues, and independent security audits conducted in July 2024 and October 2022. + +Image: privacy-guides-recommendation.jpg + +Language: English + +Date: updated regularly + +https://www.privacyguides.org/en/real-time-communication/ + +## SimpleX + +Whonix Wiki + +Review + +The Whonix wiki describes SimpleX as a general-purpose instant messaging client with both client and server released as Freedom Software under GNU AGPLv3. It highlights mandatory end-to-end encryption, quantum-resistant encryption by default for one-on-one chats, and incognito mode, while providing detailed Flatpak installation instructions and warning that users must export their chat database before shutdown to avoid losing all data. + +Image: whonix-simplex-recommendation.jpg + +Language: English + +Date: updated regularly + +https://www.whonix.org/wiki/SimpleX + +## Private Messaging: Wikilibriste Recommendations + +(Les messageries privees) + +Wikilibriste + +Review + +This French privacy guide recommends SimpleX for users seeking a strict, intentionally confidential messaging model. It highlights SimpleX's unique reception addresses per contact, Double Ratchet encryption, incognito mode, and the absence of user identifiers, while noting that user IP remains visible to relay servers unless Tor is used. + +Image: wikilibriste-simplex-recommendation.jpg + +Language: French + +Date: updated regularly + +https://www.wikilibriste.fr/messagerie-vie-privee/ + +## Messenger Matrix + +Kuketz IT-Security Blog + +Comparison + +This comprehensive German messenger comparison table rates SimpleX on dozens of technical criteria. It notes SimpleX's decentralized architecture, post-quantum encryption, NaCl/Signal Protocol cryptography, and Trail of Bits security audit, but gives it a "very limited" recommendation and categorizes it as targeting advanced users rather than beginners. + +Image: kuketz-messenger-matrix.jpg + +Language: German + +Date: updated regularly + +https://www.messenger-matrix.de/messenger-matrix.html + +## Communicate Online in Complete Anonymity + +(SimpleX Chat: comunicare online in totale anonimato) + +Web Apps Magazine + +Article + +This Italian article presents SimpleX as a paradigm shift in private messaging, highlighting its lack of phone number requirements, decentralized relay system, and metadata protection. It positions the app for journalists, activists, and privacy-conscious users, while honestly noting the tradeoff of losing all contacts if the phone is lost without backups. + +Image: webappsmagazine-simplex-anonymity.jpg + +Language: Italian + +Date: Feb 15, 2026 + +https://webappsmagazine.blogspot.com/2026/02/simplex-chat-comunicare-online-in.html + + +## SimpleX Chat: A Privacy-Optimized Chat App - Why Telegram Users Are Leaving + +(SimpleX Chat, 개인 정보 보호에 최적화된 채팅 앱, 텔레그램 사용자들이 떠나는 이유) + +Billionnapkin + +Review + +This article frames SimpleX as a privacy-optimized alternative to Telegram, arguing users are migrating due to Telegram's changed data access policies following CEO Pavel Durov's legal issues. The tone is promotional but balanced, acknowledging concerns about potential misuse while endorsing SimpleX for journalists, activists, and privacy-conscious professionals. + +Image: billionnapkin-simplex-review.jpg + +Language: Korean + +Date: Oct 8, 2024 + +https://billionnapkin.com/simplex-chat/ + +## Telegram Alternative Apps: Stronger Anonymous Messenger Recommendations + +(텔레그램 대체 앱: 더 강력한 익명 메신저 추천) + +NetXHack + +Guide + +This Korean-language comparison of Telegram alternatives describes SimpleX as an identifier-free distributed framework that requires no phone numbers or account IDs. The article notes SimpleX's relay servers remain ignorant of senders and receivers, but acknowledges it prioritizes anonymity over user experience and is better suited for small groups needing secure communication. + +Image: netxhack-telegram-alternatives.jpg + +Language: Korean + +Date: Dec 17, 2025 (updated) + +https://netxhack.com/apps/foss/alternatives-to-telegram/ + +## Users Flocking to Telegram Alternative Apps + +(텔레그램 대안 앱으로 몰려가는 이용자들) + +eKoreaNews + +News + +This Korean news article reports on users flocking to Telegram alternatives, positioning SimpleX as the first messenger without user IDs. It notes SimpleX's decentralized architecture and mentions its early funding support from Jack Dorsey, while contextualizing the migration trend against Telegram's policy changes allowing authorities access to user data. + +Image: ekoreanews-telegram-alternatives.jpg + +Language: Korean + +Date: Oct 11, 2024 + +https://www.ekoreanews.co.kr/news/articleView.html?idxno=75746 + +## Vitalik Donates 128 ETH Each to Privacy Messaging Apps Session and SimpleX Chat + +(ヴィタリック、プライバシー重視のメッセージアプリ「Session」「SimpleX Chat」に各128ETHを寄付) + +New Economy / Gentosha + +News + +This Japanese article reports on Vitalik Buterin's donation of 128 ETH each to Session and SimpleX Chat on November 27, 2025. It quotes Buterin saying encrypted messaging is critical for digital privacy, and notes he identified permissionless account creation and metadata privacy as key development priorities, while acknowledging both apps have not yet achieved ideal user experience. + +Image: neweconomy-buterin-simplex.jpg + +Language: Japanese + +Date: Nov 2025 + +https://www.neweconomy.jp/posts/521981 + +## Ethereum Founder Makes Huge Donation to Messaging App + +(イーサリアム創設者、メッセージングアプリに巨額寄付) + +CRYPTO TIMES + +News + +This Japanese crypto news outlet reports on Vitalik Buterin's combined 256 ETH donation to Session and SimpleX Chat. It highlights SimpleX's approach of eliminating permanent user identifiers entirely, using QR codes and invitation links instead, with servers functioning as data conduits that maintain no information about who communicates with whom. + +Image: cryptotimes-buterin-simplex.jpg + +Language: Japanese + +Date: Dec 3, 2025 + +https://crypto-times.jp/news-ethereum-founder-makes-huge-donation-to-messaging-app/ + +## Anonymous Messaging App SimpleX Chat: Setup and Usage Guide + +(ID不要の匿名メッセージアプリ「SimpleX Chat」の使い方) + +VPN Taizen + +Guide + +This Japanese setup guide describes SimpleX as having the highest privacy protection among messaging apps, explaining its encrypted queue system where servers cannot know who communicates with whom. The reviewer provides detailed instructions for profiles, contact management, and Tor integration, but criticizes the interface as "terrible" with confusing design and iOS stability issues. + +Image: vpn-taizen-simplex-guide.jpg + +Language: Japanese + +Date: 2025 (updated) + +https://vpn-taizen.com/how_to_use_anonymous_messaging_app_simplex_chat_that_doesnt_require_id/ + +## Privacy-Focused Chat Tool SimpleX: First Impressions + +(プライバシー特化のチャットツール「SimpleX」を利用してみる) + +Kusaimara Blog + +Review + +This Japanese blog post from September 2022 shares first impressions of SimpleX as a privacy-focused chat tool available on mobile. The author finds it usable despite being in development and expresses cautious optimism, noting SimpleX later gained Japanese language support by June 2023. + +Image: kusaimara-simplex-first-impressions.jpg + +Language: Japanese + +Date: Sep 2022 + +https://kusaimara.net/2022/09/20 + +## Anonymous Chat Showdown: Session vs SimpleX + +(匿名チャット対決!Session対SimpleX) + +Kusaimara Blog + +Comparison + +This Japanese blog post compares Session and SimpleX across recognition, usability, and anonymity. It notes SimpleX avoids user IDs entirely using unique URLs for connections but requires manual Tor activation. The author concludes both tools suffer from limited adoption, arguing that messaging apps require network effects to be practical. + +Image: kusaimara-session-vs-simplex.jpg + +Language: Japanese + +Date: Jul 2024 + +https://kusaimara.net/2024/07/758 + +## Why I Recommend SimpleX Over Signal and Session + +(巷で話題のSignalやSessionではなくSimpleXをおすすめする理由) + +vpn53049 / Ameblo + +Review + +This Japanese blog post advocates for SimpleX over Signal and Session, citing its per-connection one-time IDs, forward secrecy, and post-quantum encryption. The author criticizes Session for lacking forward secrecy and using fixed permanent IDs, and questions Signal's leadership integrity, concluding SimpleX addresses privacy gaps present in both alternatives. + +Image: ameblo-vpn53049-simplex-recommend.jpg + +Language: Japanese + +Date: May 19, 2024 + +https://ameblo.jp/vpn53049/entry-12852836589.html + +## SimpleX's Revolutionary Idea + +(SimpleXの革命的なアイデアに心を打たれた) + +vpn53049 / Ameblo + +Article + +This Japanese blog post explains SimpleX's key innovation: generating unique IDs per conversation rather than using persistent anonymous IDs. The author argues this eliminates the social graph exposure risk inherent in competing apps and solves the practical frustration of managing multiple devices or accounts to maintain anonymity across different social contexts. + +Image: ameblo-vpn53049-simplex-revolutionary.jpg + +Language: Japanese + +Date: Sep 5, 2022 + +https://ameblo.jp/vpn53049/entry-12786598980.html + +## The Next Anonymous Messengers After Signal: Session and SimpleX Chat + +(Signalの次に代わる匿名メッセージアプリ・SessionとSimpleX Chat) + +renaro / note.com + +Comparison + +This Japanese article positions Session and SimpleX as the next anonymous messengers after Signal, whose mandatory phone number requirement is its main weakness. SimpleX is presented as the most anonymous option due to having no user ID whatsoever, though it requires the additional Orbot app for maximum privacy, making Session more convenient for baseline anonymity. + +Image: renaro-signal-session-simplex.jpg + +Language: Japanese + +Date: Nov 21, 2025 + +https://note.com/renaro/n/n8b3c9af1899f + +## SimpleX Chat: Next-Generation Secure Communication Tool Without Identity Verification + +(SimpleX Chat:无需身份识别的下一代安全通讯工具) + +Huluohu Blog + +Review + +This Chinese article introduces SimpleX Chat as a uniquely private messaging platform that requires no phone number, email, or username of any kind, using shared links instead to establish connections. It highlights SimpleX's core advantages: true anonymity where even the servers cannot know who is talking to whom, end-to-end encryption, decentralized server architecture, open-source transparency, and innovative use of unidirectional message queues with rotating random receive addresses to resist network analysis. + +Image: huluohu-simplex-review.jpg + +Language: Chinese + +Date: 2024 (estimated) + +https://www.huluohu.com/posts/1221/ + +## SimpleX Chat: A Private and Encrypted Open-Source Communication Tool Without Any User IDs + +(SimpleX Chat:一个私人且加密的开源通讯工具,没有任何用户 ID) + +Ababtools + +Review + +This article describes SimpleX Chat as an open-source messaging platform that does not rely on any user identifier, not even random numbers. It highlights Double Ratchet encryption, multiple chat profiles including hidden ones, encrypted voice messages and audio/video calls, secret group chats, and portable encrypted databases, with availability across Android, iOS, and desktop. + +Image: ababtools-simplex-review.jpg + +Language: Chinese + +Date: Jan 7, 2024 + +https://ababtools.com/?post=955 + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(开源 SimpleX Chat 成功弥补了 Telegram 的不足) + +Notebookcheck China + +News + +This Chinese Notebookcheck article argues SimpleX succeeds where Telegram failed on privacy. It highlights that SimpleX requires no phone numbers, uses incognito mode with auto-generated usernames, employs one-way onion routing, and allows users to set up their own servers. The article notes unlimited group sizes but acknowledges SimpleX currently lacks widespread adoption. + +Image: notebookcheck-cn-simplex.jpg + +Language: Chinese + +Date: Oct 3, 2024 + +https://www.notebookcheck-cn.com/SimpleX-Chat-Telegram.897088.0.html + +## Open-Source SimpleX Chat Succeeds Where Telegram Failed + +(O SimpleX Chat de codigo aberto tem sucesso onde o Telegram falhou) + +Notebookcheck Portugal + +News + +This Portuguese Notebookcheck article contrasts SimpleX with Telegram, emphasizing that SimpleX does not require phone numbers for registration, uses incognito mode and one-way onion routing, and offers end-to-end plus local device encryption. It positions SimpleX's open-source, decentralized approach against Telegram's conflicts with government agencies. + +Image: notebookcheck-pt-simplex.jpg + +Language: Portuguese + +Date: Oct 2024 + +https://www.notebookcheck.info/O-SimpleX-Chat-de-codigo-aberto-tem-sucesso-onde-o-Telegram-falhou.897026.0.html + +## Anonymous Messaging Apps + +(App di messaggistica anonime) + +Le Alternative + +Guide + +This Italian blog about alternative apps describes SimpleX Chat as an open-source, end-to-end encrypted messenger that has received an independent security audit. It emphasizes that the only way to add a contact is through QR code or link, requiring neither phone number nor email address, and notes availability on F-Droid, Play Store, and iOS. + +Image: lealternative-anonymous-apps.jpg + +Language: Italian + +Date: Dec 14, 2022 + +https://blog.lealternative.net/2022/12/14/app-di-messaggistica-anonime/ + +## SimpleX Chat + +Freeonline.org + +Review + +This Italian review awards SimpleX Chat "Site of the Day" status, describing it as a messaging platform that eliminates user identifiers entirely. It highlights advanced end-to-end encryption, local-only data storage, Tor network access, and availability across all major platforms, while noting the experience is more technical than competing services. + +Image: freeonline-simplex-review.jpg + +Language: Italian + +Date: Feb 17, 2026 (updated) + +https://www.freeonline.org/simplex-chat/ + +## 10 Most Secure Messaging Apps of 2024 + +(Le 10 app di messaggistica piu sicure del 2024) + +Moyens I/O Italy + +Comparison + +Image: moyens-io-secure-apps-2024.jpg + +Language: Italian + +Date: 2024 + +https://it.moyens.net/app/app-messaggistica-piu-sicure-del-signal-session-simplex/ + +## SimpleX Chat: The Hidden Portal of Privacy + +(SimpleX Chat - O Portal Oculto da Privacidade) + +Paranoia + +Review, Video + +Image: portuguese-simplex-hidden-portal.jpg + +Language: Portuguese + +Date: Oct 2024 + +https://www.youtube.com/watch?v=CCB9m0T7RIM + +## Anonymous Chat Without Phone and Email: Online Privacy With Ultra Metadata Annihilation + +(SIMPLEX: CHAT ANONIMO SEM TELEFONE E EMAIL / PRIVACIDADE ONLINE COM ULTRA ANIQUILACAO DE METADADOS) + +Leandroibov + +Review, Video + +Image: portuguese-simplex-ultra-annihilation.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=gvVfpPB1srI + +## How to Use SimpleX Private Chat Without Identification + +(Como usar o SimpleX chat privado sem identificacao) + +Prometheus HODL + +Guide, Video + +Image: portuguese-simplex-tutorial.jpg + +Language: Portuguese + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=JMPxptujnaQ + +## SimpleX Chat: Messaging Meets Perfect Privacy + +The Digital Prepper + +Review, Video + +Image: simplex-messaging-perfect-privacy.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.youtube.com/watch?v=aKRfDch_WBQ + +## SimpleX Chat: Simple Messaging With Unusually Good Privacy + +Remember Lads Subscribe to Big Bear + +Review, Video + +Image: simplex-unusually-good-privacy.jpg + +Language: English + +Date: Dec 19, 2023 + +https://www.youtube.com/watch?v=zkEY9m2E-Y4 + +## Self-Hosted SimpleX Chat + +Simple Messaging With Unusually Good Privacy + +Guide, Video + +Image: selfhosted-simplex-tutorial.jpg + +Language: English + +Date: Feb 8, 2024 + +https://www.youtube.com/watch?v=1zMAGzYBgJY + +## Setting Up SimpleX As Your Private Messenger + +Daniel's Blog + +Guide + +This blog post walks through self-hosting a SimpleX SMP server with Traefik and Docker after the author abandoned WhatsApp over privacy concerns. It highlights SimpleX's invite-link-based connections and decentralized relays that do not reveal participant information, while honestly noting the desktop app requires an active smartphone connection and lacks a web interface. + +Image: xfuture-blog-simplex-setup.jpg + +Language: English + +Date: 2025 + +https://xfuture-blog.com/posts/setting-up-simplex-as-your-private-messenger/ + +## Showdown: Signal, Session, SimpleX, Matrix, XMPP, vs Briar + +Simplified Privacy + +Comparison + +This messenger showdown compares Signal, Session, SimpleX, Matrix, XMPP, and Briar, positioning SimpleX as "most likely to grow" due to its corporate APIs. It recommends SimpleX specifically for users who want to hide their communications, while noting it is rated as most vulnerable to psychological phishing among the messengers compared. + +Image: simplified-privacy-showdown.jpg + +Language: English + +Date: 2024 (estimated) + +https://simplifiedprivacy.com/messengers/ + +## SimpleX Network: Power to the People + +SimpleX Chat + +Livestream, Video + +Image: simplex-power-to-people-livestream.jpg + +Language: English + +Date: Feb 11, 2025 + +https://www.youtube.com/watch?v=Uez2mfVGU7s + +## Every Thing You Need to Know About SimpleX Chat + +Libre Self Hosted + +Guide + +This self-hosting directory describes SimpleX as the most private and secure chat platform, using pairwise per-queue identifiers instead of persistent user IDs. It notes the Trail of Bits security audit, AGPL-3.0 licensing, Haskell implementation, and Tor support, emphasizing that SimpleX protocols are and will remain open and in the public domain. + +Image: libreselfhosted-simplex-overview.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.libreselfhosted.com/project/simplex-chat/ + +## Privacy First Steps + +Seth For Privacy + +Guide + +In this privacy guide by Seth for Privacy, SimpleX is recommended as a key step in the privacy journey for its protection of both message contents and metadata. The author notes SimpleX has become his preferred messenger over Signal, primarily because it eliminates the phone number requirement, and he references a detailed podcast interview with SimpleX founder Evgeny. + +Image: sethforprivacy-privacy-steps.jpg + +Language: English + +Date: 2024 (estimated) + +https://github.com/sethforprivacy/sethforprivacy.com/blob/master/content/posts/privacy-first-steps.md + +## Degoogle Your Private Life: Real-Time Messaging + +iode Blog + +Guide + +This iode tech blog article on degoogling covers SimpleX as a decentralized messaging alternative using unidirectional simplex queues. It praises SimpleX's open-source end-to-end encryption and censorship resistance, but identifies significant limitations: a very small user base, no remote contact discovery, slower message delivery, no multi-device support, and total data loss if the device is lost without backups. + +Image: iode-degoogle-messaging.jpg + +Language: English + +Date: 2024 (estimated) + +https://blog.iode.tech/degoogle-your-private-life-4-instant-messaging/ + +## SimpleX Chat v5.2: Message Delivery Receipts + +NoBs Bitcoin + +News + +This release announcement covers SimpleX Chat v5.2.0, highlighting new message delivery receipts with per-contact opt-out, conversation filtering by favorites and unread status, and group improvements including full context for replied messages. The developers emphasized their commitment to keeping SimpleX protocols open and in the public domain. + +Image: nobsbitcoin-v52-receipts.jpg + +Language: English + +Date: 2023 + +https://www.nobsbitcoin.com/simplex-chat-v5-2-0/ + +## SimpleX Server Now Available for StartOS + +NoBs Bitcoin + +News + +This article announces SimpleX Server availability on StartOS through the Start9 Registry. It explains SimpleX's server architecture where servers act as simple relays that do not store profiles, contacts, or groups, and each conversation typically uses two different servers - one chosen by each participant - with Tor server support built in. + +Image: nobsbitcoin-startos.jpg + +Language: English + +Date: 2023 + +https://www.nobsbitcoin.com/simplex-server-now-available-for-startos/ + +## SimpleX Chat + +Blog de Joselito + +Comparison + +This Spanish blog post acknowledges SimpleX offers superior privacy features over XMPP, including metadata protection and automatic encryption, but argues XMPP is not obsolete. The author identifies a vulnerability in SimpleX's group key distribution relying on administrators, and views SimpleX as promising but unproven long-term, using both platforms with XMPP as his primary messenger. + +Image: joselito-simplex-vs-xmpp.jpg + +Language: Spanish + +Date: Dec 5, 2023 + +https://joselito.mataroa.blog/blog/simplex-chat/ + +## SimpleX Chat Part 2 + +Blog de Joselito + +Review + +In this follow-up Spanish post, the author details SimpleX's technical strengths including metadata protection through unidirectional message queues, double-layer encryption, and automatic message deletion from servers after receipt. While primarily an XMPP user, the author calls SimpleX "an excellent messaging platform" and appreciates that robust security works automatically without user effort. + +Image: joselito-simplex-part2.jpg + +Language: Spanish + +Date: Dec 7, 2023 + +https://joselito.mataroa.blog/blog/simplex-chat-parte-2/ + +## 14 Best Secure Messengers of 2026 + +(14 лучших безопасных мессенджеров 2026) + +pro32.com + +Comparison + +This Russian article listing 14 secure messengers highlights SimpleX's unique architecture without user identifiers, where contacts are added via QR codes or invitation links. It identifies SimpleX as one of the most promising options for bypassing blockades, suitable for users prioritizing anonymity and censorship resistance. + +Image: pro32-best-messengers-2026.jpg + +Language: Russian + +Date: 2026 + +https://pro32.com/ru/article/14-samykh-bezopasnykh-messendzherov-kakoy-vybrat-dlya-lichnoy-i-rabochey-perepiski/ + +## Comparative Review of Protected Messengers + +(Сравнительный обзор защищенных мессенджеров) + +SecurityLab.ru + +Comparison + +This Russian comparative review of protected messengers describes SimpleX as a decentralized platform targeting privacy-conscious users who prioritize anonymity. It notes SimpleX's strengths in avoiding phone numbers and tracking, but identifies fewer features compared to Telegram or Signal, a potentially confusing interface, and a smaller user community as drawbacks. + +Image: securitylab-messenger-comparison.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://www.securitylab.ru/blog/personal/SimlpeHacker/354153.php + +## SimpleX Chat Overview: What Is It? + +(Обзор SimpleX Chat: Что это такое?) + +DDPA.ru + +Review + +This Russian overview describes SimpleX as a privacy-focused messenger that assigns users no identifiers of any kind, ensuring complete anonymity. It highlights the hybrid P2P and federated architecture, end-to-end encryption resistant to relay server compromise, and positions SimpleX as ideal for journalists, activists, and cybersecurity enthusiasts. + +Image: ddpa-simplex-overview.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://ddpa.ru/p/simplex-chat + +## Group P2P Chats and the First Messenger Without ID + +(Групповые P2P-чаты и первый мессенджер без ID) + +Habr / GlobalSign + +Article + +This Habr article examines group P2P messaging and SimpleX as the first messenger without user IDs, using temporary anonymous paired message queue identifiers unique to each connection. It details incognito mode, decentralized storage, dual-layer encryption, and Tor compatibility, contrasting SimpleX's approach with all existing messengers that rely on some form of user identification. + +Image: habr-globalsign-p2p-chats.jpg + +Language: Russian + +Date: Feb 2024 + +https://habr.com/ru/companies/globalsign/articles/792986/ + +## Safer Than Signal or Telegram? SimpleX Offers Absolute Privacy Without Creating a Personal ID + +(Bezpecnejsi nez Signal nebo Telegram? SimpleX nabidne absolutni soukromi bez vytvareni osobniho ID) + +Cnews.cz + +News + +This Czech tech news article presents SimpleX as safer than Signal or Telegram, highlighting its elimination of permanent user IDs in favor of temporary anonymous message identifiers deleted immediately after sending. It notes the project originated in 2020 and gained attention when Jack Dorsey endorsed it as potentially more secure than Signal or Telegram. + +Image: cnews-cz-simplex-privacy.jpg + +Language: Czech + +Date: May 27, 2023 + +https://www.cnews.cz/bezpecnejsi-nez-signal-nebo-telegram-simplex-nabidne-absolutni-soukromi-bez-nutnosti-vytvareni-osobniho-id/ + +## SimpleX Chat Is a Revolution in Encrypted Communication + +(SimpleX Chat je revoluci v sifrovane komunikaci) + +Kryptoanarchista.cz + +Review + +This Czech crypto-anarchist publication calls SimpleX a revolution in encrypted communication that surpasses Signal and Threema. It praises the temporary anonymous pairwise identifiers that prevent correlation attacks, and the phone-number-free setup via QR codes, while noting limitations in desktop availability and the need for an alternative channel to initiate contact. + +Image: kryptoanarchista-simplex-revolution.jpg + +Language: Czech + +Date: Aug 17, 2023 + +https://kryptoanarchista.cz/simplex-chat-je-revoluci-v-sifrovane-komunikaci/ + +## Overview and Comparison of Encrypted Communication Tools + +(Prehlad a porovnanie sifrovanych komunikacnych nastrojov - messengerov) + +Juraj Bednar + +Comparison + +In this Slovak overview of encrypted communication tools, Juraj Bednar describes SimpleX as the youngest application in the comparison, using asymmetric connections via QR codes with no user identifiers. He notes basic functionality including audio and video calls works, but advises waiting before using it for family or business communication due to ongoing development and some technical issues. + +Image: bednar-encrypted-messengers.jpg + +Language: Slovak + +Date: Apr 5, 2022 + +https://juraj.bednar.io/blog/2022/04/05/sifrovane-komunikacne-nastroje-prehlad/ + +## Encrypted Communication Between Programs and Mobile Devices + +Juraj Bednar + +Guide + +Juraj Bednar chose SimpleX as his preferred solution for sending encrypted notifications between programs and mobile devices, valuing its lack of account creation requirements and absence of spam. He uses helper functions to send messages and files from scripts, while noting SimpleX still has bugs and does not guarantee delivery if the program finishes before transmission completes. + +Image: bednar-encrypted-notifications.jpg + +Language: English + +Date: Nov 24, 2024 + +https://juraj.bednar.io/en/blog-en/2024/11/24/encrypted-communication-between-programs-and-mobile-devices/ + +## Session and SimpleX: Encrypted Messenger Comparison + +Michal Kodnar + +Comparison + +This comparison characterizes SimpleX as the first identifier-free messenger, using temporary anonymous paired message queues instead of user IDs. The author finds both Session and SimpleX are interesting projects but notes SimpleX remains buggy and incomplete, requiring QR codes for new connections and lacking disappearing messages at the time of writing. + +Image: kodnar-session-simplex.jpg + +Language: English + +Date: Oct 2023 + +https://michalkodnar.xyz/blog-en/culture-en/session-and-simlex-encrypted-messenger-comparison/ + +## Secure and Decentralized Chat Without Phone Number or Accounts + +(Chat securizat si descentralizat, fara numar de telefon sau conturi, cu aplicatia de mesagerie SimpleX Chat) + +Romica Prihor + +Guide + +This Romanian blog post promotes SimpleX as a revolution in secure and decentralized messaging without phone numbers or accounts. It provides installation instructions for Android, iOS, and desktop, and covers group management through invitation links with moderation features, positioning SimpleX as ideal for those prioritizing communication privacy without compromises. + +Image: prihor-simplex-guide.jpg + +Language: Romanian + +Date: Feb 2025 + +https://romicaprihor.blogspot.com/2025/02/simplex-chat-revolutia-mesageriei.html + +## New Super-Secure Messenger SimpleX Chat: Advantages and Disadvantages + +(Novyi super-zakhyshchenyi mesendzhder SimpleX Chat: perevahy y nedoliky) + +Kostiantyn Korsun / Censor.net + +Review + +This Ukrainian blog post reviews SimpleX Chat's advantages and disadvantages, noting it requires no phone number, email, or any user identifier - a feature rare among messengers. The author highlights that SimpleX stores all data locally with no cloud storage, supports one-time profile links, an incognito mode with random display names per contact, and invitation-only group chats, while also being fundamentally different from Signal, WhatsApp, Threema, Wire, and Session which all rely on some form of user ID. + +Image: korsun-simplex-expert-review.jpg + +Language: Ukrainian + +Date: 2023 + +https://censor.net/ua/blogs/3525466/novyyi-super-zahyschenyyi-mesendjer-simplex-chat-perevagy-yi-nedoliky + +## In Search of a Secure Messenger + +(U poshukakh bezpechnoho mesendzhera) + +KR. Labs Research + +Guide + +This Ukrainian guide to secure messaging describes SimpleX as a decentralized P2P messenger where users own their own servers, requiring no phone numbers or usernames for registration. It highlights end-to-end encryption by default with no metadata collection, positioning SimpleX as an advanced privacy solution appealing to activists and journalists. + +Image: kr-labs-secure-messenger.jpg + +Language: Ukrainian + +Date: Oct 24, 2023 + +https://research.kr-labs.com.ua/secure-and-privacy-messaging-apps-guide/ + +## SimpleX.Chat Is a Chat Network That Preserves Metadata Privacy + +(SimpleX.Chat e chat mrezha, koyato zapazva poveritelnostta na metadannite) + +Hristo Hristov / Medium + +Article + +This Bulgarian Medium article explains SimpleX as a decentralized client-server network that routes messages through disposable nodes while maintaining sender and receiver anonymity. It details the dual end-to-end encryption layers, DNS independence, and the absence of any global user identities, noting that network connections can only be discovered through observation of IP packet timing. + +Image: hristov-simplex-metadata.jpg + +Language: Bulgarian + +Date: Jun 5, 2022 + +https://hristo-hristov.medium.com/simplex-chat-%D0%B5-%D1%87%D0%B0%D1%82-%D0%BC%D1%80%D0%B5%D0%B6%D0%B0-%D0%BA%D0%BE%D1%8F%D1%82%D0%BE-%D0%B7%D0%B0%D0%BF%D0%B0%D0%B7%D0%B2%D0%B0-%D0%BF%D0%BE%D0%B2%D0%B5%D1%80%D0%B8%D1%82%D0%B5%D0%BB%D0%BD%D0%BE%D1%81%D1%82%D1%82%D0%B0-%D0%BD%D0%B0-%D0%BC%D0%B5%D1%82%D0%B0%D0%B4%D0%B0%D0%BD%D0%BD%D0%B8%D1%82%D0%B5-eb31243435a6 + +## Best Encrypted Chat Apps for 2025 + +(Nay-dobrite prilozheniya za kriptiran chat na 2025) + +Questona.com + +Comparison + +This Bulgarian article on encrypted chat apps describes SimpleX as suitable for additional privacy, claiming greater anonymity than Briar because it uses no ID numbers. It notes user names change constantly in group chats and supports disappearing messages, but flags an extremely small user base and server reliability issues as significant practical drawbacks. + +Image: questona-encrypted-chat.jpg + +Language: Bulgarian + +Date: Jan 29, 2025 + +https://questona.com/kriptiran-chat/ + +## SimpleX: Communication Client for Truly Secure and Anonymous Communication + +(SimpleX - komunikacijski klijent za stvarno sigurnu i anonimnu komunikaciju) + +Bug.hr + +Review + +This Croatian tech publication describes SimpleX as a highly secure, decentralized messaging app using Double Ratchet encryption with no user identifiers and local-only data storage. It highlights anonymous profiles, self-destructing messages, voice and video calls, and self-hosting capability, with one commenter noting it is "really top for privacy." + +Image: bug-hr-app-of-day.jpg + +Language: Croatian + +Date: Feb 2, 2024 + +https://www.bug.hr/appdana/simplex-komunikacijski-klijent-za-stvarno-sigurnu-i-anonimnu-komunikaciju-38082 + +## SimpleX Chat: An Open and Secure Chat + +(SimpleX Chat, en oppen och saker chatt) + +Oppet Moln + +Article + +This Swedish article introduces SimpleX as an open and secure chat that requires no global identity such as phone number, username, or IP address. It claims superior security compared to Signal, XMPP/Matrix, and other P2P protocols, while acknowledging that users must evaluate the security claims themselves and that usability tradeoffs exist. + +Image: oppet-moln-simplex.jpg + +Language: Swedish + +Date: May 12, 2022 + +https://oppetmoln.se/20220512/simplex-chat-en-oppen-och-saker-chatt/ + +## Top 10 Secure Messaging Platforms to Replace Telegram in Vietnam 2025 + +(Top 10 Nen Tang Nhan Tin Bao Mat Thay The Telegram Tai Viet Nam 2025) + +TuDongChat + +Article + +This Vietnamese article lists SimpleX as the first decentralized, open-source messaging platform that eliminates user identification entirely, requiring no phone number, email, or personal identifier. It positions SimpleX as the highest-security option among Telegram alternatives for Vietnamese users, ideal for journalists, activists, and anyone requiring no digital footprint. + +Image: tudongchat-simplex-vietnam.jpg + +Language: Vietnamese + +Date: May 26, 2025 + +https://tudongchat.com/blog/nen-tang-nhan-tin-bao-mat/ + +## SimpleX: The Safest Instant Messaging App? + +Free.com.tw + +Review + +This Taiwanese article introduces SimpleX as a privacy-focused messaging app launched in 2020 that requires no phone number or email, using decentralized networking instead of a single server. It notes the spam prevention benefit of link-based contact sharing and mentions the desktop version requires smartphone pairing, while flagging the small user base and lack of Traditional Chinese localization. + +Image: free-com-tw-simplex.jpg + +Language: Chinese (Tr.) + +Date: Mar 26, 2025 + +https://free.com.tw/simplex/ + +## SimpleX: Comparison of Secure Messaging Platforms + +FutaGuard + +Review + +This Chinese comparison of secure messaging platforms gives SimpleX the most favorable assessment, noting it supports message deletion, disappearing messages, channels, and bots. The author calls it "currently the only one with some promise" among privacy-focused alternatives to Telegram, praising its customizable relay servers while noting ongoing cross-device synchronization challenges. + +Image: futa-gg-simplex-comparison.jpg + +Language: Chinese (Tr.) + +Date: 2024 + +https://blog.futa.gg/1/simple-x/ + +## SimpleX Chat: Privacy Without Compromise + +(SimpleX Chat - prywatnosc bez kompromisow) + +opentech.guru + +Review + +This Polish article describes SimpleX as a fully open-source, decentralized messenger that eliminates user identifiers entirely, using separate one-way message queues per contact. It highlights double ratchet and post-quantum key exchange encryption, incognito mode, Tor connectivity, and availability across all major platforms including CLI, calling the servers "dumb pipes" with no knowledge of who connects with whom. + +Image: opentech-guru-simplex.jpg + +Language: Polish + +Date: Feb 2026 + +https://opentech.guru/simplex-chat-prywatnosc-bez-kompromisow/ + +## SimpleX and Matrix Are the Best Messengers. Period. + +(SimpleX i Matrix to najlepsze komunikatory. Kropka.) + +Programista Dla Pasji + +Comparison + +This Polish article evaluates multiple messengers, praising SimpleX for its decentralization where servers function merely as message relays, requiring no personal data. However, the author ultimately chooses Matrix over SimpleX for daily use due to Matrix's superior multi-device support, while acknowledging SimpleX's elegant simplicity and decentralized design. + +Image: programista-pasji-simplex.jpg + +Language: Polish + +Date: Sep 5, 2024 + +https://programistadlapasji.pl/simplex-i-matrix-to-najlepsze-komunikatory-kropka/ + +## Privacy Redefined: First Messenger Without User IDs + +(Prywatnosc zdefiniowana na nowo - Pierwszy komunikator bez identyfikatorow uzytkownikow) + +CONEA + +Article + +Image: conea-simplex-privacy.jpg + +Language: Polish + +Date: 2024 (estimated) + +http://conea.pl/aktualnosci/Prywatnosc-zdefiniowana-na-nowo---Pierwszy-komunikator-bez-identyfikator%C3%B3w-uzytkownik%C3%B3w-(ID)_158 + +## The Only TRULY Anonymous Chat: SimpleX + +(L'unica chat VERAMENTE anonima | SimpleX) + +rdwei + +Review, Video + +Image: italian-youtube-anonymous-chat.jpg + +Language: Italian + +Date: Apr 2026 + +https://www.youtube.com/watch?v=Eamu0Ys63l4 + +## SimpleX Chat Video Review + +PeerTube Uno Italia + +Review, Video + +Video review of SimpleX Chat v5.4 on Italian federated PeerTube instance. Covers connecting mobile and desktop apps via quantum-resistant protocol and improved group features. + +Image: peertube-uno-simplex.jpg + +Language: Italian + +Date: Mar 8, 2026 + +https://peertube.uno/w/ecD1N1HjNC4SBvmWusnTC2 + +## Security in a Box: Communication Tools + +(Ilgili Araclar - Communication Tools) + +Security in a Box / Front Line Defenders + +Guide + +This Turkish-language page from Security in a Box, a digital security guide, lists SimpleX Chat as a free, open-source secure messaging application. It notes SimpleX's decentralized network, lack of phone number requirements, absence of fixed user identifiers, end-to-end encryption by default, disappearing messages, and the Trail of Bits security assessment. + +Image: securityinabox-simplex-turkish.jpg + +Language: Turkish + +Date: 2024 + +https://securityinabox.org/tr/communication/tools/ + +## SimpleX for Iran + +Paskoocheh / ASL19 + +Review + +Paskoocheh, a platform providing circumvention tools for Iranian users, offers SimpleX Chat for Android download. The listing describes SimpleX as a privacy-focused messenger with end-to-end encryption, decentralized architecture, and anonymous communication capabilities, noting it is particularly useful for civil activists, journalists, and anyone requiring confidentiality. + +Image: paskoocheh-simplex-iran.jpg + +Language: Farsi + +Date: May 20, 2025 + +https://paskoocheh.com/tools/839/android.html + +## Most Secure Messaging Apps in the World + +Plaza.ir + +Comparison + +Image: plaza-ir-secure-messaging.jpg + +Language: Farsi + +Date: 2024 + +https://www.plaza.ir/241420/best-encrypted-messaging-apps + +## Vitalik Donates 128 ETH Each to Session and SimpleX, Supporting Privacy Communication Development + +(Vitalik jin chen xuan bu xiang Session he SimpleX ge juan zeng 128 ETH) + +TechFlow + +News + +This Chinese crypto newsletter reports on Vitalik Buterin donating 128 ETH each to Session and SimpleX Chat, emphasizing that encrypted communication is crucial for protecting digital privacy. It notes Vitalik identified permissionless account creation and metadata privacy as key priorities, while acknowledging that decentralization, multi-device support, and Sybil/DoS resistance remain significant technical challenges. + +Image: techflow-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 27, 2025 + +https://www.techflowpost.com/newsletter/detail_106673.html + +## Donating 256 ETH: Vitalik's Bet on Privacy Communication - Why Session and SimpleX? + +(Juan zeng 256 ETH, Vitalik ya zhu yin si tong xun: wei shen me shi Session he SimpleX?) + +BlockBeats + +News + +This Chinese crypto outlet analyzes Vitalik's 256 ETH donation to Session and SimpleX, explaining SimpleX's radical approach of using one-directional message queues with no global user IDs. It contrasts Session's Web3 token model with SimpleX's rejection of tokenization, and notes the donation was timed one day after the EU's Chat Control proposal threatening end-to-end encryption. + +Image: blockbeats-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 2025 + +https://www.theblockbeats.info/news/60368 + +## I Used Anonymous Chat Tools for a Week: These 3 Are Truly Safe + +(Wo yong le yi zhou ni ming liao tian gong ju, fa xian zhe 3 ge cai shi zhen zheng de an quan) + +Zhihu + +Review + +This Chinese article reviews three anonymous messaging tools - Session, SimpleX Chat, and TWT - from a week-long hands-on test. SimpleX is praised for its zero-metadata design where even the server cannot know who is communicating, with support for text, voice, and groups, but criticized for requiring manual connection-code sharing and a developer-oriented interface that makes onboarding friends difficult. + +Image: zhihu-anonymous-chat-tools.jpg + +Language: Chinese + +Date: 2025 + +https://zhuanlan.zhihu.com/p/1916814606019584862 + +## Truly Secure and Anonymous Social Communication Tools + +极客小白 + +Guide, Video + +Image: chinese-youtube-secure-tools.jpg + +Language: Chinese + +Date: 2024 + +https://www.youtube.com/watch?v=UXH6wUOqnfk + +## SimpleX Chat: Decentralized Privacy Messaging App Without User Identifiers + +(SimpleX Chat - wu xu yong hu biao shi fu de qu zhong xin hua yin si xiao xi ying yong) + +Kaiyuanapp.cn + +Review + +This Chinese open-source app directory describes SimpleX as a decentralized privacy messaging app that operates without any form of user identifiers. It highlights the SimpleX Messaging Protocol with relay servers that only store encrypted messages temporarily, self-hosting capability, disappearing messages, and anonymous group chats, while noting limitations in voice/video calling, occasional messaging delays, and high Android battery consumption. + +Image: kaiyuanapp-simplex.jpg + +Language: Chinese + +Date: Apr 22, 2025 + +https://kaiyuanapp.cn/simplex-chat-%E6%97%A0%E9%9C%80%E7%94%A8%E6%88%B7%E6%A0%87%E8%AF%86%E7%AC%A6%E7%9A%84%E5%8E%BB%E4%B8%AD%E5%BF%83%E5%8C%96%E9%9A%90%E7%A7%81%E6%B6%88%E6%81%AF%E5%BA%94%E7%94%A8/ + +## Pavol Luptak on Censorship, Security, and Nomadism + +(SP21 Pavol Luptak o cenzure, bezpecnosti, nomadstvi) + +Stackuj.cz Podcast + +Podcast + +Image: stackuj-luptak-podcast.jpg + +Language: Czech + +Date: May 22, 2022 + +https://www.youtube.com/watch?v=N0prtSOyeUU + +## Kostiantyn Korsun: Zaluzhnyi and Messengers + +(Kostyantyn Korsun: Zaluzhnyy i mesendzhery) + +Tverezo.info + +Article + +This Ukrainian article, written by Kostyantyn Korsun, discusses General Zaluzhny's essay on technology in modern warfare and the Ukrainian military's widespread reliance on Signal for encrypted communications despite formal prohibitions. While focused on Signal's role in military contexts and the US Defense Secretary's controversy over using Signal for classified data, the article addresses the broader topic of encrypted messengers in sensitive operational environments. + +Image: tverezo-korsun-zaluzhnyi.jpg + +Language: Ukrainian + +Date: 2025 + +https://tverezo.info/post/205151 + +## Top 10 Most Secure Messaging Apps in 2024 + +(Top 10 mest sikre besked-apps i 2024) + +Moyens I/O Denmark + +Comparison + +Image: moyens-dk-secure-apps.jpg + +Language: Danish + +Date: 2024 + +https://dk.moyens.net/apps/top-mest-sikre-besked-apps-signal-session-simplex/ + +## SimpleX Chat Tutorial Part 2: Fun Features + +(SimpleX Chat jian yi jiao cheng di er dan) + +BHB Community + +Guide + +This Chinese tutorial covers SimpleX Chat setup and advanced features including post-quantum encryption, database password creation, server provider selection, security code verification, and group management. It notes SimpleX has no message recall function except for group admins, voice/video calls require VPN access, and file sizes are limited to 1GB. + +Image: bhb-simplex-tutorial-2.jpg + +Language: Chinese + +Date: Mar 20, 2025 + +https://boyshelpboys.com/thread-6844.htm + +## Vitalik Donated 256 ETH to 2 Chat Apps You've Never Heard Of - What's He Betting On? + +(Vitalik juan le 256 ge ETH gei 2 ge ni mei ting guo de liao tian ruan jian, dao di zai ya zhu shen me?) + +BlockWeeks + +News + +This Chinese crypto article analyzes Vitalik's donation of 128 ETH each to Session and SimpleX, timed strategically one day after the EU's Chat Control proposal. It contrasts SimpleX's rejection of tokenization with Session's Web3 SESH token model, and notes Session's token surged over 450% following the announcement while SimpleX views speculation as counterproductive to privacy goals. + +Image: blockweeks-vitalik-simplex.jpg + +Language: Chinese + +Date: Nov 28, 2025 + +https://blockweeks.com/article/189510 + +## Looking Back at 2025: Top 10 Influential Figures in the Crypto Industry + +(Hui wang 2025: ying xiang jia mi hang ye de shi da nian du feng yun ren wu) + +Tencent News + +News + +This Chinese article reviewing the top 10 influential figures in the crypto industry for 2025 mentions SimpleX briefly in the context of Vitalik Buterin donating 128 ETH each to Session and SimpleX Chat. It quotes Vitalik emphasizing that digital privacy protection through encrypted messaging is crucial, citing permissionless account creation and metadata privacy as key development directions. + +Image: tencent-news-crypto-2025.jpg + +Language: Chinese + +Date: Dec 22, 2025 + +https://news.qq.com/rain/a/20251222A01LTC00 + +## SimpleX Chat: Next-Generation Secure Communication Tool + +Zhousa.com + +Review + +This Chinese article presents SimpleX as a next-generation secure communication tool with an identity-free design requiring no phone number, email, or username. It explains the single-direction message queue architecture with rotating receiver addresses, and positions the platform for ordinary users, journalists, activists, and anyone valuing privacy. + +Image: zhousa-simplex-review.jpg + +Language: Chinese + +Date: 2024 + +https://www.zhousa.com/archives/60915.html + +## From Privacy to Social: Web3 Needs an All-in-One Encrypted Social Application + +(Cong yin si dao she jiao: Web3 xu yao yi zhan shi jia mi she jiao ying yong) + +Golden Finance / KasTop + +News + +This Chinese article about Web3 encrypted social applications positions SimpleX as an important advancement in decentralized private communication, noting Vitalik's 128 ETH donation. While praising SimpleX's end-to-end encryption and lack of user identifiers, the article argues that comprehensive platforms combining privacy with full Web3 social features represent the next evolutionary stage beyond SimpleX. + +Image: golden-finance-web3-privacy.jpg + +Language: Chinese + +Date: Dec 17, 2025 + +https://www.kastop.com/Item/1995.aspx + +## SimpleX in Test + +(SimpleX im Test) + +Bitcoinlighthouse.de + +Review + +This German review tests SimpleX and explains how it fundamentally differs from other messengers by using no user IDs at all - not even random numbers - to protect metadata privacy. The article describes how SimpleX uses per-contact message queue identifiers instead of user identifiers, and highlights the incognito mode which assigns a different display name for each contact, preventing even contacts from proving they communicate with the same person. + +Image: bitcoinlighthouse-simplex-test.jpg + +Language: German + +Date: 2024 + +https://bitcoinlighthouse.de/privacy/simplex-im-test/ + +## German Language Pack for Mobile Privacy Messaging Service SimpleX + +(Jetzt auch auf Deutsch anonym chatten: Update fuer Messenger SimpleX) + +Heise Online + +News + +Heise, a major German tech publication, reports on SimpleX version 4.0 adding a German language pack. The update also introduced encryption of received and stored messages using SQLCipher, a TypeScript SDK for chatbot integration, and self-hosted WebRTC ICE server support. The article notes the founder was seeking donations for a $20,000 independent security audit. + +Image: heise-german-language-simplex.jpg + +Language: German + +Date: 2022 + +https://www.heise.de/news/Deutsches-Sprachpaket-fuer-den-mobilen-Privatsphaere-Nachrichtendienst-SimpleX-7278902.html + +## SimpleX 1.0.0: Decentralized, Privacy-Respecting and Encrypted Chat + +(SimpleX 1.0.0: Dezentraler, Privatsphaere achtender und verschluesselter Chat) + +Tarnkappe.info + +Community + +This German security forum post announces SimpleX's stable 1.0.0 release, describing its two-layer end-to-end encryption with double-ratchet algorithm and unidirectional simplex queues with unique encryption keys per queue. It emphasizes that establishing secure channels requires exchanging encryption keys through QR codes or invitations, and notes the terminal client's availability plus a DigitalOcean one-click server deployment option. + +Image: tarnkappe-simplex-1-0.jpg + +Language: German + +Date: Jan 13, 2022 + +https://tarnkappe.info/forum/t/simplex-1-0-0-dezentraler-privatsphaere-achtender-und-verschluesselter-chat/9812 + +## SimpleX Chat Overview + +GNU/Linux.ch + +Article + +This GNU/Linux-focused Swiss German article explains SimpleX's architecture as a decentralized network using one-way nodes for asynchronous message forwarding, avoiding any form of identity for message routing. It details two layers of end-to-end encryption with forward secrecy, and notes that servers do not retain user records, do not communicate with each other, and have no way to obtain a complete list of participating servers. + +Image: gnulinux-ch-simplex-overview.jpg + +Language: German + +Date: 2022 + +https://gnulinux.ch/simplex-chat + +## New Service: SimpleX Chat Server + +AdminForge + +Service + +AdminForge, a German community infrastructure provider, announces hosting a new SimpleX Chat server at simplex.adminforge.de. It describes SimpleX's Double-Ratchet encryption, multiple profile support, anonymous group participation, and explains that SMP servers function only as relays holding messages until recipients reconnect while XFTP file transfer servers retain uploads temporarily. + +Image: adminforge-simplex-server.jpg + +Language: German + +Date: Oct 4, 2023 + +https://adminforge.de/tools/neuer-service-simplex-chat-server/ + +## Privacy Handbook: SimpleX + +(Datenschutz-Handbuch) + +Privacy-Handbuch.de + +Guide + +This German privacy handbook describes SimpleX as using an innovative metadata-avoidance approach with no account IDs, where encrypted sessions are established directly between clients and servers merely route data packets. It notes SimpleX is particularly suited for concealing contact with specific individuals, though adoption remains limited for everyday use. + +Image: privacy-handbuch-simplex.jpg + +Language: German + +Date: 2024 + +https://www.privacy-handbuch.de/handbuch_89.htm + +## Signal's Brothers: Choosing From Five Most Private and Protected Messengers + +(Bratya Signal. Vybiraem iz pyati naibolee privatnykh i zashchishchyonnykh messendzherov) + +Xakep.ru + +Review + +This Russian security magazine calls SimpleX "the most interesting messenger in this selection, and also the most mysterious." The article highlights its anonymous registration requiring no phone number, federated architecture allowing user-hosted relay servers, and modern functionality including audio/video calls and disappearing messages. + +Image: xakep-simplex-signal-brothers.jpg + +Language: Russian + +Date: Aug 27, 2024 + +https://xakep.ru/2024/08/27/5-private-messengers/ + +## Decentralized Messengers: Choosing the Most Secure Way to Communicate + +(Detsentralizovannyye messendzhery: vybiraem samyj bezopasnyj sposob obshcheniya) + +SecurityLab.ru + +Guide + +This Russian security analysis categorizes SimpleX Chat as a decentralized and anonymous messenger prioritizing minimalism and maximum security. It notes SimpleX provides a high level of anonymity and eliminates metadata exposure, but acknowledges its restricted functionality and relatively low user awareness. + +Image: securitylab-decentralized-messengers.jpg + +Language: Russian + +Date: Sep 1, 2024 + +https://www.securitylab.ru/analytics/551634.php + +## WhatsApp and Telegram Alternative: Full Guide to Decentralized Chats + +(Alternativa WhatsApp i Telegram: polnyj gid po detsentralizovannym chatam) + +SecurityLab.ru + +Guide + +This Russian guide to decentralized chat alternatives describes SimpleX as using temporary anonymous message queue identifiers instead of traditional accounts, with no phone numbers or emails required. It notes the unfamiliar interaction model may confuse newcomers and potential delivery delays exist, but ranks SimpleX highly for anonymity and metadata protection. + +Image: securitylab-whatsapp-alternative.jpg + +Language: Russian + +Date: Aug 14, 2025 + +https://www.securitylab.ru/analytics/562397.php + +## Classification of Secure Messengers: New Projects + +(Klassifikatsiya zashchishchyonnykh messendzherov. Novyye proyekty) + +Habr / GlobalSign + +News + +This Habr article classifies SimpleX Chat as an experimental, "extremely privacy-focused" messenger for enthusiasts, describing it as the first messenger without user IDs of any kind. It identifies SimpleX as one of only two messengers (alongside Briar) that meets all security criteria on the author's evaluation scale. + +Image: habr-globalsign-classification.jpg + +Language: Russian + +Date: Feb 27, 2023 + +https://habr.com/ru/companies/globalsign/articles/719330/ + +## Alternatives to Signal and Telegram: Which Secure Messenger to Use Now? + +(Alternativy Signal i Telegram: kakoj bezopasnyj messendzher ispol'zovat' teper'?) + +hi-tech.mail.ru + +Review + +This Russian tech review highlights SimpleX's complete anonymity through having no telephone numbers or identifiers, and describes its support for audio/video calls, file transfer, disappearing messages, and personal relay servers. It notes all information is stored exclusively on user devices, with messages only temporarily held on relay servers during delivery. + +Image: hi-tech-mail-simplex.jpg + +Language: Russian + +Date: Sep 9, 2024 + +https://hi-tech.mail.ru/review/114266-alternativy-signal-i-telegram-kakoj-bezopasnyj-messendzher-ispolzovat/ + +## SimpleX Chat Review + +te-st.org + +Review + +This review by Russian digital rights organization Te-st characterizes SimpleX Chat as one of the first messengers where the list of missing security features is notably short. It emphasizes that the absence of UserID means users cannot be identified afterward, and unlike most secure messaging apps, no phone number is required. + +Image: te-st-simplex-review.jpg + +Language: Russian + +Date: Aug 21, 2023 + +https://te-st.org/2023/08/21/simplex-chat-review/ + +## VC.ru Article on SimpleX + +VC.ru + +Article + +This Russian user review describes SimpleX as having clear, high-quality voice and video calls, but identifies significant practical limitations including slow media file delivery, compressed photos, and sluggish video uploads. The author expresses skepticism about Microsoft's involvement with the project and concludes that the messenger functions more like a calling app than a complete communication platform. + +Image: vc-ru-simplex.jpg + +Language: Russian + +Date: 2024 + +https://vc.ru/id2160811/779184 + +## RuTube: SimpleX Chat Video + +RuTube + +Review, Video + +This is the second video in a Russian-language series about SimpleX Chat, demonstrating its primary functionality including messaging, encrypted voice and video calls, automatic message deletion timers, incognito mode, and database backup capabilities. + +Image: rutube-simplex-video.jpg + +Language: Russian + +Date: 2024 + +https://rutube.ru/video/b3dcb8869291d7de55596392c05aa24c/ + +## SimpleX: Messaging Proof Against the Curious + +(SimpleX: La mensajeria a prueba de curiosos) + +Francisco Barral + +Article + +This Spanish article explains that SimpleX Chat uses no user identifiers and instead generates unique, temporary addresses for each connection. It describes the app's use of end-to-end encryption via the Signal Protocol, peer-to-peer connections where possible, and privacy features including disappearing messages, multiple chat profiles, and incognito mode. + +Image: franciscobarral-simplex.jpg + +Language: Spanish + +Date: 2024 + +https://franciscobarral.es/simplex-la-mensajeria-a-prueba-de-curiosos/ + +## SimpleX Chat: Secure Messaging and Decentralized Communities + +(SimpleX Chat: Mensajeria segura y comunidades descentralizadas) + +El Ecosistema Startup + +Article + +This Spanish startup-focused article presents SimpleX as a fully decentralized messaging platform with independent security audits completed in 2022 and 2024. It highlights practical applications for startup founders and community managers who need confidential discussions without depending on centralized platforms, with availability across iOS, Android, macOS, Linux, and Windows. + +Image: ecosistemastartup-simplex.jpg + +Language: Spanish + +Date: 2024 + +https://ecosistemastartup.com/simplex-chat-mensajeria-segura-y-comunidades-descentralizadas/ + +## Secure Messaging Apps: Complete Technical Analysis + +(Apps de Mensajeria Seguras: Analisis Tecnico Completo) + +EsGeeks + +Comparison + +This Spanish technical analysis describes SimpleX's approach to radical metadata reduction through ephemeral addresses and message queues specific to each contact, preventing reconstruction of social graphs. It rates SimpleX as suitable for users with strict privacy models and advanced technical knowledge, noting that while IP addresses remain visible to relay servers, using Tor resolves this. + +Image: esgeeks-most-secure-app.jpg + +Language: Spanish + +Date: 2024 + +https://esgeeks.com/app-mensajeria-mas-segura/ + +## Most Secure Decentralized Messengers + +(Mensajeros descentralizados mas seguros) + +EsGeeks + +Comparison + +This Spanish article on secure decentralized messengers categorizes SimpleX Chat as focusing on minimalism and maximum security, with a high level of anonymity and absence of metadata. It notes SimpleX suffers from limited functionality and low popularity compared to other messaging platforms. + +Image: esgeeks-decentralized-messengers.jpg + +Language: Spanish + +Date: 2024 + +https://esgeeks.com/mensajeros-descentralizados-mas-seguros/ + +## SimpleX Chat: The Messaging App + +(SimpleX Chat: la app de mensajeria segura sin identificadores de usuario) + +Computekni + +Review + +This Spanish article describes SimpleX Chat as the first messaging platform without user identifiers of any kind, using end-to-end encryption and QR codes for private connections. It highlights the app's availability on iOS, Android, and F-Droid, and notes its open-source codebase allows public inspection and quick resolution of security issues. + +Image: computekni-simplex-chat.jpg + +Language: Spanish + +Date: May 2023 + +https://www.computekni.com/2023/05/simplex-chat-la-app-de-mensajeria.html + +## This Is How This Secure Messaging App Works Without User Identifiers + +(Asi funciona esta app de mensajeria segura que carece de identificadores para los usuarios) + +WWWhatsnew + +News + +This Spanish tech news site explains that SimpleX Chat delivers messages without using sender or recipient identifiers, relying on the SimpleX Messaging Protocol (SMP) with persistent queues. It describes how users generate unique invitation codes for each contact, with messages stored directly on devices rather than centralized servers. + +Image: wwwhatsnew-simplex.jpg + +Language: Spanish + +Date: Aug 13, 2022 + +https://wwwhatsnew.com/2022/08/13/asi-funciona-esta-app-de-mensajeria-segura-que-carece-de-identificadores-para-los-usuarios/ + +## Telegram Privacy Alternatives + +(Conoce las alternativas a Telegram de mensajeria encriptada y segura) + +CriptoNoticias + +Article + +This Spanish crypto news article presents SimpleX Chat as a privacy-focused alternative to Telegram, noting that developers believe persistent alphanumeric identifiers compromise privacy. It highlights SimpleX's use of temporary anonymous message queue identifiers and single-use QR codes that reduce traceability. + +Image: criptonoticias-telegram-alternatives.jpg + +Language: Spanish + +Date: 2024 + +https://www.criptonoticias.com/tecnologia/alternativas-telegram-privacidad-mensajeria/ + +## SimpleX Chat Tutorial + +Bitcoin.ar + +Guide + +This tutorial from ONG Bitcoin Argentina presents SimpleX as "the first mailbox without user identification," launched in 2021. It notes that while SimpleX includes standard messaging features, its ergonomics remain less fluid than WhatsApp or Signal and can be more restrictive when adding contacts, positioning it as suitable for privacy-conscious users willing to sacrifice daily convenience. + +Image: bitcoin-ar-simplex-tutorial.jpg + +Language: Spanish + +Date: 2024 + +https://bitcoin.ar/tutoriales/simplex-chat/ + +## These Are the Most Secure Messaging Apps According to a Criminologist + +(Estas son las aplicaciones de mensajeria mas seguras segun una criminologa) + +Noticias de Navarra + +News + +This Spanish news article reports that criminologist Maria Aperador highlights SimpleX as the most secure messaging application, citing its lack of user identifiers (not even random ones), end-to-end encryption, and impossibility of data tracking. She emphasizes that SimpleX contains no metadata and the app doesn't know who you are or where messages originate. + +Image: noticiasnavarra-simplex-criminologist.jpg + +Language: Spanish + +Date: Nov 25, 2024 + +https://www.noticiasdenavarra.com/ciencia-y-tecnologia/2024/11/25/estas-son-aplicaciones-mensajeria-mas-seguras-segun-una-criminologa-8972947.html + +## Step-by-Step SimpleX Chat Guide + +(Passo a passo do aplicativo SimpleX Chat) + +Alex Emidio / Substack + +Guide + +This Portuguese step-by-step guide covers SimpleX Chat's backup and data recovery features, including database encryption with user-created passwords and export of encrypted backups. It emphasizes the local-first approach where contacts and messages are stored on the user's device, and warns that the database password cannot be changed if lost. + +Image: alexemidio-substack-simplex.jpg + +Language: Portuguese + +Date: May 26, 2024 + +https://alexemidio.substack.com/p/passo-a-passo-do-aplicativo-simplexchat + +## Ethereum Founder Donates R$4.2 Million to Privacy-Focused Messengers + +(Fundador do Ethereum doa R$ 4,2 milhoes para mensageiros focados em privacidade) + +LiveCoins + +News + +This Brazilian article reports that Vitalik Buterin donated 128 ETH (approximately R$2.1 million) to SimpleX Chat as part of a larger donation to privacy-focused messengers. It notes Buterin acknowledged SimpleX still needs to address challenges including multi-device support and defense against Sybil/DoS attacks without requiring phone numbers. + +Image: livecoins-vitalik-simplex.jpg + +Language: Portuguese + +Date: Nov 2025 + +https://livecoins.com.br/fundador-do-ethereum-doa-r-42-milhoes-para-mensageiros-focados-em-privacidade-baixe-e-use/ + +## Top Most Secure Messaging Apps: Signal, Session, SimpleX + +(Top aplicativos de mensagens mais seguros) + +Moyens I/O Portugal + +Comparison + +Image: moyens-pt-secure-apps.jpg + +Language: Portuguese + +Date: 2024 + +https://pt.moyens.net/aplicativos/top-aplicativos-mensagens-mais-seguros-signal-session-simplex/ + +## SimpleX: How to Use the World's Most Private Messaging App + +(Simplex: como usar o app de mensagens mais privado do mundo) + +Soberano News + +Guide + +This Portuguese guide describes SimpleX as designed to not know who you are, with whom you speak, or when you speak, using blind relay servers that operate as message forwarders without identifying senders or recipients. It acknowledges the app may be slower and have a less polished design than competitors, making it ideal for journalists, activists, and those concerned with digital surveillance. + +Image: soberano-simplex-guide.jpg + +Language: Portuguese + +Date: 2024 + +https://soberano.news/guias-e-ferramentas/simplex-como-usar-o-app-de-mensagens-mais-privado-do-mundo/ + +## How to Install SimpleX Chat Messenger on Linux via Flatpak + +(Como instalar o mensageiro Simplex Chat no Linux via Flatpak) + +Edivaldo Brito + +Guide + +This Portuguese tutorial explains how to install SimpleX Chat on Linux via Flatpak, describing it as a private, open-source encrypted messenger with no user IDs. It lists SimpleX's features including end-to-end encrypted messages, files, images, audio/video calls, secret group chats, instant private notifications, and portable chat profiles, while emphasizing that SimpleX uses no phone numbers or other user identifiers and stores all data on client devices. + +Image: edivaldobrito-simplex-flatpak.jpg + +Language: Portuguese + +Date: 2024 + +https://www.edivaldobrito.com.br/como-instalar-o-mensageiro-simplex-chat-no-linux-via-flatpak/ + +## Top 10 Best Messaging Apps for Secure, Anonymous and Privacy-Respecting Chat + +(Le 10 migliori app di messaggistica per chat sicure, anonime e rispettose della privacy) + +Jacopo Coccia + +Comparison + +This Italian article lists the 10 best messaging apps for secure and anonymous chats, mentioning apps like Threema, Signal, and Wire that offer end-to-end encryption and zero-log policies. SimpleX Chat is not specifically named in the available excerpt, which focuses on the general landscape of privacy-focused messaging and the growing need for data protection. + +Image: jacopococcia-simplex-top10.jpg + +Language: Italian + +Date: 2024 + +https://www.jacopococcia.com/10-migliori-app-messaggistica-per-chat-sicure-anonime-privacy/ + +## What Is SimpleX Chat? + +No Trust Verify + +Article + +This article from the NoTrustVerify blog describes SimpleX as the first messaging platform that doesn't require any login, using the SimpleX Messaging Protocol (SMP) with one-way message queues. It highlights features like incognito mode with random names per contact and live message typing visibility, while acknowledging that multi-device synchronization and large group management still need improvement. + +Image: notrustverify-blog-simplex.jpg + +Language: English + +Date: 2024 + +https://blog.notrustverify.ch/what-is-simplex-chat + +## Interview With the Author of SimpleX Chat: The Most Secure Messaging by Design + +GatoOscuro + +Interview + +This interview with SimpleX founder Evgeny Poberezkin covers the protocol's design beginning in 2020 and mobile app launch in March 2022. Poberezkin acknowledges the "100% private" claim is aspirational marketing rather than absolute truth, and explains that the fully open-source, decentralized architecture prevents government backdoors from compromising the entire network. + +Image: gatooscuro-interview-english.jpg + +Language: English + +Date: 2024 + +https://gatooscuro.xyz/interview-with-the-author-of-simplex-chat-the-most-secure-messaging-by-design/ + +## Safe and Private Messaging Apps Similar to Signal + +Factually + +Comparison + +This product review compares secure messaging apps similar to Signal, noting that Session and SimpleX prioritize privacy-first engineering over mainstream polish. SimpleX is highlighted for avoiding phone-number registration and using no persistent user IDs, offering stronger anonymity than Signal at the cost of convenience and sometimes reliability, while Session uses onion routing which can cause delays and missed notifications. + +Image: factually-signal-alternatives.jpg + +Language: English + +Date: 2026 + +https://factually.co/product-reviews/electronics-tech/safe-private-messaging-apps-similar-to-signal-84872a + +## Best Secure Messaging Apps 2025: Private Chat Reviews + +Tileris + +Comparison + +Image: youtube-best-secure-2025.jpg + +Language: English + +Date: Jul 30, 2025 + +https://www.youtube.com/watch?v=GH4u8pQLY90 + +## SimpleX: Best Private Messenger? + +Tom Spark's Reviews + +Review, Video + +Image: youtube-best-private-messenger.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=PbYm1G-QVUc + +## SimpleX Chat Tutorial + +How to use apps? + +Guide, Video + +Image: youtube-simplex-tutorial.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=X7CJlbBJNcc + +## SimpleX Chat Review + +QuickLearn + +Review, Video + +Image: youtube-simplex-review-2024.jpg + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=cGGrRnXAh1w + +## SimpleX Chat: Settings and Usage Guide + +(匿名メッセージアプリ - Simple X Chat の設定と使い方) + +ひとりかくれんぼ / note.com + +Guide + +This Japanese article covers SimpleX Chat's settings and usage, including configuration of global chat settings for complete message deletion, disabling link previews, activating SimpleX Lock with a passcode, and enabling self-destruct mode. The author recommends using a VPN alongside SimpleX for genuine anonymity and stresses that message deletion requires mutual consent between users. + +Image: notecom-deeplife-simplex.jpg + +Language: Japanese + +Date: 2024 + +https://note.com/deeplife/n/n84b9e0a75ac5 + +## How to Bypass SimpleX Chat Blocking + +dept.one + +Article + +This Japanese article explains that SimpleX Chat was blocked in Russia in September 2024 due to its confidentiality and security features, and provides three methods to bypass the block: using a developer-provided proxy, a third-party SOCKS proxy, or a local proxy app like Orbot. It describes SimpleX as a highly secure messenger with no user identification, decentralized architecture, and scrambled message ordering to prevent timing attacks. + +Image: dept-one-simplex-memo.jpg + +Language: Japanese + +Date: 2024 + +https://dept.one/memo/simplex-chat/ + +## SimpleX Chat: Another Kusaimara Article + +Kusaimara + +Article + +This Japanese blog post announces that SimpleX Chat added Japanese language support in version 5.1 released in late May 2023. It notes SimpleX doesn't assign unique user IDs and supports Tor network communication, while acknowledging that account portability requires database passphrase export rather than simple login across devices. + +Image: kusaimara-simplex-3.jpg + +Language: Japanese + +Date: Jun 2023 + +https://kusaimara.net/2023/06/444 + +## Vitalik Buterin Donates ETH to Privacy Apps + +Coinspeaker + +News + +This Japanese crypto news article reports that Vitalik Buterin donated 128 ETH to SimpleX Chat as part of a privacy-focused initiative. It describes SimpleX's one-way message queue design that eliminates global identifiers and enables account creation without linking personal information like phone numbers. + +Image: coinspeaker-jp-vitalik.jpg + +Language: Japanese + +Date: Nov 2025 + +https://www.coinspeaker.com/jp/vitalik-buterin-donates-eth-privacy-apps/ + +## Yahoo Japan: Vitalik Buterin Donation Coverage + +Yahoo Japan News + +News + +Image: yahoo-japan-vitalik-simplex.jpg + +Language: Japanese + +Date: Nov 2025 + +https://news.yahoo.co.jp/articles/a55ac83411dcaca913007857948d335b0ed4d21a + +## Encrypted Messengers: Comparison + +(Encrypted messengers - comparison) + +Juraj Bednar + +Comparison + +This encrypted messenger comparison describes SimpleX as the youngest app in the review, using asymmetric connections through QR codes or URLs that make it difficult to correlate sending and receiving patterns. The reviewer notes the interface shows signs of ongoing development and some reliability issues, and suggests SimpleX suits users prioritizing anonymity for temporary rather than permanent communications. + +Image: bednar-encrypted-messengers-en.jpg + +Language: English + +Date: May 3, 2022 + +https://juraj.bednar.io/en/blog-en/2022/05/03/encrypted-messengers-comparison/ + +## SimpleX Chat on IQ.wiki + +IQ.wiki + +Review + +This IQ.wiki page provides a comprehensive overview of SimpleX Chat, covering its custom protocols (SimpleXMQ and SMP), end-to-end encryption with the Signal Double Ratchet algorithm, and post-quantum resistant encryption via CRYSTALS-Kyber. It notes key milestones including the v1 mobile release in 2022, Jack Dorsey's investment in August 2024, and planned Community Vouchers utility token system for 2026. + +Image: iqwiki-simplex-entry.jpg + +Language: English + +Date: 2024 + +https://iq.wiki/wiki/simplex-chat + +## SimpleX: How to Use the Most Private Messaging App + +(SimpleX: how to use the world's most private messaging app) + +Soberano News + +Guide + +This English guide explains SimpleX's approach of deleting metadata rather than just encrypting messages, using servers as blind relays with temporary one-way queues where servers cannot identify senders or recipients. It acknowledges the app can be slower and have a less polished interface than competitors, positioning it for journalists, activists, and those concerned about digital surveillance. + +Image: soberano-simplex-english.jpg + +Language: English + +Date: 2024 + +https://soberano.news/en/guides-and-tools/simplex-how-to-use-the-worlds-most-private-messaging-app/ + +## Libertarian Institute Article on SimpleX + +Institute for Libertarian Ideas / Japan + +Article + +This Libertarian Institute article emphasizes that SimpleX serves everyday citizens rather than just activists, providing significant privacy improvements with minimal sacrifice of convenience. It highlights identity flexibility where users can change display names per contact, hidden profiles behind password protection, and notes SimpleX functions as an anonymization network similar to Tor. + +Image: libertarian-institute-simplex.jpg + +Language: English + +Date: 2024 + +https://institute-for-libertarian.org/the-libertarian/2148/ + +## Messaging Applications on Medium + +(Mesajlasma Uygulamalari) + +Gizli Kalsin / Medium + +Article + +This Turkish Medium article lists SimpleX Chat as one of six recommended secure messaging applications, describing it as a decentralized instant messaging app that operates without phone numbers or user IDs. It notes users join conversations by scanning QR codes or clicking invite links, and the application provides complete anonymity. + +Image: gizlikalsin-medium-simplex.jpg + +Language: Turkish + +Date: Sep 3, 2023 + +https://medium.com/@gizlikalsin/mesajla%C5%9Fma-uygulamalar%C4%B1-74dc81d78737 + +## BlockTop: From Privacy to Social - Web3 and Encrypted Social Applications + +(Cong yin si dao she jiao: Web3 xu yao yi zhan shi jia mi she jiao ying yong) + +BlockTop + +News + +This Chinese article positions SimpleX alongside Session as representing truly decentralized privacy communication in the Web3 ecosystem. It highlights Vitalik Buterin's donation of 128 ETH to SimpleX and describes the platform's elimination of phone numbers, emails, or usernames in favor of the SimpleX Messaging Protocol, calling it potential "killer-level infrastructure" for the crypto industry. + +Image: blocktop-web3-privacy.jpg + +Language: Chinese + +Date: Dec 17, 2025 + +https://blocktop.cn/newsContent/1/176524 + + +## Better and More Secure Messenger Than Signal + +(Lepszy i bezpieczniejszy komunikator niz Signal) + +Selfhosty.pl + +Comparison + +This Polish article argues there are more secure messengers than Signal, noting that Signal's requirement for a phone number is a serious privacy weakness since phone numbers and IP addresses can be used to track users. The article recommends exploring alternatives that do not require a phone number for registration, positioning them as safer choices for users concerned about government surveillance and data breaches. + +Image: selfhosty-simplex-signal.jpg + +Language: Polish + +Date: 2024 + +https://selfhosty.pl/lepszy-i-bezpieczniejszy-komunikator-niz-signal/ + +## Security in Instant Messaging Services + +(Seguridad en servicios de mensajeria instantanea) + +Colectivo 406 + +Guide + +This Spanish security guide describes SimpleX Chat as using unidirectional message queues with end-to-end encryption including post-quantum algorithms, where servers act only as message bridges with minimal knowledge. It notes SimpleX is fully self-hostable unlike Signal, but acknowledges the complex changing queue addresses make maintaining long-term contacts more difficult. + +Image: colectivo-406-messaging-security.jpg + +Language: Spanish + +Date: Oct 27, 2024 + +https://406.neocities.org/a/apps_mensajeria/ + +## Anonymous Messaging Apps: Recommended List + +(Ni ming tong xin huan jing gou jian - messeji apuri osusume ichiran) + +ひとりかくれんぼ / note.com + +Review + +This Japanese article strongly recommends SimpleX Chat as the top choice for anonymous secure communication, emphasizing that it requires no phone number or email and doesn't use user identifiers or collect metadata. The author notes that user experience is mostly acceptable with only minor delays in call connections, and observes that SimpleX adoption remains relatively low in Japan. + +Image: deeplife-anonymous-apps-list.jpg + +Language: Japanese + +Date: 2024 (estimated) + +https://note.com/deeplife/n/ne7ffdd8a50cd + +## Building Your First Anonymous Digital Life: Fundamentals + +(Tokumei de ikiru tame no saisho no kankyo kochiku to kihon no hanashi) + +ひとりかくれんぼ / note.com + +Guide + +This Japanese article about building an anonymous digital life uses SimpleX Chat as the recommended contact method for readers seeking personalized guidance on privacy topics. It presents SimpleX solely as a secure, end-to-end encrypted communication channel without elaborating on its specific features. + +Image: deeplife-anonymous-life-guide.jpg + +Language: Japanese + +Date: 2024 (estimated) + +https://note.com/deeplife/n/nea6f1c4e08a7 + +## Privacy Protection: Instant Messaging + +Hacking Articles + +Guide + +This security-focused article describes SimpleX Chat as a privacy-focused messaging network that keeps profile and contact information hidden from its servers, using proprietary protocols designed with privacy as a core principle. It recommends SimpleX for lightweight, decentralized communication and anonymous use without central servers, while noting it may lack some advanced features compared to more established platforms. + +Image: hacking-articles-privacy-messaging.jpg + +Language: English + +Date: Sep 17, 2025 + +https://www.hackingarticles.in/privacy-protection-instant-messaging/ + +## SimpleX on Yggdrasil Network + +Yggdrasil Wiki + +Guide + +This Russian wiki page describes SimpleX as the only messenger that uses no user profile identifiers, not even random numbers, with fully open-source client and server code that anyone can self-host. It explains that SimpleX delivers messages using per-contact message queue identifiers rather than user IDs, with plans to automate queue rotation so that even conversations will have no long-term network-visible identifiers. + +Image: yggwiki-simplex-howto.jpg + +Language: Russian + +Date: 2024 (estimated) + +https://yggwiki.cc/social_media:simplex + +## SimpleX Chat Review + +Tests & Tips + +Review + +German review and testing site covering SimpleX Chat. Evaluates the messenger's privacy features, usability, and security properties as part of a broader mobile app testing catalog. + +Image: tests-tips-simplex-review.jpg + +Language: German + +Date: 2024 (estimated) + +https://tests.tips/en/?software/mobile-apps/simplex + +## Self-Host SimpleX Chat + +(Hospeda SimpleX Chat) + +CLASES DE LINUX + +Guide, Video + +Image: hospeda-simplex-chat.jpg + +Language: Spanish + +Date: Feb 10, 2024 + +https://www.youtube.com/watch?v=p1NF68KIt7M + +## SimpleX Chat: The Libertarian WhatsApp + +(SimpleX Chat - Whatsapp libertario) + +Paranoia + +Guide, Video + +Image: simplex-whatsapp-libertario.jpg + +Language: Portuguese + +Date: Jan 9, 2025 + +https://www.youtube.com/watch?v=4sOvaD-YZLU + +## SimpleX Chat Is a Revolution in Encrypted Communication + +Kryptoanarchista.cz + +Review + +This Czech crypto-anarchist blog presents SimpleX as an open-source encrypted messenger requiring no KYC, supporting text, voice, video calls, file sharing, and Tor connectivity. It notes the absence of persistent identities prevents tracking of user connections but acknowledges current limitations including the need for QR code exchange to start conversations and lack of cross-device synchronization. + +Image: kryptoanarchista-english-review.jpg + +Language: English + +Date: Aug 17, 2023 + +https://kryptoanarchista.cz/en/simplex-chat-is-a-revolution-in-encrypted-communication/ + +## Which Messengers Work in Russia and Which Are Blocked + +(Kakie messendzhery rabotayut v Rossii a kakie zablokirovany) + +AppVisor.ru + +Article + +This Russian article reports that SimpleX Chat was among several decentralized messengers blocked by Moscow City Court order in September 2024, alongside Briar, Session, and Verum. It notes the blocking was due to SimpleX's architecture that makes user tracking technically extremely difficult. + +Image: appvisor-blocked-messengers.jpg + +Language: Russian + +Date: Mar 6, 2026 + +https://appvisor.ru/post/news/kakie-messendzhery-rabotayut-v-rossii-a-kakie-zablokirovany/ + +## SimpleX: Ultra-Private Messaging + +(SimpleX, mensajeria ultraprivada) + +Bala Extra + +Podcast + +Image: bala-extra-simplex-podcast.jpg + +Language: Spanish + +Date: Nov 25, 2025 + +https://www.listennotes.com/podcasts/bala-extra/simplex-mensajer%C3%ADa-z3pHYeVJcA2/ + +## Protocols Not Platforms: NOSTR, BTC, SimpleX + +Closed Network Privacy Podcast + +Podcast + +Image: closed-network-protocols.jpg + +Language: English + +Date: 2024 (estimated) + +https://closednetwork.io/podcast/episode-38-protocols-not-platforms-nostr-btc-simplex/ + +## Introducing TorGuard's New Private VPN Cloud App: SimpleX Server + +TorGuard + +Service + +Image: torguard-simplex-server.jpg + +Language: English + +Date: 2025 (estimated) + +https://blog.torguard.net/introducing-torguards-new-private-vpn-cloud-app-simplex-server/ + +## SimpleX Chat Hosting and Services + +Taurix IT + +Service + +Taurix IT offers managed SimpleX Chat hosting services, including hosting their own SMP servers in separate locations and providing setup assistance for clients who want to self-host. The page emphasizes that SimpleX protects metadata which competitors like Signal and WhatsApp fail to adequately safeguard, making it particularly valuable for whistleblowers, journalists, and activists. + +Image: taurix-simplex-hosting.jpg + +Language: English + +Date: 2025 (estimated) + +https://www.taurix.net/simplex-chat/ + +## Taurix SimpleX Customer Service Bot + +Taurix IT + +Service + +This repository contains a Python-based customer service bot for SimpleX Chat that joins a specified control group and announces newly created customer chats. Control group members can then use commands to join customer conversations, automating the connection between customer service representatives and incoming inquiries. + +Image: taurix-customerservice-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://code.taurix.net/TaurixIT/simplex-customerservicebot + +## RoboSats Orderbook Alert Bot for SimpleX Chat + +TempleOfSats + +Service + +This open-source bot monitors the RoboSats peer-to-peer Bitcoin trading platform and sends alerts via SimpleX Chat when orders matching user-defined criteria (currency, premium rates, payment methods, trade amounts) are posted. Users manage alerts through simple commands, with a default 7-day alert lifetime and extension capabilities. + +Image: robosats-simplex-bot.jpg + +Language: English + +Date: 2024 (estimated) + +https://github.com/TempleOfSats/Robosats-Orderbook-Alert-Bot-for-SimpleX-Chat + +## SimpleX SMP Server Setup Guide + +Freedom Lab NYC + +Guide + +FreedomLab provides a tutorial for self-hosting a SimpleX SMP (Simple Messaging Protocol) server on a Debian/Ubuntu VPS with Tor support. The guide covers installation steps for running your own private, decentralized messaging relay server as part of the SimpleX network. + +Image: freedomlab-simplex-smp.jpg + +Language: English + +Date: 2025 (estimated) + +https://freedomlab.nyc/resources/simplex-smp/ + +## SimpleX Chat on LunarDAO Wiki + +LunarDAO + +Community + +This LunarDAO wiki page presents SimpleX as a privacy-focused federated messaging platform that requires no phone number or account creation, using one-way message queues instead of traditional accounts. It describes features including live typing indicators, voice/video calls, disappearing messages, self-destruct passcodes, and SOCKS proxy routing, while acknowledging challenges with multi-device synchronization and large group management. + +Image: lunardao-simplex-wiki.jpg + +Language: English + +Date: 2024 (estimated) + +https://wiki.lunardao.net/simplexchat.html + +## SimpleX Communities on Monerica + +Monerica + +Review + +This Monerica directory page lists several Monero-focused SimpleX Chat communities spanning multiple languages and regions, including groups for Monero discussion in Slovenian, German, Italian, and Hebrew. It includes an automated bot that sends hourly Monero price updates via SimpleX. + +Image: monerica-simplex-communities.jpg + +Language: English + +Date: 2025 (estimated) + +https://monerica.com/communities/simplex + +## SimpleX Chat: Censorship-Resistant Communication + +SplinterCon + +Review + +The SplinterCon anti-censorship project page describes SimpleX as a decentralized messenger that operates without user IDs, using end-to-end encryption for messages and file transfers. It highlights that SimpleX can run on the Tor network and allows users to deploy their own servers, with messages routed through servers that can operate without persistence. + +Image: splintercon-simplex-listing.jpg + +Language: English + +Date: 2025 (estimated) + +https://splintercon.net/project/simplex-chat/ + +## Tech Guides for Anarchists: End-to-End Encrypted Messaging + +AnarSec + +Guide + +This anarchist security guide notes SimpleX Chat uses decentralized servers (not peer-to-peer) with in-memory storage and no phone number requirement. It recommends Cwtch over SimpleX for text-only communication but suggests SimpleX as an acceptable option for voice and video calls, noting that content padding exists to frustrate correlation attacks via message size. + +Image: anarsec-e2ee-guide.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.anarsec.guide/posts/e2ee/ + +## Join Beginner Privacy on SimpleX + +Beginner Privacy + +Community + +The Beginner Privacy community selected SimpleX Chat as their primary communication platform for its strong privacy features. The page provides setup instructions for beginners across Linux, Mac, Windows, iOS, and Android, emphasizing accessibility through both graphical and command-line interfaces. + +Image: beginner-privacy-simplex-group.jpg + +Language: English + +Date: 2025 (estimated) + +https://beginnerprivacy.com/about/join-simplex-group/ + +## Sofwul.cz: E-Commerce with SimpleX Contact + +Sofwul + +Service + +This Czech e-commerce site lists SimpleX as one of several encrypted messaging contact options for reaching their customer support, alongside Threema, Session, Jami, and Teleguard. + +Image: sofwul-simplex-contact.jpg + +Language: Czech + +Date: 2025 (estimated) + +https://www.sofwul.cz/kontakty-messengery + +## SimpleX Chat Server Now Available for StartOS + +Start9 + +Service + +This Stacker News post announces SimpleX Chat server availability for StartOS (Start9's operating system), describing its support for direct messages, group messages, calls, and video calls with end-to-end encryption. Commenters praise it as "very promising tech" that is "plug and play right at the initial download" and comfortable to recommend to non-technical friends. + +Image: start9-simplex-startos.jpg + +Language: English + +Date: 2023 (estimated) + +https://stacker.news/items/196092 + +## Hack Liberty: Cypherpunk Community on SimpleX + +Hack Liberty + +Community + +Image: hackliberty-simplex-community.jpg + +Language: English + +Date: 2024 (estimated) + +https://links.hackliberty.org/ + +## Nowhere.moe: Privacy Hosting with SimpleX Infrastructure + +Nowhere.moe + +Service + +Privacy-focused hosting service operates community SimpleX SMP and XFTP relay servers with both clearnet and Tor onion addresses. Runs anonymous SimpleX chatrooms and provides tutorials on privacy and self-hosting. + +Image: nowhere-moe-simplex-servers.jpg + +Language: English + +Date: 2024 (estimated) + +https://nowhere.moe/ + +## XMRBazaar: Monero Marketplace on SimpleX + +XMRBazaar + +Service + +This Monero community post announces an unofficial XMRBazaar group on SimpleX Chat, providing two join links - one described as "possibly slower but uncensorable" using direct server protocols and another via the SimpleX Directory Service described as "faster but censorable." The group migrated from Matrix to SimpleX for discussing the Monero marketplace. + +Image: xmrbazaar-simplex-community.jpg + +Language: English + +Date: 2024 (estimated) + +https://monero.town/post/4623536 + +## Simplified Privacy: Tech Groups on SimpleX + +Simplified Privacy + +Community + +Simplified Privacy lists several active SimpleX Chat groups covering privacy, security, cryptocurrency (Monero and Bitcoin/Lightning Network), Nostr protocol, and a Switzerland-based privacy community. The page positions SimpleX alongside Session and XMPP as decentralized messaging platforms for privacy-conscious technical discussions. + +Image: simplified-privacy-techgroups.jpg + +Language: English + +Date: 2024 (estimated) + +https://simplifiedprivacy.com/techgroups/ + +## NBTV Community on SimpleX + +NBTV / Ludlow Institute + +Community + +The Ludlow Institute (NBTV) offers SimpleX as one of several community communication platforms alongside Signal and Element, describing it as an end-to-end encrypted messaging app for real-time discussions with other NBTV community members. + +Image: nbtv-simplex-community.jpg + +Language: English + +Date: 2025 (estimated) + +https://ludlowinstitute.org/community + +## Taurix "tellme" Bot + +Taurix IT + +Service + +TellMe is a notification system that monitors events (completed processes, server uptime, etc.) and sends alerts through SimpleX Chat or Matrix via websockets. It supports custom messages, process monitoring, periodic command output watching, and host ping availability tracking. + +Image: taurix-tellme-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://code.taurix.net/TaurixIT/tellme + +## BitList.co Bitcointalk Notification Bot + +BitList.co + +Service + +This Bitcointalk thread presents a notification bot for SimpleX Chat that monitors the forum for merit awards, mentions, quotes, specific users, topics, and keyword phrases. The developer notes that due to SimpleX's decentralized architecture, notifications may experience delays compared to Telegram alternatives, and data is stored for retry when users aren't connected. + +Image: bitlist-bitcointalk-bot.jpg + +Language: English + +Date: 2025 (estimated) + +https://bitcointalk.org/index.php?topic=5535681.0 + +## SimpleX Without Tears: Wrapped in Onion, Served via Tor + +nemental.de + +Guide + +This guide explains how to host a SimpleX SMP server as a Tor hidden service using Docker containers, making the relay accessible only through .onion addresses. It notes that while SimpleX already has strong privacy by design, adding Tor hides the server's public IP and eliminates DNS exposure, pulling the relay further out of reach of traditional tracking. + +Image: nemental-simplex-tor-guide.jpg + +Language: English + +Date: 2025 (estimated) + +https://nemental.de/simplex-without-tears-wrapped-in-onion-served-via-tor/ + +## SimpleX Server Docker Installation Guide (SMP/XFTP) + +Hack Liberty + +Guide + +This Hack Liberty forum guide provides detailed instructions for deploying SimpleX Chat's SMP and XFTP servers using Docker Compose, with dual-network configuration for both clearnet and Tor operation. It covers port configuration, security measures including running containers as unprivileged users, and emphasizes backing up private CA keys before deletion from the server. + +Image: hackliberty-docker-guide.jpg + +Language: English + +Date: 2024 (estimated) + +https://forum.hackliberty.org/t/simplex-server-docker-installation-guide-smp-xftp/140 + +## SimpleX Theme Archive + +SimpleX-Themes + +Community + +This community-maintained archive offers over 100 downloadable SimpleX Chat themes with preview screenshots, ranging from dark modes (AMOLED Black, Dracula) to colorful designs (Aurora Sunset, Catppuccin, neon synthwave). It is an independent community project not affiliated with SimpleX Chat Ltd, and users can submit their own custom themes. + +Image: simplex-themes-archive.jpg + +Language: English + +Date: 2025 (estimated) + +https://slcw.github.io/SimpleX-Themes/ + +## Goodbye Telegram, Welcome SimpleX! + +(Viszlat Telegram, udvozollek SimpleX!) + +HUP.hu + +Community + +This Hungarian article shares a user's year-long experience with SimpleX Chat, praising that it requires no email, phone number, username, or password to register. The author describes the SimpleX address system, the Directory Bot for finding public groups, and notes the tradeoff that deleting the app without a database backup means permanently losing your profile and all contacts, while also referencing the Wired article about neo-Nazis on SimpleX and the SimpleX developer's response defending privacy. + +Image: hup-hu-goodbye-telegram.jpg + +Language: Hungarian + +Date: Oct 2024 + +https://hup.hu/node/186622 + +## Sharing Several E2EE Open-Source Chat Apps 100x Safer Than Telegram + +(Fen xiang ji ge bi dian bao Telegram an quan 100 bei de duan dui duan jia mi kai yuan liao tian ruan jian) + +V2EX + +Community + +This Chinese V2EX forum post recommends SimpleX as having the highest encryption strength among reviewed E2EE messaging apps, highlighting that unlike Session's fixed IDs, SimpleX eliminates even fixed identifiers entirely. It notes SimpleX uses temporary anonymous pairing identifiers, creates independent fingerprints per conversation, supports Tor integration, and can be self-hosted. + +Image: v2ex-simplex-telegram-100x.jpg + +Language: Chinese + +Date: Aug 26, 2024 + +https://www.v2ex.com/t/1067954 + +## Sharing E2EE Open-Source Chat Apps 100x Safer Than Telegram + +(Fen xiang ji ge bi dian bao Telegram an quan 100 bei de kai yuan liao tian ruan jian) + +Matters.town + +Article + +This Chinese article recommends several end-to-end encrypted open-source chat apps that are far more secure than Telegram, written in the context of the Telegram founder's detention in France. It explains end-to-end encryption and mentions Session and SimpleX among the recommended alternatives that protect message content from being readable even by the relay servers. + +Image: matters-simplex-telegram-comparison.jpg + +Language: Chinese + +Date: Aug 26, 2024 + +https://matters.town/a/hvb8p0wepluy + +## Comparison of Instant Messengers + +Eylenburg + +Comparison + +This comprehensive messenger comparison states that SimpleX "probably has the best privacy and anonymity of all the messengers compared here," noting it requires no accounts or IDs and users connect via one-time invitation links. It identifies the main limitation as adoption - being a relatively new app with few users makes it impractical for most people who need to communicate with existing contacts. + +Image: eylenburg-messenger-comparison.jpg + +Language: English + +Date: Apr 2026 (updated) + +https://eylenburg.github.io/im_comparison.htm + +## SimpleX VS Quiet: Privacy and Security + +Hack Forums + +Comparison + +Image: hackforums-simplex-vs-quiet.jpg + +Language: English + +Date: 2024 (estimated) + +https://hackforums.net/blog/SimpleX-VS-Quiet + + +## MoneroKon 2024: SimpleX Chat talk + +SimpleX Chat + +Conference talk + +MoneroKon 2024 conference talk by Evgeny Poberezkin on how SimpleX Chat bridges the gap between privacy-focused and mass-market messaging applications. + +Language: English + +Date: 2024 + +https://www.youtube.com/watch?v=Fl-QS0-qENw + + +## SimpleXChat on Monero + Price and More! + +Monero Talk + +Interview, Video + +Monero Talk episode 239 featuring a discussion of SimpleX Chat's integration with Monero, payment incentives for server operators, and the broader privacy messaging landscape. + +Image: monero-talk-ep239-simplex.jpg + +Language: English + +Date: 2024 (estimated) + +https://www.youtube.com/live/quk4WY3fJCc?si=IQ0rutoHQpWtF2Un&t=3812 + + +## MoneroTopia 2026: The Future of SimpleX Network + +SimpleX Chat + +Conference talk, Video + +SimpleX Chat presentation at MoneroTopia 2026 conference in Mexico City, covering the latest developments in the SimpleX network and its alignment with the Monero privacy ecosystem. + +Image: monerotopia-2026-simplex.jpg + +Language: English + +Date: Feb 15, 2026 + +https://www.youtube.com/live/Alp-hVCoF7c?si=FqOTt1mCeQJVo-cM&t=16688 + +## SimpleX Chat - Simple Messaging With Unusually Good Privacy + +Bornhack 2023 conference, Chaos Computer Club + +Conference talk, Video + +This is a spontaneous talk about the relatively new (mobile apps released 2022) open source SimpleX Chat instant messenger protocol and software, and some reasons why it's a far better choice than in particular Matrix. + +Language: English + +Date: Aug 07, 2023 + +https://media.ccc.de/v/bornhack2023-56143-simplex-chat-simple-m diff --git a/docs/links/images/3arbi4tech-simplex-comparison.jpg b/docs/links/images/3arbi4tech-simplex-comparison.jpg new file mode 100644 index 0000000000..b8b78936b7 Binary files /dev/null and b/docs/links/images/3arbi4tech-simplex-comparison.jpg differ diff --git a/docs/links/images/ababtools-simplex-review.jpg b/docs/links/images/ababtools-simplex-review.jpg new file mode 100644 index 0000000000..792a8f777a Binary files /dev/null and b/docs/links/images/ababtools-simplex-review.jpg differ diff --git a/docs/links/images/adminforge-simplex-server.jpg b/docs/links/images/adminforge-simplex-server.jpg new file mode 100644 index 0000000000..300adaa08f Binary files /dev/null and b/docs/links/images/adminforge-simplex-server.jpg differ diff --git a/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg b/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg new file mode 100644 index 0000000000..e6f1f3d36e Binary files /dev/null and b/docs/links/images/aiutocomputerhelp-simplex-revolution.jpg differ diff --git a/docs/links/images/alexemidio-substack-simplex.jpg b/docs/links/images/alexemidio-substack-simplex.jpg new file mode 100644 index 0000000000..54c06afef0 Binary files /dev/null and b/docs/links/images/alexemidio-substack-simplex.jpg differ diff --git a/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg b/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg new file mode 100644 index 0000000000..f19ab863fb Binary files /dev/null and b/docs/links/images/ameblo-vpn53049-simplex-recommend.jpg differ diff --git a/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg b/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg new file mode 100644 index 0000000000..f19ab863fb Binary files /dev/null and b/docs/links/images/ameblo-vpn53049-simplex-revolutionary.jpg differ diff --git a/docs/links/images/anarsec-e2ee-guide.jpg b/docs/links/images/anarsec-e2ee-guide.jpg new file mode 100644 index 0000000000..e337f160f3 Binary files /dev/null and b/docs/links/images/anarsec-e2ee-guide.jpg differ diff --git a/docs/links/images/appvisor-blocked-messengers.jpg b/docs/links/images/appvisor-blocked-messengers.jpg new file mode 100644 index 0000000000..60ddcbcb49 Binary files /dev/null and b/docs/links/images/appvisor-blocked-messengers.jpg differ diff --git a/docs/links/images/bastion-military-messengers.jpg b/docs/links/images/bastion-military-messengers.jpg new file mode 100644 index 0000000000..e7cd4d50ac Binary files /dev/null and b/docs/links/images/bastion-military-messengers.jpg differ diff --git a/docs/links/images/bednar-encrypted-messengers-en.jpg b/docs/links/images/bednar-encrypted-messengers-en.jpg new file mode 100644 index 0000000000..552e49e93b Binary files /dev/null and b/docs/links/images/bednar-encrypted-messengers-en.jpg differ diff --git a/docs/links/images/bednar-encrypted-messengers.jpg b/docs/links/images/bednar-encrypted-messengers.jpg new file mode 100644 index 0000000000..80c79dd2b1 Binary files /dev/null and b/docs/links/images/bednar-encrypted-messengers.jpg differ diff --git a/docs/links/images/bednar-encrypted-notifications.jpg b/docs/links/images/bednar-encrypted-notifications.jpg new file mode 100644 index 0000000000..98f05ba5e0 Binary files /dev/null and b/docs/links/images/bednar-encrypted-notifications.jpg differ diff --git a/docs/links/images/beebom-best-secure-2026.jpg b/docs/links/images/beebom-best-secure-2026.jpg new file mode 100644 index 0000000000..c8a9ae637f Binary files /dev/null and b/docs/links/images/beebom-best-secure-2026.jpg differ diff --git a/docs/links/images/beginner-privacy-simplex-group.jpg b/docs/links/images/beginner-privacy-simplex-group.jpg new file mode 100644 index 0000000000..18918cc90e Binary files /dev/null and b/docs/links/images/beginner-privacy-simplex-group.jpg differ diff --git a/docs/links/images/bhb-simplex-tutorial-2.jpg b/docs/links/images/bhb-simplex-tutorial-2.jpg new file mode 100644 index 0000000000..a482c3c909 Binary files /dev/null and b/docs/links/images/bhb-simplex-tutorial-2.jpg differ diff --git a/docs/links/images/billionnapkin-simplex-review.jpg b/docs/links/images/billionnapkin-simplex-review.jpg new file mode 100644 index 0000000000..4d77bebada Binary files /dev/null and b/docs/links/images/billionnapkin-simplex-review.jpg differ diff --git a/docs/links/images/bitcoin-ar-simplex-tutorial.jpg b/docs/links/images/bitcoin-ar-simplex-tutorial.jpg new file mode 100644 index 0000000000..eacc967e52 Binary files /dev/null and b/docs/links/images/bitcoin-ar-simplex-tutorial.jpg differ diff --git a/docs/links/images/bitcoinlighthouse-simplex-test.jpg b/docs/links/images/bitcoinlighthouse-simplex-test.jpg new file mode 100644 index 0000000000..a5b9b82ff7 Binary files /dev/null and b/docs/links/images/bitcoinlighthouse-simplex-test.jpg differ diff --git a/docs/links/images/blockbeats-vitalik-simplex.jpg b/docs/links/images/blockbeats-vitalik-simplex.jpg new file mode 100644 index 0000000000..1b4f926ce4 Binary files /dev/null and b/docs/links/images/blockbeats-vitalik-simplex.jpg differ diff --git a/docs/links/images/blockweeks-vitalik-simplex.jpg b/docs/links/images/blockweeks-vitalik-simplex.jpg new file mode 100644 index 0000000000..583ac57689 Binary files /dev/null and b/docs/links/images/blockweeks-vitalik-simplex.jpg differ diff --git a/docs/links/images/brightcoding-privacy-by-design.jpg b/docs/links/images/brightcoding-privacy-by-design.jpg new file mode 100644 index 0000000000..2c2a593d75 Binary files /dev/null and b/docs/links/images/brightcoding-privacy-by-design.jpg differ diff --git a/docs/links/images/bug-hr-app-of-day.jpg b/docs/links/images/bug-hr-app-of-day.jpg new file mode 100644 index 0000000000..58a9b5d889 Binary files /dev/null and b/docs/links/images/bug-hr-app-of-day.jpg differ diff --git a/docs/links/images/chinese-youtube-secure-tools.jpg b/docs/links/images/chinese-youtube-secure-tools.jpg new file mode 100644 index 0000000000..e7bc007c8f Binary files /dev/null and b/docs/links/images/chinese-youtube-secure-tools.jpg differ diff --git a/docs/links/images/citadel-dispatch-cd196.jpg b/docs/links/images/citadel-dispatch-cd196.jpg new file mode 100644 index 0000000000..0300cfc74c Binary files /dev/null and b/docs/links/images/citadel-dispatch-cd196.jpg differ diff --git a/docs/links/images/cloudsek-best-secure-2026.jpg b/docs/links/images/cloudsek-best-secure-2026.jpg new file mode 100644 index 0000000000..ba021d7d83 Binary files /dev/null and b/docs/links/images/cloudsek-best-secure-2026.jpg differ diff --git a/docs/links/images/cnews-cz-simplex-privacy.jpg b/docs/links/images/cnews-cz-simplex-privacy.jpg new file mode 100644 index 0000000000..769b5697b8 Binary files /dev/null and b/docs/links/images/cnews-cz-simplex-privacy.jpg differ diff --git a/docs/links/images/cnews-telegram-simplex-migration.jpg b/docs/links/images/cnews-telegram-simplex-migration.jpg new file mode 100644 index 0000000000..25233d0b7c Binary files /dev/null and b/docs/links/images/cnews-telegram-simplex-migration.jpg differ diff --git a/docs/links/images/codeby-simplex-free-messengers.jpg b/docs/links/images/codeby-simplex-free-messengers.jpg new file mode 100644 index 0000000000..9777e8a0fd Binary files /dev/null and b/docs/links/images/codeby-simplex-free-messengers.jpg differ diff --git a/docs/links/images/coinspeaker-jp-vitalik.jpg b/docs/links/images/coinspeaker-jp-vitalik.jpg new file mode 100644 index 0000000000..e45ec7f1cf Binary files /dev/null and b/docs/links/images/coinspeaker-jp-vitalik.jpg differ diff --git a/docs/links/images/computekni-simplex-chat.jpg b/docs/links/images/computekni-simplex-chat.jpg new file mode 100644 index 0000000000..75730fa6c6 Binary files /dev/null and b/docs/links/images/computekni-simplex-chat.jpg differ diff --git a/docs/links/images/cryptoslate-buterin-analysis.jpg b/docs/links/images/cryptoslate-buterin-analysis.jpg new file mode 100644 index 0000000000..fabbe9814e Binary files /dev/null and b/docs/links/images/cryptoslate-buterin-analysis.jpg differ diff --git a/docs/links/images/cryptotimes-buterin-simplex.jpg b/docs/links/images/cryptotimes-buterin-simplex.jpg new file mode 100644 index 0000000000..1b92ef223a Binary files /dev/null and b/docs/links/images/cryptotimes-buterin-simplex.jpg differ diff --git a/docs/links/images/cyberinsider-most-secure-2026.jpg b/docs/links/images/cyberinsider-most-secure-2026.jpg new file mode 100644 index 0000000000..34d49afcc0 Binary files /dev/null and b/docs/links/images/cyberinsider-most-secure-2026.jpg differ diff --git a/docs/links/images/datacampus-self-hosting.jpg b/docs/links/images/datacampus-self-hosting.jpg new file mode 100644 index 0000000000..cda10213f3 Binary files /dev/null and b/docs/links/images/datacampus-self-hosting.jpg differ diff --git a/docs/links/images/dcinside-vpngate-simplex-translation.jpg b/docs/links/images/dcinside-vpngate-simplex-translation.jpg new file mode 100644 index 0000000000..cd60d11659 Binary files /dev/null and b/docs/links/images/dcinside-vpngate-simplex-translation.jpg differ diff --git a/docs/links/images/dcinside-wikileaks-simplex.jpg b/docs/links/images/dcinside-wikileaks-simplex.jpg new file mode 100644 index 0000000000..1527c05b37 Binary files /dev/null and b/docs/links/images/dcinside-wikileaks-simplex.jpg differ diff --git a/docs/links/images/ddpa-simplex-overview.jpg b/docs/links/images/ddpa-simplex-overview.jpg new file mode 100644 index 0000000000..a7017f6989 Binary files /dev/null and b/docs/links/images/ddpa-simplex-overview.jpg differ diff --git a/docs/links/images/deeplife-anonymous-apps-list.jpg b/docs/links/images/deeplife-anonymous-apps-list.jpg new file mode 100644 index 0000000000..ff68796e02 Binary files /dev/null and b/docs/links/images/deeplife-anonymous-apps-list.jpg differ diff --git a/docs/links/images/deeplife-anonymous-life-guide.jpg b/docs/links/images/deeplife-anonymous-life-guide.jpg new file mode 100644 index 0000000000..c31cc4a5af Binary files /dev/null and b/docs/links/images/deeplife-anonymous-life-guide.jpg differ diff --git a/docs/links/images/dept-one-simplex-memo.jpg b/docs/links/images/dept-one-simplex-memo.jpg new file mode 100644 index 0000000000..d0d8dbac17 Binary files /dev/null and b/docs/links/images/dept-one-simplex-memo.jpg differ diff --git a/docs/links/images/dev-community-privacy-setup-2026.jpg b/docs/links/images/dev-community-privacy-setup-2026.jpg new file mode 100644 index 0000000000..a1d5d2e1d0 Binary files /dev/null and b/docs/links/images/dev-community-privacy-setup-2026.jpg differ diff --git a/docs/links/images/digitalcourage-simplex-recommendation.jpg b/docs/links/images/digitalcourage-simplex-recommendation.jpg new file mode 100644 index 0000000000..badd42c295 Binary files /dev/null and b/docs/links/images/digitalcourage-simplex-recommendation.jpg differ diff --git a/docs/links/images/diolinux-simplex-messenger.jpg b/docs/links/images/diolinux-simplex-messenger.jpg new file mode 100644 index 0000000000..bcd5fcb04c Binary files /dev/null and b/docs/links/images/diolinux-simplex-messenger.jpg differ diff --git a/docs/links/images/ecosistemastartup-simplex.jpg b/docs/links/images/ecosistemastartup-simplex.jpg new file mode 100644 index 0000000000..d947ec934e Binary files /dev/null and b/docs/links/images/ecosistemastartup-simplex.jpg differ diff --git a/docs/links/images/edivaldo-brito-simplex-review.jpg b/docs/links/images/edivaldo-brito-simplex-review.jpg new file mode 100644 index 0000000000..bf3dfac3a7 Binary files /dev/null and b/docs/links/images/edivaldo-brito-simplex-review.jpg differ diff --git a/docs/links/images/edivaldobrito-simplex-flatpak.jpg b/docs/links/images/edivaldobrito-simplex-flatpak.jpg new file mode 100644 index 0000000000..ed8fc7db1f Binary files /dev/null and b/docs/links/images/edivaldobrito-simplex-flatpak.jpg differ diff --git a/docs/links/images/ekoreanews-telegram-alternatives.jpg b/docs/links/images/ekoreanews-telegram-alternatives.jpg new file mode 100644 index 0000000000..fcc70bec8d Binary files /dev/null and b/docs/links/images/ekoreanews-telegram-alternatives.jpg differ diff --git a/docs/links/images/eksisozluk-simplex.jpg b/docs/links/images/eksisozluk-simplex.jpg new file mode 100644 index 0000000000..2410f84666 Binary files /dev/null and b/docs/links/images/eksisozluk-simplex.jpg differ diff --git a/docs/links/images/esgeeks-decentralized-messengers.jpg b/docs/links/images/esgeeks-decentralized-messengers.jpg new file mode 100644 index 0000000000..8893a74ab9 Binary files /dev/null and b/docs/links/images/esgeeks-decentralized-messengers.jpg differ diff --git a/docs/links/images/esgeeks-most-secure-app.jpg b/docs/links/images/esgeeks-most-secure-app.jpg new file mode 100644 index 0000000000..b3c0973549 Binary files /dev/null and b/docs/links/images/esgeeks-most-secure-app.jpg differ diff --git a/docs/links/images/expressvpn-most-secure-2026.jpg b/docs/links/images/expressvpn-most-secure-2026.jpg new file mode 100644 index 0000000000..b5f3499d74 Binary files /dev/null and b/docs/links/images/expressvpn-most-secure-2026.jpg differ diff --git a/docs/links/images/franciscobarral-simplex.jpg b/docs/links/images/franciscobarral-simplex.jpg new file mode 100644 index 0000000000..831cbbc3b3 Binary files /dev/null and b/docs/links/images/franciscobarral-simplex.jpg differ diff --git a/docs/links/images/free-com-tw-simplex.jpg b/docs/links/images/free-com-tw-simplex.jpg new file mode 100644 index 0000000000..9d497e34ea Binary files /dev/null and b/docs/links/images/free-com-tw-simplex.jpg differ diff --git a/docs/links/images/freedom-tech-simplex-review.jpg b/docs/links/images/freedom-tech-simplex-review.jpg new file mode 100644 index 0000000000..6753595147 Binary files /dev/null and b/docs/links/images/freedom-tech-simplex-review.jpg differ diff --git a/docs/links/images/freedomlab-simplex-smp.jpg b/docs/links/images/freedomlab-simplex-smp.jpg new file mode 100644 index 0000000000..2a30c89e52 Binary files /dev/null and b/docs/links/images/freedomlab-simplex-smp.jpg differ diff --git a/docs/links/images/freedomnode-session-simplex.jpg b/docs/links/images/freedomnode-session-simplex.jpg new file mode 100644 index 0000000000..2244d7b5c5 Binary files /dev/null and b/docs/links/images/freedomnode-session-simplex.jpg differ diff --git a/docs/links/images/freeonline-simplex-review.jpg b/docs/links/images/freeonline-simplex-review.jpg new file mode 100644 index 0000000000..dd9465f021 Binary files /dev/null and b/docs/links/images/freeonline-simplex-review.jpg differ diff --git a/docs/links/images/gatooscuro-interview-english.jpg b/docs/links/images/gatooscuro-interview-english.jpg new file mode 100644 index 0000000000..0d4dc2e319 Binary files /dev/null and b/docs/links/images/gatooscuro-interview-english.jpg differ diff --git a/docs/links/images/gatooscuro-simplex-interview.jpg b/docs/links/images/gatooscuro-simplex-interview.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/gatooscuro-simplex-interview.jpg differ diff --git a/docs/links/images/gatooscuro-simplex-review.jpg b/docs/links/images/gatooscuro-simplex-review.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/gatooscuro-simplex-review.jpg differ diff --git a/docs/links/images/gazeta-mig-simplex-telegram.jpg b/docs/links/images/gazeta-mig-simplex-telegram.jpg new file mode 100644 index 0000000000..6d92871f9b Binary files /dev/null and b/docs/links/images/gazeta-mig-simplex-telegram.jpg differ diff --git a/docs/links/images/gnulinux-ch-simplex-overview.jpg b/docs/links/images/gnulinux-ch-simplex-overview.jpg new file mode 100644 index 0000000000..463e657142 Binary files /dev/null and b/docs/links/images/gnulinux-ch-simplex-overview.jpg differ diff --git a/docs/links/images/gnulinux-ch-simplex-smartphones.jpg b/docs/links/images/gnulinux-ch-simplex-smartphones.jpg new file mode 100644 index 0000000000..2f66de7130 Binary files /dev/null and b/docs/links/images/gnulinux-ch-simplex-smartphones.jpg differ diff --git a/docs/links/images/golden-finance-web3-privacy.jpg b/docs/links/images/golden-finance-web3-privacy.jpg new file mode 100644 index 0000000000..8ca86dc822 Binary files /dev/null and b/docs/links/images/golden-finance-web3-privacy.jpg differ diff --git a/docs/links/images/habr-anonymous-messengers.jpg b/docs/links/images/habr-anonymous-messengers.jpg new file mode 100644 index 0000000000..f1d1f926d4 Binary files /dev/null and b/docs/links/images/habr-anonymous-messengers.jpg differ diff --git a/docs/links/images/habr-anonymous-standard.jpg b/docs/links/images/habr-anonymous-standard.jpg new file mode 100644 index 0000000000..d310443ec3 Binary files /dev/null and b/docs/links/images/habr-anonymous-standard.jpg differ diff --git a/docs/links/images/habr-globalsign-classification.jpg b/docs/links/images/habr-globalsign-classification.jpg new file mode 100644 index 0000000000..e8f33d3183 Binary files /dev/null and b/docs/links/images/habr-globalsign-classification.jpg differ diff --git a/docs/links/images/habr-globalsign-p2p-chats.jpg b/docs/links/images/habr-globalsign-p2p-chats.jpg new file mode 100644 index 0000000000..b21515dea0 Binary files /dev/null and b/docs/links/images/habr-globalsign-p2p-chats.jpg differ diff --git a/docs/links/images/habr-simplex-first-messenger.jpg b/docs/links/images/habr-simplex-first-messenger.jpg new file mode 100644 index 0000000000..c4fc5edef4 Binary files /dev/null and b/docs/links/images/habr-simplex-first-messenger.jpg differ diff --git a/docs/links/images/hacking-articles-privacy-messaging.jpg b/docs/links/images/hacking-articles-privacy-messaging.jpg new file mode 100644 index 0000000000..959728197b Binary files /dev/null and b/docs/links/images/hacking-articles-privacy-messaging.jpg differ diff --git a/docs/links/images/hackspoiler-simplex-star.jpg b/docs/links/images/hackspoiler-simplex-star.jpg new file mode 100644 index 0000000000..09b70de9d1 Binary files /dev/null and b/docs/links/images/hackspoiler-simplex-star.jpg differ diff --git a/docs/links/images/heise-german-language-simplex.jpg b/docs/links/images/heise-german-language-simplex.jpg new file mode 100644 index 0000000000..eedabbce76 Binary files /dev/null and b/docs/links/images/heise-german-language-simplex.jpg differ diff --git a/docs/links/images/heise-simplex-100-release.jpg b/docs/links/images/heise-simplex-100-release.jpg new file mode 100644 index 0000000000..66bfea70ea Binary files /dev/null and b/docs/links/images/heise-simplex-100-release.jpg differ diff --git a/docs/links/images/heise-simplex-smartphone.jpg b/docs/links/images/heise-simplex-smartphone.jpg new file mode 100644 index 0000000000..f21904463d Binary files /dev/null and b/docs/links/images/heise-simplex-smartphone.jpg differ diff --git a/docs/links/images/heise-simplex-v3-apple.jpg b/docs/links/images/heise-simplex-v3-apple.jpg new file mode 100644 index 0000000000..1b39a768c8 Binary files /dev/null and b/docs/links/images/heise-simplex-v3-apple.jpg differ diff --git a/docs/links/images/heise-simplex-v4-private.jpg b/docs/links/images/heise-simplex-v4-private.jpg new file mode 100644 index 0000000000..eedabbce76 Binary files /dev/null and b/docs/links/images/heise-simplex-v4-private.jpg differ diff --git a/docs/links/images/help-net-security-product-showcase.jpg b/docs/links/images/help-net-security-product-showcase.jpg new file mode 100644 index 0000000000..5eae8399f2 Binary files /dev/null and b/docs/links/images/help-net-security-product-showcase.jpg differ diff --git a/docs/links/images/hi-tech-mail-simplex.jpg b/docs/links/images/hi-tech-mail-simplex.jpg new file mode 100644 index 0000000000..89b3a2810c Binary files /dev/null and b/docs/links/images/hi-tech-mail-simplex.jpg differ diff --git a/docs/links/images/hospeda-simplex-chat.jpg b/docs/links/images/hospeda-simplex-chat.jpg new file mode 100644 index 0000000000..d534a25095 Binary files /dev/null and b/docs/links/images/hospeda-simplex-chat.jpg differ diff --git a/docs/links/images/htr-simplex-review.jpg b/docs/links/images/htr-simplex-review.jpg new file mode 100644 index 0000000000..162d9c375b Binary files /dev/null and b/docs/links/images/htr-simplex-review.jpg differ diff --git a/docs/links/images/io-tech-secure-messaging.jpg b/docs/links/images/io-tech-secure-messaging.jpg new file mode 100644 index 0000000000..272f691514 Binary files /dev/null and b/docs/links/images/io-tech-secure-messaging.jpg differ diff --git a/docs/links/images/io-tech-simplex-forum.jpg b/docs/links/images/io-tech-simplex-forum.jpg new file mode 100644 index 0000000000..a4b3ce6362 Binary files /dev/null and b/docs/links/images/io-tech-simplex-forum.jpg differ diff --git a/docs/links/images/iode-degoogle-messaging.jpg b/docs/links/images/iode-degoogle-messaging.jpg new file mode 100644 index 0000000000..aff57e2d0c Binary files /dev/null and b/docs/links/images/iode-degoogle-messaging.jpg differ diff --git a/docs/links/images/iphon-fr-most-incognito.jpg b/docs/links/images/iphon-fr-most-incognito.jpg new file mode 100644 index 0000000000..cbb270bd88 Binary files /dev/null and b/docs/links/images/iphon-fr-most-incognito.jpg differ diff --git a/docs/links/images/iphone-ticker-simplex-privacy.jpg b/docs/links/images/iphone-ticker-simplex-privacy.jpg new file mode 100644 index 0000000000..2d28fd4adf Binary files /dev/null and b/docs/links/images/iphone-ticker-simplex-privacy.jpg differ diff --git a/docs/links/images/iqwiki-simplex-entry.jpg b/docs/links/images/iqwiki-simplex-entry.jpg new file mode 100644 index 0000000000..dedcb07cd5 Binary files /dev/null and b/docs/links/images/iqwiki-simplex-entry.jpg differ diff --git a/docs/links/images/italian-youtube-anonymous-chat.jpg b/docs/links/images/italian-youtube-anonymous-chat.jpg new file mode 100644 index 0000000000..52cb930c29 Binary files /dev/null and b/docs/links/images/italian-youtube-anonymous-chat.jpg differ diff --git a/docs/links/images/itforprof-alternatives-2026.jpg b/docs/links/images/itforprof-alternatives-2026.jpg new file mode 100644 index 0000000000..60224bef5d Binary files /dev/null and b/docs/links/images/itforprof-alternatives-2026.jpg differ diff --git a/docs/links/images/itsfoss-simplex-review.jpg b/docs/links/images/itsfoss-simplex-review.jpg new file mode 100644 index 0000000000..8469de2397 Binary files /dev/null and b/docs/links/images/itsfoss-simplex-review.jpg differ diff --git a/docs/links/images/ixbt-messengers-2026.jpg b/docs/links/images/ixbt-messengers-2026.jpg new file mode 100644 index 0000000000..aeda101fb5 Binary files /dev/null and b/docs/links/images/ixbt-messengers-2026.jpg differ diff --git a/docs/links/images/jacopococcia-simplex-top10.jpg b/docs/links/images/jacopococcia-simplex-top10.jpg new file mode 100644 index 0000000000..49163a5f80 Binary files /dev/null and b/docs/links/images/jacopococcia-simplex-top10.jpg differ diff --git a/docs/links/images/jornaldebrasilia-simplex-telegram.jpg b/docs/links/images/jornaldebrasilia-simplex-telegram.jpg new file mode 100644 index 0000000000..e06a18233c Binary files /dev/null and b/docs/links/images/jornaldebrasilia-simplex-telegram.jpg differ diff --git a/docs/links/images/joselito-simplex-vs-xmpp.jpg b/docs/links/images/joselito-simplex-vs-xmpp.jpg new file mode 100644 index 0000000000..bd33bd19d4 Binary files /dev/null and b/docs/links/images/joselito-simplex-vs-xmpp.jpg differ diff --git a/docs/links/images/kaiyuanapp-simplex.jpg b/docs/links/images/kaiyuanapp-simplex.jpg new file mode 100644 index 0000000000..999c2e1d5f Binary files /dev/null and b/docs/links/images/kaiyuanapp-simplex.jpg differ diff --git a/docs/links/images/kdroidwin-simplex-hatena-2.jpg b/docs/links/images/kdroidwin-simplex-hatena-2.jpg new file mode 100644 index 0000000000..2e51cef786 Binary files /dev/null and b/docs/links/images/kdroidwin-simplex-hatena-2.jpg differ diff --git a/docs/links/images/kdroidwin-simplex-hatena.jpg b/docs/links/images/kdroidwin-simplex-hatena.jpg new file mode 100644 index 0000000000..0be91eff23 Binary files /dev/null and b/docs/links/images/kdroidwin-simplex-hatena.jpg differ diff --git a/docs/links/images/kodnar-session-simplex.jpg b/docs/links/images/kodnar-session-simplex.jpg new file mode 100644 index 0000000000..2244d7b5c5 Binary files /dev/null and b/docs/links/images/kodnar-session-simplex.jpg differ diff --git a/docs/links/images/korben-wiki-messagerie.jpg b/docs/links/images/korben-wiki-messagerie.jpg new file mode 100644 index 0000000000..992a1642f5 Binary files /dev/null and b/docs/links/images/korben-wiki-messagerie.jpg differ diff --git a/docs/links/images/kr-labs-secure-messenger.jpg b/docs/links/images/kr-labs-secure-messenger.jpg new file mode 100644 index 0000000000..77a4d8d5bd Binary files /dev/null and b/docs/links/images/kr-labs-secure-messenger.jpg differ diff --git a/docs/links/images/kryptoanarchista-english-review.jpg b/docs/links/images/kryptoanarchista-english-review.jpg new file mode 100644 index 0000000000..3dd00b90b9 Binary files /dev/null and b/docs/links/images/kryptoanarchista-english-review.jpg differ diff --git a/docs/links/images/kryptoanarchista-simplex-revolution.jpg b/docs/links/images/kryptoanarchista-simplex-revolution.jpg new file mode 100644 index 0000000000..3dd00b90b9 Binary files /dev/null and b/docs/links/images/kryptoanarchista-simplex-revolution.jpg differ diff --git a/docs/links/images/kuketz-forum-simplex-security.jpg b/docs/links/images/kuketz-forum-simplex-security.jpg new file mode 100644 index 0000000000..90e5949483 Binary files /dev/null and b/docs/links/images/kuketz-forum-simplex-security.jpg differ diff --git a/docs/links/images/kuketz-group-chat-test.jpg b/docs/links/images/kuketz-group-chat-test.jpg new file mode 100644 index 0000000000..3a761b1b3b Binary files /dev/null and b/docs/links/images/kuketz-group-chat-test.jpg differ diff --git a/docs/links/images/kuketz-messenger-matrix.jpg b/docs/links/images/kuketz-messenger-matrix.jpg new file mode 100644 index 0000000000..3b33fad709 --- /dev/null +++ b/docs/links/images/kuketz-messenger-matrix.jpg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/links/images/kuketz-simplex-review.jpg b/docs/links/images/kuketz-simplex-review.jpg new file mode 100644 index 0000000000..3a761b1b3b Binary files /dev/null and b/docs/links/images/kuketz-simplex-review.jpg differ diff --git a/docs/links/images/kusaimara-session-vs-simplex.jpg b/docs/links/images/kusaimara-session-vs-simplex.jpg new file mode 100644 index 0000000000..2faf2e7ed6 Binary files /dev/null and b/docs/links/images/kusaimara-session-vs-simplex.jpg differ diff --git a/docs/links/images/kusaimara-simplex-3.jpg b/docs/links/images/kusaimara-simplex-3.jpg new file mode 100644 index 0000000000..b61c917779 Binary files /dev/null and b/docs/links/images/kusaimara-simplex-3.jpg differ diff --git a/docs/links/images/kusaimara-simplex-first-impressions.jpg b/docs/links/images/kusaimara-simplex-first-impressions.jpg new file mode 100644 index 0000000000..17e4c05bbc Binary files /dev/null and b/docs/links/images/kusaimara-simplex-first-impressions.jpg differ diff --git a/docs/links/images/lealternative-anonymous-apps.jpg b/docs/links/images/lealternative-anonymous-apps.jpg new file mode 100644 index 0000000000..52ab3d75e5 Binary files /dev/null and b/docs/links/images/lealternative-anonymous-apps.jpg differ diff --git a/docs/links/images/lealternative-simplex-review.jpg b/docs/links/images/lealternative-simplex-review.jpg new file mode 100644 index 0000000000..dc49ce2467 Binary files /dev/null and b/docs/links/images/lealternative-simplex-review.jpg differ diff --git a/docs/links/images/lemedia05-simplex-100.jpg b/docs/links/images/lemedia05-simplex-100.jpg new file mode 100644 index 0000000000..285311a0b5 Binary files /dev/null and b/docs/links/images/lemedia05-simplex-100.jpg differ diff --git a/docs/links/images/lemedia05-smartphone.jpg b/docs/links/images/lemedia05-smartphone.jpg new file mode 100644 index 0000000000..285311a0b5 Binary files /dev/null and b/docs/links/images/lemedia05-smartphone.jpg differ diff --git a/docs/links/images/libreselfhosted-simplex-overview.jpg b/docs/links/images/libreselfhosted-simplex-overview.jpg new file mode 100644 index 0000000000..dad31c112e --- /dev/null +++ b/docs/links/images/libreselfhosted-simplex-overview.jpg @@ -0,0 +1 @@ +downloads: 1Mdownloads1M \ No newline at end of file diff --git a/docs/links/images/linkedin-pt-simplex.jpg b/docs/links/images/linkedin-pt-simplex.jpg new file mode 100644 index 0000000000..bfa148b0c9 --- /dev/null +++ b/docs/links/images/linkedin-pt-simplex.jpg @@ -0,0 +1,8 @@ + diff --git a/docs/links/images/linux-magazin-simplex-privacy.jpg b/docs/links/images/linux-magazin-simplex-privacy.jpg new file mode 100644 index 0000000000..8d0e5b67eb Binary files /dev/null and b/docs/links/images/linux-magazin-simplex-privacy.jpg differ diff --git a/docs/links/images/livecoins-vitalik-simplex.jpg b/docs/links/images/livecoins-vitalik-simplex.jpg new file mode 100644 index 0000000000..1be58f86dc Binary files /dev/null and b/docs/links/images/livecoins-vitalik-simplex.jpg differ diff --git a/docs/links/images/marius-privacy-messengers-overview.jpg b/docs/links/images/marius-privacy-messengers-overview.jpg new file mode 100644 index 0000000000..2b58527f08 Binary files /dev/null and b/docs/links/images/marius-privacy-messengers-overview.jpg differ diff --git a/docs/links/images/matters-simplex-telegram-comparison.jpg b/docs/links/images/matters-simplex-telegram-comparison.jpg new file mode 100644 index 0000000000..0b73e2b773 Binary files /dev/null and b/docs/links/images/matters-simplex-telegram-comparison.jpg differ diff --git a/docs/links/images/midia-segura-simplex-review.jpg b/docs/links/images/midia-segura-simplex-review.jpg new file mode 100644 index 0000000000..604ef75151 Binary files /dev/null and b/docs/links/images/midia-segura-simplex-review.jpg differ diff --git a/docs/links/images/monerotopia-2026-simplex.jpg b/docs/links/images/monerotopia-2026-simplex.jpg new file mode 100644 index 0000000000..d30d1cd357 Binary files /dev/null and b/docs/links/images/monerotopia-2026-simplex.jpg differ diff --git a/docs/links/images/nbtv-simplex-review.jpg b/docs/links/images/nbtv-simplex-review.jpg new file mode 100644 index 0000000000..92a58f7f53 Binary files /dev/null and b/docs/links/images/nbtv-simplex-review.jpg differ diff --git a/docs/links/images/nemental-simplex-tor-guide.jpg b/docs/links/images/nemental-simplex-tor-guide.jpg new file mode 100644 index 0000000000..a616e7367b Binary files /dev/null and b/docs/links/images/nemental-simplex-tor-guide.jpg differ diff --git a/docs/links/images/netxhack-telegram-alternatives.jpg b/docs/links/images/netxhack-telegram-alternatives.jpg new file mode 100644 index 0000000000..f97aeedc1e Binary files /dev/null and b/docs/links/images/netxhack-telegram-alternatives.jpg differ diff --git a/docs/links/images/neweconomy-buterin-simplex.jpg b/docs/links/images/neweconomy-buterin-simplex.jpg new file mode 100644 index 0000000000..ac252bd168 Binary files /dev/null and b/docs/links/images/neweconomy-buterin-simplex.jpg differ diff --git a/docs/links/images/nicolas-forcet-comparison.jpg b/docs/links/images/nicolas-forcet-comparison.jpg new file mode 100644 index 0000000000..08b1f9223f Binary files /dev/null and b/docs/links/images/nicolas-forcet-comparison.jpg differ diff --git a/docs/links/images/niebezpiecznik-simplex-mention.jpg b/docs/links/images/niebezpiecznik-simplex-mention.jpg new file mode 100644 index 0000000000..e7ef78ee71 Binary files /dev/null and b/docs/links/images/niebezpiecznik-simplex-mention.jpg differ diff --git a/docs/links/images/nobsbitcoin-funding-v60.jpg b/docs/links/images/nobsbitcoin-funding-v60.jpg new file mode 100644 index 0000000000..9edfdabf14 Binary files /dev/null and b/docs/links/images/nobsbitcoin-funding-v60.jpg differ diff --git a/docs/links/images/nobsbitcoin-startos.jpg b/docs/links/images/nobsbitcoin-startos.jpg new file mode 100644 index 0000000000..d3d5b6501e Binary files /dev/null and b/docs/links/images/nobsbitcoin-startos.jpg differ diff --git a/docs/links/images/nobsbitcoin-v52-receipts.jpg b/docs/links/images/nobsbitcoin-v52-receipts.jpg new file mode 100644 index 0000000000..0570d3d227 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v52-receipts.jpg differ diff --git a/docs/links/images/nobsbitcoin-v53-desktop.jpg b/docs/links/images/nobsbitcoin-v53-desktop.jpg new file mode 100644 index 0000000000..ff5c8d1413 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v53-desktop.jpg differ diff --git a/docs/links/images/nobsbitcoin-v54-desktop.jpg b/docs/links/images/nobsbitcoin-v54-desktop.jpg new file mode 100644 index 0000000000..88733181aa Binary files /dev/null and b/docs/links/images/nobsbitcoin-v54-desktop.jpg differ diff --git a/docs/links/images/nobsbitcoin-v57-quantum.jpg b/docs/links/images/nobsbitcoin-v57-quantum.jpg new file mode 100644 index 0000000000..fafa208ea5 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v57-quantum.jpg differ diff --git a/docs/links/images/nobsbitcoin-v58-routing.jpg b/docs/links/images/nobsbitcoin-v58-routing.jpg new file mode 100644 index 0000000000..69a9bed4ca Binary files /dev/null and b/docs/links/images/nobsbitcoin-v58-routing.jpg differ diff --git a/docs/links/images/nobsbitcoin-v61.jpg b/docs/links/images/nobsbitcoin-v61.jpg new file mode 100644 index 0000000000..166fa11537 Binary files /dev/null and b/docs/links/images/nobsbitcoin-v61.jpg differ diff --git a/docs/links/images/notebookcheck-cn-simplex.jpg b/docs/links/images/notebookcheck-cn-simplex.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-cn-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-pt-simplex.jpg b/docs/links/images/notebookcheck-pt-simplex.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-pt-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-ru-simplex.jpg b/docs/links/images/notebookcheck-ru-simplex.jpg new file mode 100644 index 0000000000..edfdd31a33 Binary files /dev/null and b/docs/links/images/notebookcheck-ru-simplex.jpg differ diff --git a/docs/links/images/notebookcheck-simplex-succeeds.jpg b/docs/links/images/notebookcheck-simplex-succeeds.jpg new file mode 100644 index 0000000000..bce52f2e94 Binary files /dev/null and b/docs/links/images/notebookcheck-simplex-succeeds.jpg differ diff --git a/docs/links/images/notecom-deeplife-simplex.jpg b/docs/links/images/notecom-deeplife-simplex.jpg new file mode 100644 index 0000000000..9f3915bc7b Binary files /dev/null and b/docs/links/images/notecom-deeplife-simplex.jpg differ diff --git a/docs/links/images/noticiasnavarra-simplex-criminologist.jpg b/docs/links/images/noticiasnavarra-simplex-criminologist.jpg new file mode 100644 index 0000000000..9dae7ea2e7 Binary files /dev/null and b/docs/links/images/noticiasnavarra-simplex-criminologist.jpg differ diff --git a/docs/links/images/nowhere-moe-simplex-servers.jpg b/docs/links/images/nowhere-moe-simplex-servers.jpg new file mode 100644 index 0000000000..8db82ac277 Binary files /dev/null and b/docs/links/images/nowhere-moe-simplex-servers.jpg differ diff --git a/docs/links/images/opennet-simplex-65.jpg b/docs/links/images/opennet-simplex-65.jpg new file mode 100644 index 0000000000..3e6e6a0ded Binary files /dev/null and b/docs/links/images/opennet-simplex-65.jpg differ diff --git a/docs/links/images/opentech-guru-simplex.jpg b/docs/links/images/opentech-guru-simplex.jpg new file mode 100644 index 0000000000..756b411086 Binary files /dev/null and b/docs/links/images/opentech-guru-simplex.jpg differ diff --git a/docs/links/images/oppet-moln-simplex.jpg b/docs/links/images/oppet-moln-simplex.jpg new file mode 100644 index 0000000000..76ce1ab4f6 Binary files /dev/null and b/docs/links/images/oppet-moln-simplex.jpg differ diff --git a/docs/links/images/optout-improving-simplex.jpg b/docs/links/images/optout-improving-simplex.jpg new file mode 100644 index 0000000000..1ebff7959d Binary files /dev/null and b/docs/links/images/optout-improving-simplex.jpg differ diff --git a/docs/links/images/optout-simplex-s3e02.jpg b/docs/links/images/optout-simplex-s3e02.jpg new file mode 100644 index 0000000000..1ebff7959d Binary files /dev/null and b/docs/links/images/optout-simplex-s3e02.jpg differ diff --git a/docs/links/images/paflegeek-simplex-selfhost.jpg b/docs/links/images/paflegeek-simplex-selfhost.jpg new file mode 100644 index 0000000000..4d757dcb19 Binary files /dev/null and b/docs/links/images/paflegeek-simplex-selfhost.jpg differ diff --git a/docs/links/images/panorama-simplex-ultra-secret.jpg b/docs/links/images/panorama-simplex-ultra-secret.jpg new file mode 100644 index 0000000000..08a70ae03b Binary files /dev/null and b/docs/links/images/panorama-simplex-ultra-secret.jpg differ diff --git a/docs/links/images/paskoocheh-simplex-iran.jpg b/docs/links/images/paskoocheh-simplex-iran.jpg new file mode 100644 index 0000000000..df34741395 Binary files /dev/null and b/docs/links/images/paskoocheh-simplex-iran.jpg differ diff --git a/docs/links/images/peertube-uno-simplex.jpg b/docs/links/images/peertube-uno-simplex.jpg new file mode 100644 index 0000000000..afe8400f01 Binary files /dev/null and b/docs/links/images/peertube-uno-simplex.jpg differ diff --git a/docs/links/images/portuguese-simplex-hidden-portal.jpg b/docs/links/images/portuguese-simplex-hidden-portal.jpg new file mode 100644 index 0000000000..68315d9221 Binary files /dev/null and b/docs/links/images/portuguese-simplex-hidden-portal.jpg differ diff --git a/docs/links/images/portuguese-simplex-revolutionary.jpg b/docs/links/images/portuguese-simplex-revolutionary.jpg new file mode 100644 index 0000000000..c52c33bdd6 Binary files /dev/null and b/docs/links/images/portuguese-simplex-revolutionary.jpg differ diff --git a/docs/links/images/portuguese-simplex-tutorial.jpg b/docs/links/images/portuguese-simplex-tutorial.jpg new file mode 100644 index 0000000000..4c1462ec4f Binary files /dev/null and b/docs/links/images/portuguese-simplex-tutorial.jpg differ diff --git a/docs/links/images/portuguese-simplex-ultra-annihilation.jpg b/docs/links/images/portuguese-simplex-ultra-annihilation.jpg new file mode 100644 index 0000000000..778f78ddd7 Binary files /dev/null and b/docs/links/images/portuguese-simplex-ultra-annihilation.jpg differ diff --git a/docs/links/images/pplware-simplex-telegram.jpg b/docs/links/images/pplware-simplex-telegram.jpg new file mode 100644 index 0000000000..255dd9c7d5 Binary files /dev/null and b/docs/links/images/pplware-simplex-telegram.jpg differ diff --git a/docs/links/images/prihor-simplex-guide.jpg b/docs/links/images/prihor-simplex-guide.jpg new file mode 100644 index 0000000000..fef6aa033d Binary files /dev/null and b/docs/links/images/prihor-simplex-guide.jpg differ diff --git a/docs/links/images/privacy-guides-recommendation.jpg b/docs/links/images/privacy-guides-recommendation.jpg new file mode 100644 index 0000000000..df2c4d9785 Binary files /dev/null and b/docs/links/images/privacy-guides-recommendation.jpg differ diff --git a/docs/links/images/pro32-best-messengers-2026.jpg b/docs/links/images/pro32-best-messengers-2026.jpg new file mode 100644 index 0000000000..9c9b67688c Binary files /dev/null and b/docs/links/images/pro32-best-messengers-2026.jpg differ diff --git a/docs/links/images/programista-pasji-simplex.jpg b/docs/links/images/programista-pasji-simplex.jpg new file mode 100644 index 0000000000..82f2bf75fe Binary files /dev/null and b/docs/links/images/programista-pasji-simplex.jpg differ diff --git a/docs/links/images/questona-encrypted-chat.jpg b/docs/links/images/questona-encrypted-chat.jpg new file mode 100644 index 0000000000..0ffe1e69e7 Binary files /dev/null and b/docs/links/images/questona-encrypted-chat.jpg differ diff --git a/docs/links/images/reclaimthenet-ip-privacy.jpg b/docs/links/images/reclaimthenet-ip-privacy.jpg new file mode 100644 index 0000000000..ee5e83fbf2 Binary files /dev/null and b/docs/links/images/reclaimthenet-ip-privacy.jpg differ diff --git a/docs/links/images/reclaimthenet-quantum-beta.jpg b/docs/links/images/reclaimthenet-quantum-beta.jpg new file mode 100644 index 0000000000..6135f8209d Binary files /dev/null and b/docs/links/images/reclaimthenet-quantum-beta.jpg differ diff --git a/docs/links/images/renaro-signal-session-simplex.jpg b/docs/links/images/renaro-signal-session-simplex.jpg new file mode 100644 index 0000000000..9c04021c7d Binary files /dev/null and b/docs/links/images/renaro-signal-session-simplex.jpg differ diff --git a/docs/links/images/robosats-simplex-bot.jpg b/docs/links/images/robosats-simplex-bot.jpg new file mode 100644 index 0000000000..5d6720bd18 Binary files /dev/null and b/docs/links/images/robosats-simplex-bot.jpg differ diff --git a/docs/links/images/russian-paranoid-messenger-tutorial.jpg b/docs/links/images/russian-paranoid-messenger-tutorial.jpg new file mode 100644 index 0000000000..da219f96e3 Binary files /dev/null and b/docs/links/images/russian-paranoid-messenger-tutorial.jpg differ diff --git a/docs/links/images/russian-simplex-anonymous-no-id.jpg b/docs/links/images/russian-simplex-anonymous-no-id.jpg new file mode 100644 index 0000000000..f388896955 Binary files /dev/null and b/docs/links/images/russian-simplex-anonymous-no-id.jpg differ diff --git a/docs/links/images/russian-simplex-max-protection.jpg b/docs/links/images/russian-simplex-max-protection.jpg new file mode 100644 index 0000000000..ff9ecfe6ba Binary files /dev/null and b/docs/links/images/russian-simplex-max-protection.jpg differ diff --git a/docs/links/images/russian-simplex-overview-functions.jpg b/docs/links/images/russian-simplex-overview-functions.jpg new file mode 100644 index 0000000000..6f8f628133 Binary files /dev/null and b/docs/links/images/russian-simplex-overview-functions.jpg differ diff --git a/docs/links/images/rutube-simplex-video.jpg b/docs/links/images/rutube-simplex-video.jpg new file mode 100644 index 0000000000..aa7d416855 Binary files /dev/null and b/docs/links/images/rutube-simplex-video.jpg differ diff --git a/docs/links/images/security-nl-simplex-users.jpg b/docs/links/images/security-nl-simplex-users.jpg new file mode 100644 index 0000000000..7de10eff2d Binary files /dev/null and b/docs/links/images/security-nl-simplex-users.jpg differ diff --git a/docs/links/images/securityinabox-simplex-turkish.jpg b/docs/links/images/securityinabox-simplex-turkish.jpg new file mode 100644 index 0000000000..230b3e1463 Binary files /dev/null and b/docs/links/images/securityinabox-simplex-turkish.jpg differ diff --git a/docs/links/images/selfhosted-simplex-tutorial.jpg b/docs/links/images/selfhosted-simplex-tutorial.jpg new file mode 100644 index 0000000000..d534a25095 Binary files /dev/null and b/docs/links/images/selfhosted-simplex-tutorial.jpg differ diff --git a/docs/links/images/selfhosty-simplex-signal.jpg b/docs/links/images/selfhosty-simplex-signal.jpg new file mode 100644 index 0000000000..85d9f24b4c Binary files /dev/null and b/docs/links/images/selfhosty-simplex-signal.jpg differ diff --git a/docs/links/images/serokell-haskell-simplex.jpg b/docs/links/images/serokell-haskell-simplex.jpg new file mode 100644 index 0000000000..7ea448125c Binary files /dev/null and b/docs/links/images/serokell-haskell-simplex.jpg differ diff --git a/docs/links/images/sethforprivacy-privacy-steps.jpg b/docs/links/images/sethforprivacy-privacy-steps.jpg new file mode 100644 index 0000000000..c2210ca2fa Binary files /dev/null and b/docs/links/images/sethforprivacy-privacy-steps.jpg differ diff --git a/docs/links/images/simplex-messaging-perfect-privacy.jpg b/docs/links/images/simplex-messaging-perfect-privacy.jpg new file mode 100644 index 0000000000..d71d4139ed Binary files /dev/null and b/docs/links/images/simplex-messaging-perfect-privacy.jpg differ diff --git a/docs/links/images/simplex-power-to-people-livestream.jpg b/docs/links/images/simplex-power-to-people-livestream.jpg new file mode 100644 index 0000000000..b4a3d03db7 Binary files /dev/null and b/docs/links/images/simplex-power-to-people-livestream.jpg differ diff --git a/docs/links/images/simplex-status-bot.jpg b/docs/links/images/simplex-status-bot.jpg new file mode 100644 index 0000000000..033a8cfcc4 Binary files /dev/null and b/docs/links/images/simplex-status-bot.jpg differ diff --git a/docs/links/images/simplex-themes-archive.jpg b/docs/links/images/simplex-themes-archive.jpg new file mode 100644 index 0000000000..c6e8afda19 Binary files /dev/null and b/docs/links/images/simplex-themes-archive.jpg differ diff --git a/docs/links/images/simplex-unusually-good-privacy.jpg b/docs/links/images/simplex-unusually-good-privacy.jpg new file mode 100644 index 0000000000..9059fc7a28 Binary files /dev/null and b/docs/links/images/simplex-unusually-good-privacy.jpg differ diff --git a/docs/links/images/simplex-whatsapp-libertario.jpg b/docs/links/images/simplex-whatsapp-libertario.jpg new file mode 100644 index 0000000000..8048e590ec Binary files /dev/null and b/docs/links/images/simplex-whatsapp-libertario.jpg differ diff --git a/docs/links/images/soberano-simplex-english.jpg b/docs/links/images/soberano-simplex-english.jpg new file mode 100644 index 0000000000..08c28cd2cc Binary files /dev/null and b/docs/links/images/soberano-simplex-english.jpg differ diff --git a/docs/links/images/soberano-simplex-guide.jpg b/docs/links/images/soberano-simplex-guide.jpg new file mode 100644 index 0000000000..08c28cd2cc Binary files /dev/null and b/docs/links/images/soberano-simplex-guide.jpg differ diff --git a/docs/links/images/sofwul-simplex-contact.jpg b/docs/links/images/sofwul-simplex-contact.jpg new file mode 100644 index 0000000000..b508ba4696 Binary files /dev/null and b/docs/links/images/sofwul-simplex-contact.jpg differ diff --git a/docs/links/images/spanish-simplex-sin-identificadores.jpg b/docs/links/images/spanish-simplex-sin-identificadores.jpg new file mode 100644 index 0000000000..6294b77977 Binary files /dev/null and b/docs/links/images/spanish-simplex-sin-identificadores.jpg differ diff --git a/docs/links/images/spanish-simplex-ultra-private.jpg b/docs/links/images/spanish-simplex-ultra-private.jpg new file mode 100644 index 0000000000..8b43c390cc Binary files /dev/null and b/docs/links/images/spanish-simplex-ultra-private.jpg differ diff --git a/docs/links/images/splintercon-simplex-listing.jpg b/docs/links/images/splintercon-simplex-listing.jpg new file mode 100644 index 0000000000..2d6c790fdc Binary files /dev/null and b/docs/links/images/splintercon-simplex-listing.jpg differ diff --git a/docs/links/images/stackuj-luptak-podcast.jpg b/docs/links/images/stackuj-luptak-podcast.jpg new file mode 100644 index 0000000000..d0c8a6c5ff Binary files /dev/null and b/docs/links/images/stackuj-luptak-podcast.jpg differ diff --git a/docs/links/images/start9-simplex-startos.jpg b/docs/links/images/start9-simplex-startos.jpg new file mode 100644 index 0000000000..06933341aa Binary files /dev/null and b/docs/links/images/start9-simplex-startos.jpg differ diff --git a/docs/links/images/syskb-simplex-643.jpg b/docs/links/images/syskb-simplex-643.jpg new file mode 100644 index 0000000000..f58e1924c6 Binary files /dev/null and b/docs/links/images/syskb-simplex-643.jpg differ diff --git a/docs/links/images/tabnews-simplex-first-messenger.jpg b/docs/links/images/tabnews-simplex-first-messenger.jpg new file mode 100644 index 0000000000..430be61d03 Binary files /dev/null and b/docs/links/images/tabnews-simplex-first-messenger.jpg differ diff --git a/docs/links/images/tarnkappe-simplex-1-0.jpg b/docs/links/images/tarnkappe-simplex-1-0.jpg new file mode 100644 index 0000000000..e301c52546 Binary files /dev/null and b/docs/links/images/tarnkappe-simplex-1-0.jpg differ diff --git a/docs/links/images/taurix-simplex-hosting.jpg b/docs/links/images/taurix-simplex-hosting.jpg new file mode 100644 index 0000000000..d92596e37f Binary files /dev/null and b/docs/links/images/taurix-simplex-hosting.jpg differ diff --git a/docs/links/images/te-st-simplex-review.jpg b/docs/links/images/te-st-simplex-review.jpg new file mode 100644 index 0000000000..ed8ed65657 Binary files /dev/null and b/docs/links/images/te-st-simplex-review.jpg differ diff --git a/docs/links/images/techflow-vitalik-simplex.jpg b/docs/links/images/techflow-vitalik-simplex.jpg new file mode 100644 index 0000000000..221bcd4e77 Binary files /dev/null and b/docs/links/images/techflow-vitalik-simplex.jpg differ diff --git a/docs/links/images/techlore-recommend-simplex.jpg b/docs/links/images/techlore-recommend-simplex.jpg new file mode 100644 index 0000000000..7d7f4c4df4 Binary files /dev/null and b/docs/links/images/techlore-recommend-simplex.jpg differ diff --git a/docs/links/images/techlore-talks-simplex-interview.jpg b/docs/links/images/techlore-talks-simplex-interview.jpg new file mode 100644 index 0000000000..e8f8f2fd48 Binary files /dev/null and b/docs/links/images/techlore-talks-simplex-interview.jpg differ diff --git a/docs/links/images/tecmundo-simplex-telegram.jpg b/docs/links/images/tecmundo-simplex-telegram.jpg new file mode 100644 index 0000000000..02d83f888a Binary files /dev/null and b/docs/links/images/tecmundo-simplex-telegram.jpg differ diff --git a/docs/links/images/tencent-news-crypto-2025.jpg b/docs/links/images/tencent-news-crypto-2025.jpg new file mode 100644 index 0000000000..82176f1228 Binary files /dev/null and b/docs/links/images/tencent-news-crypto-2025.jpg differ diff --git a/docs/links/images/tudongchat-simplex-vietnam.jpg b/docs/links/images/tudongchat-simplex-vietnam.jpg new file mode 100644 index 0000000000..f905f91614 Binary files /dev/null and b/docs/links/images/tudongchat-simplex-vietnam.jpg differ diff --git a/docs/links/images/tugatech-simplex-telegram.jpg b/docs/links/images/tugatech-simplex-telegram.jpg new file mode 100644 index 0000000000..e0e938313c Binary files /dev/null and b/docs/links/images/tugatech-simplex-telegram.jpg differ diff --git a/docs/links/images/tuta-whatsapp-alternatives.jpg b/docs/links/images/tuta-whatsapp-alternatives.jpg new file mode 100644 index 0000000000..42a82d1205 Binary files /dev/null and b/docs/links/images/tuta-whatsapp-alternatives.jpg differ diff --git a/docs/links/images/v2ex-im-china.jpg b/docs/links/images/v2ex-im-china.jpg new file mode 100644 index 0000000000..eebefd1455 Binary files /dev/null and b/docs/links/images/v2ex-im-china.jpg differ diff --git a/docs/links/images/v2ex-simplex-telegram-100x.jpg b/docs/links/images/v2ex-simplex-telegram-100x.jpg new file mode 100644 index 0000000000..a5541af8d3 Binary files /dev/null and b/docs/links/images/v2ex-simplex-telegram-100x.jpg differ diff --git a/docs/links/images/vc-ru-simplex.jpg b/docs/links/images/vc-ru-simplex.jpg new file mode 100644 index 0000000000..2df513e9e3 Binary files /dev/null and b/docs/links/images/vc-ru-simplex.jpg differ diff --git a/docs/links/images/verasoul-simplex-review.jpg b/docs/links/images/verasoul-simplex-review.jpg new file mode 100644 index 0000000000..e4c609da1b Binary files /dev/null and b/docs/links/images/verasoul-simplex-review.jpg differ diff --git a/docs/links/images/vpn-taizen-simplex-guide.jpg b/docs/links/images/vpn-taizen-simplex-guide.jpg new file mode 100644 index 0000000000..6eafe62eb3 Binary files /dev/null and b/docs/links/images/vpn-taizen-simplex-guide.jpg differ diff --git a/docs/links/images/webappsmagazine-simplex-anonymity.jpg b/docs/links/images/webappsmagazine-simplex-anonymity.jpg new file mode 100644 index 0000000000..2e532baa13 Binary files /dev/null and b/docs/links/images/webappsmagazine-simplex-anonymity.jpg differ diff --git a/docs/links/images/whonix-simplex-recommendation.jpg b/docs/links/images/whonix-simplex-recommendation.jpg new file mode 100644 index 0000000000..dadc207bd9 Binary files /dev/null and b/docs/links/images/whonix-simplex-recommendation.jpg differ diff --git a/docs/links/images/wwwhatsnew-simplex.jpg b/docs/links/images/wwwhatsnew-simplex.jpg new file mode 100644 index 0000000000..5a1e7b6eb4 Binary files /dev/null and b/docs/links/images/wwwhatsnew-simplex.jpg differ diff --git a/docs/links/images/wykop-simplex-dsa.jpg b/docs/links/images/wykop-simplex-dsa.jpg new file mode 100644 index 0000000000..7884b362e7 Binary files /dev/null and b/docs/links/images/wykop-simplex-dsa.jpg differ diff --git a/docs/links/images/xakep-simplex-signal-brothers.jpg b/docs/links/images/xakep-simplex-signal-brothers.jpg new file mode 100644 index 0000000000..9ccb6b1e2e Binary files /dev/null and b/docs/links/images/xakep-simplex-signal-brothers.jpg differ diff --git a/docs/links/images/yahoo-finance-buterin.jpg b/docs/links/images/yahoo-finance-buterin.jpg new file mode 100644 index 0000000000..6c8d0bfbad Binary files /dev/null and b/docs/links/images/yahoo-finance-buterin.jpg differ diff --git a/docs/links/images/youtube-best-private-messenger.jpg b/docs/links/images/youtube-best-private-messenger.jpg new file mode 100644 index 0000000000..ff2c2e134c Binary files /dev/null and b/docs/links/images/youtube-best-private-messenger.jpg differ diff --git a/docs/links/images/youtube-best-secure-2025.jpg b/docs/links/images/youtube-best-secure-2025.jpg new file mode 100644 index 0000000000..78d379f145 Binary files /dev/null and b/docs/links/images/youtube-best-secure-2025.jpg differ diff --git a/docs/links/images/youtube-simplex-review-2024.jpg b/docs/links/images/youtube-simplex-review-2024.jpg new file mode 100644 index 0000000000..08a57214ef Binary files /dev/null and b/docs/links/images/youtube-simplex-review-2024.jpg differ diff --git a/docs/links/images/youtube-simplex-tutorial.jpg b/docs/links/images/youtube-simplex-tutorial.jpg new file mode 100644 index 0000000000..0e94c8fc58 Binary files /dev/null and b/docs/links/images/youtube-simplex-tutorial.jpg differ diff --git a/docs/links/images/zhousa-simplex-review.jpg b/docs/links/images/zhousa-simplex-review.jpg new file mode 100644 index 0000000000..6712c3bf7e Binary files /dev/null and b/docs/links/images/zhousa-simplex-review.jpg differ diff --git a/docs/protocol/channels-protocol.md b/docs/protocol/channels-protocol.md index 979ab2c85b..6a232ea2ff 100644 --- a/docs/protocol/channels-protocol.md +++ b/docs/protocol/channels-protocol.md @@ -9,6 +9,7 @@ For architecture, design rationale, security properties, and threat model, see [ - [Protocol](#protocol) - [Channel creation](#channel-creation) - [Relay acceptance](#relay-acceptance) + - [Relay addition](#relay-addition) - [Subscriber connection](#subscriber-connection) - [Message signing](#message-signing) - [Message forwarding](#message-forwarding) @@ -57,6 +58,34 @@ When a relay receives an invitation to serve a channel, it validates the channel TODO: Periodic monitoring where the relay retrieves channel link data to verify its relay link is still listed is planned but not yet implemented. +### Relay addition + +When the owner adds a relay to an existing channel: + +1. **Acceptance.** The new relay accepts the invitation following the [Relay acceptance](#relay-acceptance) flow. The owner promotes the relay to active when the channel link's updated relay list is confirmed. + +2. **Announce.** If the channel has at least one subscriber, the owner sends `x.grp.relay.new` (carrying the new relay's short link) to every other currently-connected relay of the channel. + +3. **Forward.** Each relay forwards `x.grp.relay.new` to its subscribers. The relay does not create a member record for the announced relay — relays do not connect to other relays of the same channel. + +4. **Connect.** On receipt, the subscriber resolves the announced short link and connects to the new relay asynchronously. + +The announce is an optimisation. When it does not reach a subscriber — because the channel had no subscribers at announce time, because an older client or relay sits in the path, or because of a transient network failure — the subscriber reaches the same end state on the next channel open via its relay sync against the channel's link data. + +### Relay rejection + +When a relay operator removes the relay from a channel, the relay marks the channel as rejected and refuses future invitations from the same channel link: + +1. **Leave.** The relay operator runs `/leave #channel`. The relay marks the channel as rejected locally, keyed by the channel's short link. + +2. **Refuse.** When the owner later sends `x.grp.relay.inv` for the same channel link — typically from a re-invitation — the relay does not accept the invitation as a relay. Instead it replies with `x.grp.relay.reject` over the owner-relay direct contact channel, carrying a rejection reason. The current reason is `rejoin_rejected`; older relays or future reasons fall through to an unknown reason for forward compatibility. + +3. **Owner handling.** The owner marks the corresponding relay as rejected and notifies the operator UI. The owner also sets the relay member's status to `GSMemLeft` so the UI treats the rejected relay identically to one that ran `/leave`. The owner's next user-initiated relay addition for the same channel creates a fresh invitation, which the relay rejects again unless the rejection has been cleared. + +4. **Clear.** The relay operator runs `/group allow ` to clear the rejection for the channel. After the next user-initiated relay addition, the relay accepts the invitation and rejoins as a relay. + +An older owner client that does not recognise `x.grp.relay.reject` ignores the message and leaves the relay invitation in an invited state indefinitely — the same end state as a relay that does not respond. An older relay binary does not enforce rejection; in mixed-version deployments the operator can re-run `/leave` under the new binary to re-establish rejection. + ### Subscriber connection A subscriber joins a channel through the following flow: @@ -89,6 +118,7 @@ Messages that alter the channel's roster, profile, or administrative state are c | `x.grp.mem.del` | Remove member | Required | | `x.grp.mem.role` | Change member role | Required | | `x.grp.mem.restrict` | Restrict member | Required | +| `x.grp.relay.new` | Announce new relay to subscribers | Required | | `x.grp.leave` | Leave channel | Required (unverified allowed between subscribers) | | `x.info` | Update member profile | Required (unverified allowed between subscribers) | | `x.msg.new` | Content message | Not signed | diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 1d5eb5197c..c929125033 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.6.0", + "version": "0.7.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 f8aa6e445d..d1b89ffe27 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -387,6 +387,20 @@ export namespace APIAddGroupRelays { } } +// Clear relay rejection for a channel (relay operator). +// Network usage: background. +export interface APIAllowRelayGroup { + groupId: number // int64 +} + +export namespace APIAllowRelayGroup { + export type Response = CR.RelayGroupAllowed | CR.ChatCmdError + + export function cmdString(self: APIAllowRelayGroup): string { + return '/_relay allow #' + self.groupId + } +} + // Update group profile. // Network usage: background. export interface APIUpdateGroupProfile { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index e4284bf87e..0fcf0e6eca 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -32,6 +32,7 @@ export type ChatResponse = | CR.GroupRelays | CR.GroupRelaysAdded | CR.GroupRelaysAddFailed + | CR.RelayGroupAllowed | CR.GroupMembers | CR.GroupUpdated | CR.GroupsList @@ -89,6 +90,7 @@ export namespace CR { | "groupRelays" | "groupRelaysAdded" | "groupRelaysAddFailed" + | "relayGroupAllowed" | "groupMembers" | "groupUpdated" | "groupsList" @@ -293,6 +295,12 @@ export namespace CR { addRelayResults: T.AddRelayResult[] } + export interface RelayGroupAllowed extends Interface { + type: "relayGroupAllowed" + user: T.User + groupInfo: T.GroupInfo + } + export interface GroupMembers extends Interface { type: "groupMembers" user: T.User diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 46ba695e61..1b9e9f6f65 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -527,6 +527,7 @@ export enum CIDeleteMode { Broadcast = "broadcast", Internal = "internal", InternalMark = "internalMark", + History = "history", } export type CIDeleted = CIDeleted.Deleted | CIDeleted.Blocked | CIDeleted.BlockedByAdmin | CIDeleted.Moderated @@ -3745,6 +3746,7 @@ export enum RelayStatus { Accepted = "accepted", Active = "active", Inactive = "inactive", + Rejected = "rejected", } export enum ReportReason { diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index c5cc255722..5166283e75 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "6.5.1", + "version": "6.5.2", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.6.0", + "@simplex-chat/types": "^0.7.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/download-libs.js b/packages/simplex-chat-nodejs/src/download-libs.js index 5c1b70cda0..db042d48a2 100644 --- a/packages/simplex-chat-nodejs/src/download-libs.js +++ b/packages/simplex-chat-nodejs/src/download-libs.js @@ -4,7 +4,7 @@ const path = require('path'); const extract = require('extract-zip'); const GITHUB_REPO = 'simplex-chat/simplex-chat-libs'; -const RELEASE_TAG = 'v6.5.1'; +const RELEASE_TAG = 'v6.5.2'; const BACKEND = (process.env.SIMPLEX_BACKEND || process.env.npm_config_simplex_backend || 'sqlite').toLowerCase(); if (BACKEND !== 'sqlite' && BACKEND !== 'postgres') { diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index 52e7843a95..f7365e731c 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -78,7 +78,7 @@ export interface BotCommand { export function ciBotCommand(chatItem: T.ChatItem): BotCommand | undefined { const msg = ciContentText(chatItem)?.trim() if (msg) { - const r = msg.match(/\/([^\s]+)(.*)/) + const r = msg.match(/^\/([^\s]+)(.*)/) if (r && r.length >= 3) { return {keyword: r[1], params: r[2].trim()} } diff --git a/packages/simplex-chat-nodejs/tests/util.test.ts b/packages/simplex-chat-nodejs/tests/util.test.ts new file mode 100644 index 0000000000..4fe3140edc --- /dev/null +++ b/packages/simplex-chat-nodejs/tests/util.test.ts @@ -0,0 +1,37 @@ +import {T} from "@simplex-chat/types" +import {ciBotCommand} from "../src/util" + +function rcvText(text: string): T.ChatItem { + return {content: {type: "rcvMsgContent", msgContent: {type: "text", text}}} as T.ChatItem +} + +describe("ciBotCommand", () => { + it("parses a command at the start of the message", () => { + expect(ciBotCommand(rcvText("/grok hello"))).toEqual({keyword: "grok", params: "hello"}) + }) + + it("returns undefined for a slash in the middle of a word", () => { + expect(ciBotCommand(rcvText("What follow/read blog posts?"))).toBeUndefined() + }) + + it("returns undefined for a slash after a space", () => { + expect(ciBotCommand(rcvText("see /home for details"))).toBeUndefined() + }) + + it("strips leading whitespace before matching", () => { + expect(ciBotCommand(rcvText(" /grok ask this"))).toEqual({keyword: "grok", params: "ask this"}) + }) + + it("returns command with empty params when only the keyword is present", () => { + expect(ciBotCommand(rcvText("/team"))).toEqual({keyword: "team", params: ""}) + }) + + it("returns undefined for plain text without slash", () => { + expect(ciBotCommand(rcvText("hello there"))).toBeUndefined() + }) + + it("returns undefined for non-text chat item content", () => { + const ci = {content: {type: "rcvDeleted"}} as T.ChatItem + expect(ciBotCommand(ci)).toBeUndefined() + }) +}) diff --git a/packages/simplex-chat-python/.gitignore b/packages/simplex-chat-python/.gitignore new file mode 100644 index 0000000000..5d5acffbb9 --- /dev/null +++ b/packages/simplex-chat-python/.gitignore @@ -0,0 +1,22 @@ +# Python build / cache artifacts — never commit these +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pyright_cache/ + +# Virtual environments +.venv/ +.venv-*/ +venv/ + +# Lazy-downloaded native libs (handled at runtime by _native._resolve_libs_dir) +libs/ + +# Local override for SIMPLEX_LIBS_DIR work, etc. +.env diff --git a/packages/simplex-chat-python/LICENSE b/packages/simplex-chat-python/LICENSE new file mode 100644 index 0000000000..0ad25db4bd --- /dev/null +++ b/packages/simplex-chat-python/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/packages/simplex-chat-python/README.md b/packages/simplex-chat-python/README.md new file mode 100644 index 0000000000..b86886d591 --- /dev/null +++ b/packages/simplex-chat-python/README.md @@ -0,0 +1,70 @@ +# SimpleX Chat Python library + +Python 3.11+ client for [SimpleX Chat](https://simplex.chat) bots. Equivalent to the [Node.js library](https://www.npmjs.com/package/simplex-chat). + +## Install + +```bash +pip install simplex-chat +``` + +The native `libsimplex` is downloaded lazily on first use. To pre-fetch: + +```bash +python -m simplex_chat install # sqlite (default) +python -m simplex_chat install --backend postgres # linux-x86_64 only +``` + +## Quick start + +```python +import re +from simplex_chat import Bot, BotProfile, Message, SqliteDb, TextMessage + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", +) + +@bot.on_message(content_type="text", text=re.compile(r"^-?\d+(\.\d+)?$")) +async def square(msg: TextMessage) -> None: + n = float(msg.text or "0") + await msg.reply(f"{n} * {n} = {n * n}") + +@bot.on_message(content_type="text") +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + +if __name__ == "__main__": + bot.run() +``` + +`bot.run()` blocks. The connection address is logged on startup — paste it into a SimpleX client to talk to the bot. `Ctrl+C` to stop. + +Three decorators: `@bot.on_message(...)`, `@bot.on_command(name)`, `@bot.on_event(tag)`. Message handlers are first-match-wins in registration order, so register specific filters first and catch-alls last. + +See [`examples/squaring_bot.py`](./examples/squaring_bot.py) for the full example. + +## Development + +```bash +uv venv && source .venv/bin/activate +uv pip install -e '.[dev]' +ruff check && pyright && pytest tests/ +``` + +Wire types under `src/simplex_chat/types/_*.py` are generated. Regenerate with `cabal test simplex-chat-test --test-options='--match Python'`. + +## Release + +Manual for now. Bump `_version.py:__version__`, build a wheel, upload to PyPI: + +```bash +uv build --wheel +uv publish --token "$PYPI_TOKEN" +``` + +## License + +[AGPL-3.0](./LICENSE) diff --git a/packages/simplex-chat-python/examples/squaring_bot.py b/packages/simplex-chat-python/examples/squaring_bot.py new file mode 100644 index 0000000000..296b51347e --- /dev/null +++ b/packages/simplex-chat-python/examples/squaring_bot.py @@ -0,0 +1,52 @@ +"""Squaring bot — replies to every number with its square. + +Run with the simplex-chat package installed: + + python examples/squaring_bot.py + +Sends `n * n = ...` for any text message that parses as a number; falls +back to a hint for non-number messages; responds to `/help` with usage. +""" + +from __future__ import annotations + +import re + +from simplex_chat import ( + Bot, + BotCommand, + BotProfile, + Message, + ParsedCommand, + SqliteDb, + TextMessage, +) + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", + commands=[BotCommand(keyword="help", label="Show help")], +) + +NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$") + + +@bot.on_message(content_type="text", text=NUMBER_RE) +async def square(msg: TextMessage) -> None: + n = float(msg.text or "0") + await msg.reply(f"{n} * {n} = {n * n}") + + +@bot.on_message(content_type="text") +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + + +@bot.on_command("help") +async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None: + await msg.reply("Send a number, I'll square it.") + + +if __name__ == "__main__": + bot.run() diff --git a/packages/simplex-chat-python/pyproject.toml b/packages/simplex-chat-python/pyproject.toml new file mode 100644 index 0000000000..76f22cdabe --- /dev/null +++ b/packages/simplex-chat-python/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +readme = "README.md" +license = "AGPL-3.0-only" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +keywords = ["simplex", "messenger", "chat", "privacy", "security", "bots"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Chat", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python" +Issues = "https://github.com/simplex-chat/simplex-chat/issues" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=0.23"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.format] +# `src/simplex_chat/types/*.py` are generated by the Haskell codegen +# (bots/src/API/Docs/Generate/Python.hs). Re-formatting them locally +# would diverge from the generator's output and break `cabal test +# simplex-chat-test --match Python`. Lint still applies — only format +# is suppressed. +exclude = ["src/simplex_chat/types/_*.py"] + +[tool.pyright] +# Same rationale: the generated cmd_string helpers use `self.get('x')` +# call pairs that pyright cannot narrow across (`is not None` followed +# by re-access). Hand-written code is still strictly checked. +include = ["src/simplex_chat"] +exclude = ["src/simplex_chat/types/_*.py", "**/__pycache__", "**/.venv*"] diff --git a/packages/simplex-chat-python/src/simplex_chat/__init__.py b/packages/simplex-chat-python/src/simplex_chat/__init__.py new file mode 100644 index 0000000000..c353b74935 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/__init__.py @@ -0,0 +1,72 @@ +"""SimpleX Chat — Python client library for chat bots.""" + +from ._version import __version__ +from .api import ( + ChatApi, + ChatCommandError, + ConnReqType, + ContactAlreadyExistsError, + Db, + PostgresDb, + SqliteDb, +) +from .bot import ( + Bot, + BotCommand, + BotProfile, + ChatMessage, + Client, + CommandHandler, + EventHandler, + FileMessage, + ImageMessage, + LinkMessage, + Message, + MessageHandler, + Middleware, + ParsedCommand, + Profile, + ReportMessage, + TextMessage, + UnknownMessage, + VideoMessage, + VoiceMessage, +) +from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation +from . import util as util # re-export the util namespace + +__all__ = [ + "__version__", + "Bot", + "BotCommand", + "BotProfile", + "ChatAPIError", + "ChatApi", + "ChatCommandError", + "ChatInitError", + "ChatMessage", + "Client", + "CommandHandler", + "ConnReqType", + "ContactAlreadyExistsError", + "CryptoArgs", + "Db", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "MigrationConfirmation", + "ParsedCommand", + "PostgresDb", + "Profile", + "ReportMessage", + "SqliteDb", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", + "util", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/__main__.py b/packages/simplex-chat-python/src/simplex_chat/__main__.py new file mode 100644 index 0000000000..2fa4f3cd37 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/__main__.py @@ -0,0 +1,35 @@ +"""CLI: ``python -m simplex_chat install [--backend=sqlite|postgres]``.""" + +from __future__ import annotations + +import argparse +import sys + +from . import _native + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="simplex_chat") + sub = p.add_subparsers(dest="command", required=True) + install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache") + install.add_argument( + "--backend", + choices=["sqlite", "postgres"], + default="sqlite", + help="which libsimplex variant to download (default: sqlite)", + ) + args = p.parse_args(argv) + # `args.command` is always set: `add_subparsers(required=True)` makes + # argparse exit before reaching this point if no subcommand is given. + assert args.command == "install" + try: + path = _native._resolve_libs_dir(args.backend) + print(f"libsimplex installed at: {path}") + return 0 + except Exception as e: + print(f"install failed: {e}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py new file mode 100644 index 0000000000..313c606883 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -0,0 +1,257 @@ +"""Native libsimplex loader: platform detection, lazy download, ctypes setup. + +Internal — users interact with `Bot` / `ChatApi`, never with this module. +""" + +from __future__ import annotations + +import ctypes +import errno +import os +import platform +import sys +import tempfile +import threading +import urllib.request +import zipfile +from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p +from pathlib import Path +from typing import Literal + +from ._version import LIBS_VERSION + +Backend = Literal["sqlite", "postgres"] + +_GITHUB_REPO = "simplex-chat/simplex-chat-libs" + +_PLATFORM_MAP = { + "linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}), + "darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}), + "win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}), +} + +_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"} + +SUPPORTED = ( + "linux-x86_64", + "linux-aarch64", + "macos-x86_64", + "macos-aarch64", + "windows-x86_64", +) + + +def _platform_tag() -> str: + info = _PLATFORM_MAP.get(sys.platform) + if not info: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + sysname, archs = info + arch = archs.get(platform.machine()) + if not arch: + raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}") + tag = f"{sysname}-{arch}" + if tag not in SUPPORTED: + raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}") + return tag + + +def _libname() -> str: + return _LIBNAME[sys.platform] + + +def _libs_url(backend: Backend) -> str: + suffix = "-postgres" if backend == "postgres" else "" + return ( + f"https://github.com/{_GITHUB_REPO}/releases/download/" + f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + ) + + +def _cache_root() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "simplex-chat" + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat" + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "simplex-chat" + + +def _resolve_libs_dir(backend: Backend) -> Path: + if override := os.environ.get("SIMPLEX_LIBS_DIR"): + return Path(override) + if backend == "postgres" and _platform_tag() != "linux-x86_64": + raise RuntimeError( + "postgres backend is only supported on linux-x86_64; " + f"current platform is {_platform_tag()}" + ) + target = _cache_root() / f"v{LIBS_VERSION}" / backend + if not (target / _libname()).exists(): + _download(target, backend) + return target + + +_DOWNLOAD_CHUNK = 1 << 16 # 64 KiB + + +def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: + """Stream `url` → `dest`, printing a carriage-return progress bar. + + `timeout` is per-request; we don't touch `socket.setdefaulttimeout` + so other socket users in the same process aren't affected. + """ + with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310 - https://github.com/... + total = int(resp.headers.get("Content-Length") or 0) + received = 0 + with dest.open("wb") as out: + while chunk := resp.read(_DOWNLOAD_CHUNK): + out.write(chunk) + received += len(chunk) + if total > 0: + pct = min(100, received * 100 // total) + msg = f"\r download: {received >> 20} / {total >> 20} MiB ({pct}%)" + else: + msg = f"\r download: {received >> 20} MiB" + print(msg, end="", file=sys.stderr, flush=True) + print("", file=sys.stderr, flush=True) # newline after final progress line + + +def _download(target: Path, backend: Backend) -> None: + """Download libs zip → atomic rename into `target`. Concurrent processes safe. + + Atomicity strategy: each process extracts to its own sibling tempdir on the same + filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename` + onto a NON-EXISTENT path is atomic; if the target exists (another process won + the race), `os.rename` fails on most platforms — we then verify the winner has + what we need and proceed. NEVER rmtree the target: that creates a TOCTOU + window where another process is reading/loading the file we're deleting. + """ + target.parent.mkdir(parents=True, exist_ok=True) + url = _libs_url(backend) + print( + f"Downloading libsimplex ({_platform_tag()}, {backend}) v{LIBS_VERSION} from {url} ...", + file=sys.stderr, + flush=True, + ) + with tempfile.TemporaryDirectory(dir=target.parent) as tmp: + zip_path = Path(tmp) / "libs.zip" + _stream_to_file(url, zip_path, timeout=60.0) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + # zip layout: /libs/libsimplex.* + libHS*.* + extracted_libs = Path(tmp) / "libs" + if not extracted_libs.is_dir(): + raise RuntimeError(f"libs/ missing from {_libs_url(backend)}") + try: + os.rename(extracted_libs, target) + except OSError as e: + # EEXIST / ENOTEMPTY mean another process won the race — fall through + # and check that the winner left a usable libsimplex behind. Anything + # else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real + # failure and must propagate. Same VERSION cached → same content → + # safe to proceed once we've confirmed the file is there. + if e.errno not in (errno.EEXIST, errno.ENOTEMPTY): + raise + if not (target / _libname()).exists(): + raise RuntimeError( + f"another process partially populated {target} but libsimplex " + f"is missing; remove the directory manually and retry" + ) from e + + +_lock = threading.Lock() +_lib: ctypes.CDLL | None = None +_libc: ctypes.CDLL | None = None +_backend: Backend | None = None + + +def _load_libc() -> ctypes.CDLL: + if sys.platform == "win32": + return ctypes.CDLL("msvcrt") + return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table + + +def _setup_signatures(lib: ctypes.CDLL) -> None: + """Declare argtypes/restype for the 8 chat_* functions exported by libsimplex. + + All result strings come back as raw c_void_p so the caller can free them + after copying — matches HandleCResult in cpp/simplex.cc:157-165. + """ + lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)] + lib.chat_migrate_init.restype = c_void_p + lib.chat_close_store.argtypes = [c_void_p] + lib.chat_close_store.restype = c_void_p + lib.chat_send_cmd.argtypes = [c_void_p, c_char_p] + lib.chat_send_cmd.restype = c_void_p + lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int] + lib.chat_recv_msg_wait.restype = c_void_p + # chat_write_file's payload is treated read-only by libsimplex; passing + # `bytes` via c_char_p avoids the from_buffer_copy doubling. ctypes pins + # the bytes buffer for the duration of the call. + lib.chat_write_file.argtypes = [c_void_p, c_char_p, c_char_p, c_int] + lib.chat_write_file.restype = c_void_p + lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p] + lib.chat_read_file.restype = POINTER(c_uint8) + lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p] + lib.chat_encrypt_file.restype = c_void_p + lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p] + lib.chat_decrypt_file.restype = c_void_p + + +def _hs_init(lib: ctypes.CDLL) -> None: + """Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32.""" + if sys.platform == "win32": + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"] + else: + argv_strs = [ + b"simplex", + b"+RTS", + b"-A64m", + b"-H64m", + b"-xn", + b"--install-signal-handlers=no", + ] + argc = c_int(len(argv_strs)) + arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None) + arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p))) + lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))] + lib.hs_init_with_rtsopts.restype = None + lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr) + + +def lib_for(backend: Backend) -> ctypes.CDLL: + """Resolve, load, and initialize libsimplex for the given backend. + + Idempotent for the same backend; raises if called with a different backend. + Concurrent calls serialize on the module-level lock. + """ + global _lib, _libc, _backend + with _lock: + if _lib is not None: + if _backend != backend: + raise RuntimeError( + f"libsimplex already loaded with backend={_backend!r}; " + f"cannot switch to {backend!r} in the same process" + ) + return _lib + libs_dir = _resolve_libs_dir(backend) + lib = ctypes.CDLL(str(libs_dir / _libname())) + _setup_signatures(lib) + _hs_init(lib) + _libc = _load_libc() + _lib = lib + _backend = backend + return lib + + +def libc() -> ctypes.CDLL: + """libc — needed by `core` to free Haskell-allocated result strings.""" + if _libc is None: + raise RuntimeError("lib_for() must be called before libc()") + return _libc + + +def lib() -> ctypes.CDLL: + """Loaded libsimplex handle. Raises if `lib_for()` has not been called.""" + if _lib is None: + raise RuntimeError("lib_for() must be called before lib()") + return _lib diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py new file mode 100644 index 0000000000..bd182d0240 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -0,0 +1,9 @@ +"""Single source of truth for both the Python package version and the +simplex-chat-libs release tag we depend on. + +Bump both together for normal releases. For wrapper-only fixes use a PEP 440 +post-release: __version__ = "6.5.2.post1", LIBS_VERSION unchanged. +""" + +__version__ = "6.5.2" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.2" # simplex-chat-libs release tag (no 'v' prefix) diff --git a/packages/simplex-chat-python/src/simplex_chat/api.py b/packages/simplex-chat-python/src/simplex_chat/api.py new file mode 100644 index 0000000000..ef37e28384 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -0,0 +1,720 @@ +"""Low-level escape-hatch API. Most users go through `Bot` instead.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Any, Literal + +from . import _native, core, util +from .core import MigrationConfirmation +from .types import CC, CEvt, CR, T + +# Mirrors Node `ConnReqType` enum (api.ts:15-18) — the two possible outcomes +# of `api_connect` / `api_connect_active_user` depending on the link kind. +ConnReqType = Literal["invitation", "contact"] + + +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + + +Db = SqliteDb | PostgresDb + + +def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]: + """Returns (path-or-prefix, key-or-conn, backend).""" + if isinstance(db, SqliteDb): + return (db.file_prefix, db.encryption_key or "", "sqlite") + if isinstance(db, PostgresDb): + return (db.schema_prefix or "", db.connection_string, "postgres") + raise TypeError(f"Unknown db: {db!r}") + + +class ChatCommandError(Exception): + """A chat command returned an unexpected response type. + + `response` is the raw wire response; `response_type` exposes its `type` + discriminator for quick checks. Subclasses cover known recoverable cases + so callers can `except ContactAlreadyExistsError` instead of inspecting + `response.get("type")` themselves. + """ + + def __init__(self, message: str, response: CR.ChatResponse): + super().__init__(message) + self.response = response + + @property + def response_type(self) -> str: + return self.response.get("type", "") # type: ignore[return-value] + + +class ContactAlreadyExistsError(ChatCommandError): + """`api_connect`/`api_connect_active_user` was called for an existing contact.""" + + +class ChatApi: + def __init__(self, ctrl: int): + self._ctrl: int | None = ctrl + self._started = False + + @classmethod + async def init( + cls, + db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + ) -> "ChatApi": + path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) + # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. + _native.lib_for(backend) + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + return cls(ctrl) + + @property + def ctrl(self) -> int: + """Opaque controller pointer. Raises if `close()` has been called.""" + if self._ctrl is None: + raise RuntimeError("ChatApi controller not initialized (close() called?)") + return self._ctrl + + @property + def initialized(self) -> bool: + """True until `close()` is called. Mirrors Node `ChatApi.initialized`.""" + return self._ctrl is not None + + @property + def started(self) -> bool: + """True between `start_chat()` and the next `stop_chat()` / `close()`.""" + return self._started + + async def start_chat(self) -> None: + r = await self.send_chat_cmd( + CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + ) + if r.get("type") not in ("chatStarted", "chatRunning"): + raise ChatCommandError("error starting chat", r) + self._started = True + + async def stop_chat(self) -> None: + r = await self.send_chat_cmd("/_stop") + if r.get("type") != "chatStopped": + raise ChatCommandError("error stopping chat", r) + self._started = False + + async def close(self) -> None: + await core.chat_close_store(self.ctrl) + self._ctrl = None + self._started = False + + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: + return await core.chat_send_cmd(self.ctrl, cmd) + + async def recv_chat_event(self, wait_us: int = 500_000) -> CEvt.ChatEvent | None: + return await core.chat_recv_msg_wait(self.ctrl, wait_us) + + # ------------------------------------------------------------------ # + # Address commands + # ------------------------------------------------------------------ # + + async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: + r = await self.send_chat_cmd(CC.APICreateMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLinkCreated": + return r["connLinkContact"] + raise ChatCommandError("error creating user address", r) + + async def api_delete_user_address(self, user_id: int) -> None: + r = await self.send_chat_cmd(CC.APIDeleteMyAddress_cmd_string({"userId": user_id})) + if r["type"] != "userContactLinkDeleted": + raise ChatCommandError("error deleting user address", r) + + async def api_get_user_address(self, user_id: int) -> T.UserContactLink | None: + try: + r = await self.send_chat_cmd(CC.APIShowMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLink": + return r["contactLink"] + raise ChatCommandError("error loading user address", r) + except core.ChatAPIError as e: + ce = e.chat_error + if ( + ce is not None + and ce.get("type") == "errorStore" + and ce.get("storeError", {}).get("type") == "userContactLinkNotFound" + ): + return None + raise + + async def api_set_profile_address( + self, user_id: int, enable: bool + ) -> T.UserProfileUpdateSummary: + r = await self.send_chat_cmd( + CC.APISetProfileAddress_cmd_string({"userId": user_id, "enable": enable}) + ) + if r["type"] == "userProfileUpdated": + return r["updateSummary"] + raise ChatCommandError("error setting profile address", r) + + async def api_set_address_settings(self, user_id: int, settings: T.AddressSettings) -> None: + r = await self.send_chat_cmd( + CC.APISetAddressSettings_cmd_string({"userId": user_id, "settings": settings}) + ) + if r["type"] != "userContactLinkUpdated": + raise ChatCommandError("error changing user contact address settings", r) + + # ------------------------------------------------------------------ # + # Message commands + # ------------------------------------------------------------------ # + + async def api_send_messages( + self, + chat: list | T.ChatRef | T.ChatInfo, + messages: list[T.ComposedMessage], + live_message: bool = False, + ) -> list[T.AChatItem]: + if isinstance(chat, list): + send_ref: T.ChatRef = {"chatType": chat[0], "chatId": chat[1]} + elif "chatType" in chat and "chatId" in chat: + send_ref = chat + else: + ref = util.chat_info_ref(chat) + if ref is None: + raise ValueError("api_send_messages: can't send messages to this chat") + send_ref = ref + r = await self.send_chat_cmd( + CC.APISendMessages_cmd_string( + { + "sendRef": send_ref, + "composedMessages": messages, + "liveMessage": live_message, + } + ) + ) + if r["type"] == "newChatItems": + return r["chatItems"] + raise ChatCommandError("unexpected response", r) + + async def api_send_text_message( + self, + chat: list | T.ChatRef | T.ChatInfo, + text: str, + in_reply_to: int | None = None, + ) -> list[T.AChatItem]: + msg: T.ComposedMessage = {"msgContent": {"type": "text", "text": text}, "mentions": {}} + if in_reply_to is not None: + msg["quotedItemId"] = in_reply_to + return await self.api_send_messages(chat, [msg]) + + async def api_send_text_reply(self, chat_item: T.AChatItem, text: str) -> list[T.AChatItem]: + return await self.api_send_text_message( + chat_item["chatInfo"], text, chat_item["chatItem"]["meta"]["itemId"] + ) + + async def api_update_chat_item( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_id: int, + msg_content: T.MsgContent, + live_message: bool = False, + ) -> T.ChatItem: + r = await self.send_chat_cmd( + CC.APIUpdateChatItem_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemId": chat_item_id, + "liveMessage": live_message, + "updatedMessage": {"msgContent": msg_content, "mentions": {}}, + } + ) + ) + if r["type"] == "chatItemUpdated": + return r["chatItem"]["chatItem"] + raise ChatCommandError("error updating chat item", r) + + async def api_delete_chat_items( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_ids: list[int], + delete_mode: T.CIDeleteMode, + ) -> list[T.ChatItemDeletion]: + r = await self.send_chat_cmd( + CC.APIDeleteChatItem_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemIds": chat_item_ids, + "deleteMode": delete_mode, + } + ) + ) + if r["type"] == "chatItemsDeleted": + return r["chatItemDeletions"] + raise ChatCommandError("error deleting chat item", r) + + async def api_delete_member_chat_item( + self, group_id: int, chat_item_ids: list[int] + ) -> list[T.ChatItemDeletion]: + r = await self.send_chat_cmd( + CC.APIDeleteMemberChatItem_cmd_string( + {"groupId": group_id, "chatItemIds": chat_item_ids} + ) + ) + if r["type"] == "chatItemsDeleted": + return r["chatItemDeletions"] + raise ChatCommandError("error deleting member chat item", r) + + async def api_chat_item_reaction( + self, + chat_type: T.ChatType, + chat_id: int, + chat_item_id: int, + add: bool, + reaction: T.MsgReaction, + ) -> T.ACIReaction: + r = await self.send_chat_cmd( + CC.APIChatItemReaction_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatItemId": chat_item_id, + "add": add, + "reaction": reaction, + } + ) + ) + if r["type"] == "chatItemReaction": + return r["reaction"] + raise ChatCommandError("error setting item reaction", r) + + # ------------------------------------------------------------------ # + # File commands + # ------------------------------------------------------------------ # + + async def api_receive_file(self, file_id: int) -> T.AChatItem: + r = await self.send_chat_cmd( + CC.ReceiveFile_cmd_string({"fileId": file_id, "userApprovedRelays": True}) + ) + if r["type"] == "rcvFileAccepted": + return r["chatItem"] + raise ChatCommandError("error receiving file", r) + + async def api_cancel_file(self, file_id: int) -> None: + r = await self.send_chat_cmd(CC.CancelFile_cmd_string({"fileId": file_id})) + if r["type"] not in ("sndFileCancelled", "rcvFileCancelled"): + raise ChatCommandError("error canceling file", r) + + # ------------------------------------------------------------------ # + # Group commands + # ------------------------------------------------------------------ # + + async def api_add_member( + self, group_id: int, contact_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAddMember_cmd_string( + {"groupId": group_id, "contactId": contact_id, "memberRole": member_role} + ) + ) + if r["type"] == "sentGroupInvitation": + return r["member"] + raise ChatCommandError("error adding member", r) + + async def api_join_group(self, group_id: int) -> T.GroupInfo: + r = await self.send_chat_cmd(CC.APIJoinGroup_cmd_string({"groupId": group_id})) + if r["type"] == "userAcceptedGroupSent": + return r["groupInfo"] + raise ChatCommandError("error joining group", r) + + async def api_accept_member( + self, group_id: int, group_member_id: int, member_role: T.GroupMemberRole + ) -> T.GroupMember: + r = await self.send_chat_cmd( + CC.APIAcceptMember_cmd_string( + {"groupId": group_id, "groupMemberId": group_member_id, "memberRole": member_role} + ) + ) + if r["type"] == "memberAccepted": + return r["member"] + raise ChatCommandError("error accepting member", r) + + async def api_set_members_role( + self, group_id: int, group_member_ids: list[int], member_role: T.GroupMemberRole + ) -> None: + r = await self.send_chat_cmd( + CC.APIMembersRole_cmd_string( + {"groupId": group_id, "groupMemberIds": group_member_ids, "memberRole": member_role} + ) + ) + if r["type"] != "membersRoleUser": + raise ChatCommandError("error setting members role", r) + + async def api_block_members_for_all( + self, group_id: int, group_member_ids: list[int], blocked: bool + ) -> None: + r = await self.send_chat_cmd( + CC.APIBlockMembersForAll_cmd_string( + {"groupId": group_id, "groupMemberIds": group_member_ids, "blocked": blocked} + ) + ) + if r["type"] != "membersBlockedForAllUser": + raise ChatCommandError("error blocking members", r) + + async def api_remove_members( + self, group_id: int, member_ids: list[int], with_messages: bool = False + ) -> list[T.GroupMember]: + r = await self.send_chat_cmd( + CC.APIRemoveMembers_cmd_string( + {"groupId": group_id, "groupMemberIds": member_ids, "withMessages": with_messages} + ) + ) + if r["type"] == "userDeletedMembers": + return r["members"] + raise ChatCommandError("error removing member", r) + + async def api_leave_group(self, group_id: int) -> T.GroupInfo: + r = await self.send_chat_cmd(CC.APILeaveGroup_cmd_string({"groupId": group_id})) + if r["type"] == "leftMemberUser": + return r["groupInfo"] + raise ChatCommandError("error leaving group", r) + + async def api_list_members(self, group_id: int) -> list[T.GroupMember]: + r = await self.send_chat_cmd(CC.APIListMembers_cmd_string({"groupId": group_id})) + if r["type"] == "groupMembers": + return r["group"]["members"] + raise ChatCommandError("error getting group members", r) + + async def api_new_group(self, user_id: int, group_profile: T.GroupProfile) -> T.GroupInfo: + r = await self.send_chat_cmd( + CC.APINewGroup_cmd_string( + {"userId": user_id, "groupProfile": group_profile, "incognito": False} + ) + ) + if r["type"] == "groupCreated": + return r["groupInfo"] + raise ChatCommandError("error creating group", r) + + async def api_update_group_profile( + self, group_id: int, group_profile: T.GroupProfile + ) -> T.GroupInfo: + r = await self.send_chat_cmd( + CC.APIUpdateGroupProfile_cmd_string( + {"groupId": group_id, "groupProfile": group_profile} + ) + ) + if r["type"] == "groupUpdated": + return r["toGroup"] + raise ChatCommandError("error updating group", r) + + # ------------------------------------------------------------------ # + # Group link commands + # ------------------------------------------------------------------ # + + async def api_create_group_link(self, group_id: int, member_role: T.GroupMemberRole) -> str: + r = await self.send_chat_cmd( + CC.APICreateGroupLink_cmd_string({"groupId": group_id, "memberRole": member_role}) + ) + if r["type"] == "groupLinkCreated": + link = r["groupLink"]["connLinkContact"] + return link.get("connShortLink") or link["connFullLink"] + raise ChatCommandError("error creating group link", r) + + async def api_set_group_link_member_role( + self, group_id: int, member_role: T.GroupMemberRole + ) -> None: + r = await self.send_chat_cmd( + CC.APIGroupLinkMemberRole_cmd_string({"groupId": group_id, "memberRole": member_role}) + ) + if r["type"] != "groupLink": + raise ChatCommandError("error setting group link member role", r) + + async def api_delete_group_link(self, group_id: int) -> None: + r = await self.send_chat_cmd(CC.APIDeleteGroupLink_cmd_string({"groupId": group_id})) + if r["type"] != "groupLinkDeleted": + raise ChatCommandError("error deleting group link", r) + + async def api_get_group_link(self, group_id: int) -> T.GroupLink: + r = await self.send_chat_cmd(CC.APIGetGroupLink_cmd_string({"groupId": group_id})) + if r["type"] == "groupLink": + return r["groupLink"] + raise ChatCommandError("error getting group link", r) + + async def api_get_group_link_str(self, group_id: int) -> str: + link = (await self.api_get_group_link(group_id))["connLinkContact"] + return link.get("connShortLink") or link["connFullLink"] + + # ------------------------------------------------------------------ # + # Connection commands + # ------------------------------------------------------------------ # + + async def api_create_link(self, user_id: int) -> str: + r = await self.send_chat_cmd( + CC.APIAddContact_cmd_string({"userId": user_id, "incognito": False}) + ) + if r["type"] == "invitation": + link = r["connLinkInvitation"] + return link.get("connShortLink") or link["connFullLink"] + raise ChatCommandError("error creating link", r) + + async def api_connect_plan( + self, user_id: int, connection_link: str + ) -> tuple[T.ConnectionPlan, T.CreatedConnLink]: + r = await self.send_chat_cmd( + CC.APIConnectPlan_cmd_string( + {"userId": user_id, "connectionLink": connection_link, "resolveKnown": False} + ) + ) + if r["type"] == "connectionPlan": + return (r["connectionPlan"], r["connLink"]) + raise ChatCommandError("error getting connect plan", r) + + async def api_connect( + self, + user_id: int, + incognito: bool, + prepared_link: T.CreatedConnLink | None = None, + ) -> ConnReqType: + args: CC.APIConnect = {"userId": user_id, "incognito": incognito} + if prepared_link is not None: + args["preparedLink_"] = prepared_link + r = await self.send_chat_cmd(CC.APIConnect_cmd_string(args)) + return self._handle_connect_result(r) + + async def api_connect_active_user(self, conn_link: str) -> ConnReqType: + r = await self.send_chat_cmd( + CC.Connect_cmd_string({"incognito": False, "connLink_": conn_link}) + ) + return self._handle_connect_result(r) + + def _handle_connect_result(self, r: CR.ChatResponse) -> ConnReqType: + if r["type"] == "sentConfirmation": + return "invitation" + if r["type"] == "sentInvitation": + return "contact" + if r["type"] == "contactAlreadyExists": + raise ContactAlreadyExistsError("contact already exists", r) + raise ChatCommandError("connection error", r) + + async def api_accept_contact_request(self, contact_req_id: int) -> T.Contact: + r = await self.send_chat_cmd( + CC.APIAcceptContact_cmd_string({"contactReqId": contact_req_id}) + ) + if r["type"] == "acceptingContactRequest": + return r["contact"] + raise ChatCommandError("error accepting contact request", r) + + async def api_reject_contact_request(self, contact_req_id: int) -> None: + r = await self.send_chat_cmd( + CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id}) + ) + if r["type"] != "contactRequestRejected": + raise ChatCommandError("error rejecting contact request", r) + + # ------------------------------------------------------------------ # + # Chat commands + # ------------------------------------------------------------------ # + + async def api_list_contacts(self, user_id: int) -> list[T.Contact]: + r = await self.send_chat_cmd(CC.APIListContacts_cmd_string({"userId": user_id})) + if r["type"] == "contactsList": + return r["contacts"] + raise ChatCommandError("error listing contacts", r) + + async def api_list_groups( + self, + user_id: int, + contact_id: int | None = None, + search: str | None = None, + ) -> list[T.GroupInfo]: + args: CC.APIListGroups = {"userId": user_id} + if contact_id is not None: + args["contactId_"] = contact_id + if search is not None: + args["search"] = search + r = await self.send_chat_cmd(CC.APIListGroups_cmd_string(args)) + if r["type"] == "groupsList": + return r["groups"] + raise ChatCommandError("error listing groups", r) + + async def api_get_chats( + self, + user_id: int, + pagination: T.PaginationByTime, + query: T.ChatListQuery | None = None, + pending_connections: bool = False, + ) -> list[T.AChat]: + if query is None: + query = {"type": "filters", "favorite": False, "unread": False} + r = await self.send_chat_cmd( + CC.APIGetChats_cmd_string( + { + "userId": user_id, + "pendingConnections": pending_connections, + "pagination": pagination, + "query": query, + } + ) + ) + if r["type"] == "apiChats": + return r["chats"] + raise ChatCommandError("error getting chats", r) + + async def api_delete_chat( + self, + chat_type: T.ChatType, + chat_id: int, + delete_mode: T.ChatDeleteMode | None = None, + ) -> None: + if delete_mode is None: + delete_mode = {"type": "full", "notify": True} + r = await self.send_chat_cmd( + CC.APIDeleteChat_cmd_string( + { + "chatRef": {"chatType": chat_type, "chatId": chat_id}, + "chatDeleteMode": delete_mode, + } + ) + ) + if chat_type == "direct" and r["type"] == "contactDeleted": + return + if chat_type == "group" and r["type"] == "groupDeletedUser": + return + raise ChatCommandError("error deleting chat", r) + + async def api_set_group_custom_data( + self, group_id: int, custom_data: dict[str, object] | None = None + ) -> None: + args: CC.APISetGroupCustomData = {"groupId": group_id} + if custom_data is not None: + args["customData"] = custom_data + r = await self.send_chat_cmd(CC.APISetGroupCustomData_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting group custom data", r) + + async def api_set_contact_custom_data( + self, contact_id: int, custom_data: dict[str, object] | None = None + ) -> None: + args: CC.APISetContactCustomData = {"contactId": contact_id} + if custom_data is not None: + args["customData"] = custom_data + r = await self.send_chat_cmd(CC.APISetContactCustomData_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting contact custom data", r) + + async def api_set_auto_accept_member_contacts(self, user_id: int, on_off: bool) -> None: + r = await self.send_chat_cmd( + CC.APISetUserAutoAcceptMemberContacts_cmd_string({"userId": user_id, "onOff": on_off}) + ) + if r["type"] != "cmdOk": + raise ChatCommandError("error setting auto-accept member contacts", r) + + async def api_get_chat(self, chat_type: T.ChatType, chat_id: int, count: int) -> dict[str, Any]: + ref = T.ChatType_cmd_string(chat_type) + str(chat_id) + r = await self.send_chat_cmd(f"/_get chat {ref} count={count}") + if r["type"] == "apiChat": + return r["chat"] + raise ChatCommandError("error getting chat", r) + + # ------------------------------------------------------------------ # + # User profile commands + # ------------------------------------------------------------------ # + + async def api_get_active_user(self) -> T.User | None: + try: + r = await self.send_chat_cmd(CC.ShowActiveUser_cmd_string({})) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("unexpected response", r) + except core.ChatAPIError as e: + ce = e.chat_error + if ( + ce is not None + and ce.get("type") == "error" + and ce.get("errorType", {}).get("type") == "noActiveUser" + ): + return None + raise + + async def api_create_active_user(self, profile: T.Profile | None = None) -> T.User: + new_user: T.NewUser = {"pastTimestamp": False, "userChatRelay": False} + if profile is not None: + new_user["profile"] = profile + r = await self.send_chat_cmd(CC.CreateActiveUser_cmd_string({"newUser": new_user})) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("unexpected response", r) + + async def api_list_users(self) -> list[T.UserInfo]: + r = await self.send_chat_cmd(CC.ListUsers_cmd_string({})) + if r["type"] == "usersList": + return r["users"] + raise ChatCommandError("error listing users", r) + + async def api_set_active_user(self, user_id: int, view_pwd: str | None = None) -> T.User: + args: CC.APISetActiveUser = {"userId": user_id} + if view_pwd is not None: + args["viewPwd"] = view_pwd + r = await self.send_chat_cmd(CC.APISetActiveUser_cmd_string(args)) + if r["type"] == "activeUser": + return r["user"] + raise ChatCommandError("error setting active user", r) + + async def api_delete_user( + self, user_id: int, del_smp_queues: bool, view_pwd: str | None = None + ) -> None: + args: CC.APIDeleteUser = {"userId": user_id, "delSMPQueues": del_smp_queues} + if view_pwd is not None: + args["viewPwd"] = view_pwd + r = await self.send_chat_cmd(CC.APIDeleteUser_cmd_string(args)) + if r["type"] != "cmdOk": + raise ChatCommandError("error deleting user", r) + + async def api_update_profile( + self, user_id: int, profile: T.Profile + ) -> T.UserProfileUpdateSummary | None: + r = await self.send_chat_cmd( + CC.APIUpdateProfile_cmd_string({"userId": user_id, "profile": profile}) + ) + if r["type"] == "userProfileNoChange": + return None + if r["type"] == "userProfileUpdated": + return r["updateSummary"] + raise ChatCommandError("error updating profile", r) + + async def api_set_contact_prefs(self, contact_id: int, preferences: T.Preferences) -> None: + r = await self.send_chat_cmd( + CC.APISetContactPrefs_cmd_string({"contactId": contact_id, "preferences": preferences}) + ) + if r["type"] != "contactPrefsUpdated": + raise ChatCommandError("error setting contact prefs", r) + + # ------------------------------------------------------------------ # + # Member contact commands + # ------------------------------------------------------------------ # + + async def api_create_member_contact(self, group_id: int, group_member_id: int) -> T.Contact: + r = await self.send_chat_cmd(f"/_create member contact #{group_id} {group_member_id}") + if r["type"] == "newMemberContact": + return r["contact"] + raise ChatCommandError("error creating member contact", r) + + async def api_send_member_contact_invitation( + self, + contact_id: int, + message: T.MsgContent | str | None = None, + ) -> T.Contact: + cmd = f"/_invite member contact @{contact_id}" + if message is not None: + if isinstance(message, str): + cmd += f" text {message}" + else: + cmd += f" json {json.dumps(message)}" + r = await self.send_chat_cmd(cmd) + if r["type"] == "newMemberContactSentInv": + return r["contact"] + raise ChatCommandError("error sending member contact invitation", r) diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py new file mode 100644 index 0000000000..fb511e2818 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -0,0 +1,178 @@ +"""`Bot` — Client extended with server-side features (address, auto-accept, commands).""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import util +from .api import Db +from .client import ( + BotProfile, + ChatMessage, + Client, + CommandHandler, + EventHandler, + FileMessage, + ImageMessage, + LinkMessage, + Message, + MessageHandler, + Middleware, + ParsedCommand, + Profile, + ReportMessage, + TextMessage, + UnknownMessage, + VideoMessage, + VoiceMessage, + log, +) +from .core import MigrationConfirmation +from .types import T + + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + + +class Bot(Client): + """SimpleX bot — Client extended with server-side features. + + On top of `Client` (identity + messaging + connect_to/send_and_wait/events), + a Bot: + - creates and announces its own contact address + - auto-accepts incoming contact requests (configurable) + - advertises a list of slash-commands in its profile preferences + - sets `peerType=bot` and disables calls/voice in profile prefs + - sends a `welcome` message to new contacts via the auto-reply address setting + + If you want just identity + messaging without any of that, use `Client` + directly. + """ + + def __init__( + self, + *, + profile: Profile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: + super().__init__( + profile=profile, + db=db, + confirm_migrations=confirm_migrations, + update_profile=update_profile, + log_contacts=log_contacts, + log_network=log_network, + ) + self._welcome = welcome + self._commands = commands or [] + self._create_address = create_address + self._update_address = update_address + self._auto_accept = auto_accept + self._business_address = business_address + self._allow_files = allow_files + + # ------------------------------------------------------------------ # + # Profile + address sync (overrides hooks in Client) + # ------------------------------------------------------------------ # + + async def _post_start(self, user: T.User) -> None: + """Bots sync address first, then embed the link in the profile.""" + link = await self._sync_address(user) + await self._maybe_sync_profile(user, contact_link=link) + + async def _sync_address(self, user: T.User) -> str | None: + """Address sync. Returns the public link if any, for embedding in the profile.""" + api = self.api + user_id = user["userId"] + + address = await api.api_get_user_address(user_id) + if address is None: + if self._create_address: + log.info("Bot has no address, creating...") + await api.api_create_user_address(user_id) + address = await api.api_get_user_address(user_id) + if address is None: + raise RuntimeError("Failed reading newly created user address") + else: + log.warning("Bot has no address") + + link: str | None = None + if address is not None: + link = util.contact_address_str(address["connLinkContact"]) + log.info("Bot address: %s", link) + + # Address settings (auto-accept + welcome message). Mirrors bot.ts:185-194. + # autoAccept present → accept; absent → no auto-accept (mirrors Node bot.ts). + if address is not None and self._update_address: + desired: T.AddressSettings = {"businessAddress": self._business_address} + if self._auto_accept: + desired["autoAccept"] = {"acceptIncognito": False} + if self._welcome is not None: + desired["autoReply"] = ( + {"type": "text", "text": self._welcome} + if isinstance(self._welcome, str) + else self._welcome + ) + if address["addressSettings"] != desired: + log.info("Bot address settings changed, updating...") + await api.api_set_address_settings(user_id, desired) + + return link + + def _profile_to_wire(self) -> T.Profile: + """Bot profile: base profile + peerType=bot, command list, calls/voice prefs disabled. + + Mirrors Node `mkBotProfile` (bot.ts:88-102). + """ + p = super()._profile_to_wire() + prefs: T.Preferences = { + "calls": {"allow": "no"}, + "voice": {"allow": "no"}, + "files": {"allow": "yes" if self._allow_files else "no"}, + } + if self._commands: + prefs["commands"] = [ + {"type": "command", "keyword": c.keyword, "label": c.label} + for c in self._commands + ] + p["preferences"] = prefs + p["peerType"] = "bot" + return p + + +__all__ = [ + "Bot", + "BotCommand", + "BotProfile", + "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "ParsedCommand", + "Profile", + "ReportMessage", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py new file mode 100644 index 0000000000..b0d144b8b9 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -0,0 +1,955 @@ +"""Base `Client` API: lifecycle, dispatch, decorators, connect_to / send_and_wait / events. + +Bot extends Client to add server-side features (address, auto-accept, welcome, +commands). Client by itself is suitable for monitors, probes, automated +participants — anything that talks TO services rather than accepting incoming +connections. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import signal as _signal +from collections.abc import AsyncIterator, Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Generic, Literal, TypeVar, overload + +from . import util +from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db +from .core import ChatAPIError, MigrationConfirmation +from .filters import compile_message_filter +from .types import CEvt, T + +log = logging.getLogger("simplex_chat") + +C = TypeVar("C", bound="T.MsgContent") + + +@dataclass(slots=True) +class Profile: + """SimpleX user profile fields: display name, optional full name, descr, avatar. + + Universal — used by both `Client` and `Bot`. The bot-specific extensions + (peerType=bot, command list, calls/voice preferences) are added at + wire-conversion time by `Bot`, not stored here. + """ + + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + + +# Backwards-compatibility alias — the dataclass was named `BotProfile` before +# the Client/Bot hierarchy was introduced. Keep the old name working so +# `from simplex_chat import BotProfile` doesn't break existing code. +BotProfile = Profile + + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str + + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem + content: C + client: "Client" + + @property + def chat_info(self) -> T.ChatInfo: + return self.chat_item["chatInfo"] + + @property + def text(self) -> str | None: + c = self.content + if isinstance(c, dict): + return c.get("text") # type: ignore[return-value] + return None + + async def reply(self, text: str) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_text_reply(self.chat_item, text) + ci = items[0] + content = ci["chatItem"]["content"] + # content is CIContent — snd variant has msgContent; cast for type safety. + msg_content: T.MsgContent = content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + async def reply_content(self, content: T.MsgContent) -> "Message[T.MsgContent]": + items = await self.client.api.api_send_messages( + self.chat_info, [{"msgContent": content, "mentions": {}}] + ) + ci = items[0] + ci_content = ci["chatItem"]["content"] + msg_content: T.MsgContent = ci_content["msgContent"] # type: ignore[index] + return Message(chat_item=ci, content=msg_content, client=self.client) + + +# Concrete narrowed aliases — one per MsgContent_ variant in _types.py. +TextMessage = Message[T.MsgContent_text] +LinkMessage = Message[T.MsgContent_link] +ImageMessage = Message[T.MsgContent_image] +VideoMessage = Message[T.MsgContent_video] +VoiceMessage = Message[T.MsgContent_voice] +FileMessage = Message[T.MsgContent_file] +ReportMessage = Message[T.MsgContent_report] +ChatMessage = Message[T.MsgContent_chat] +UnknownMessage = Message[T.MsgContent_unknown] + +MessageHandler = Callable[[Message[Any]], Awaitable[None]] +CommandHandler = Callable[[Message[Any], ParsedCommand], Awaitable[None]] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] + + +class Middleware: + """Override `__call__` to wrap message handlers with cross-cutting logic. + + `handler` is the next stage in the chain — call it with `(message, data)` + to continue, or skip the call to short-circuit. `data` is a per-dispatch + dict that middleware can use to pass values down the chain. + """ + + async def __call__( + self, + handler: Callable[[Message[Any], dict[str, object]], Awaitable[None]], + message: Message[Any], + data: dict[str, object], + ) -> None: + await handler(message, data) + + +class Client: + """SimpleX participant — has an identity, sends and receives messages. + + No address, no auto-accept of incoming requests, no bot profile prefs. Use + this for monitors, probes, automated participants — anything that talks + TO services rather than accepting incoming connections. Use `Bot` for the + server-side flavour. + + Typical pattern: + + async with Client(profile=Profile(display_name="m"), db=...) as c: + serve = asyncio.create_task(c.serve_forever()) + contact = await c.connect_to(link) + reply = await c.send_and_wait(contact["contactId"], "/help") + c.stop() + await serve + + The decorator-style handlers (`@on_message`, `@on_command`, `@on_event`) + work too if you want callback-style dispatch instead of async-await. + """ + + def __init__( + self, + *, + profile: Profile, + db: Db, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + update_profile: bool = True, + log_contacts: bool = False, + log_network: bool = False, + ) -> None: + self._profile = profile + self._db = db + self._confirm_migrations = confirm_migrations + self._update_profile = update_profile + self._log_contacts = log_contacts + self._log_network = log_network + self._api: ChatApi | None = None + self._serving = False + self._stop_event = asyncio.Event() + self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] + self._command_handlers: list[ + tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] + ] = [] + self._event_handlers: dict[str, list[EventHandler]] = {} + self._middleware: list[Middleware] = [] + # Track default-handler registration so __aenter__ on a re-used client + # doesn't accumulate duplicate log/error handlers. + self._defaults_registered = False + # Internal waiters used by `send_and_wait` (keyed by contact_id, FIFO + # within a contact) and `connect_to` (one-shot, resolved on the next + # contactConnected event). Populated by user-async-callers, drained + # in `_dispatch_event` before user handlers run. + self._reply_waiters: dict[int, list[asyncio.Future[Message[Any]]]] = {} + self._connect_waiters: list[asyncio.Future[T.Contact]] = [] + + @property + def api(self) -> ChatApi: + if self._api is None: + raise RuntimeError("Client not initialized — call run() or use `async with client:`") + return self._api + + # ------------------------------------------------------------------ # + # Decorators + # ------------------------------------------------------------------ # + + @overload + def on_message( + self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[ + [Callable[[TextMessage], Awaitable[None]]], + Callable[[TextMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["link"], **rest: Any + ) -> Callable[ + [Callable[[LinkMessage], Awaitable[None]]], + Callable[[LinkMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[ + [Callable[[ImageMessage], Awaitable[None]]], + Callable[[ImageMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["video"], **rest: Any + ) -> Callable[ + [Callable[[VideoMessage], Awaitable[None]]], + Callable[[VideoMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["voice"], **rest: Any + ) -> Callable[ + [Callable[[VoiceMessage], Awaitable[None]]], + Callable[[VoiceMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["file"], **rest: Any + ) -> Callable[ + [Callable[[FileMessage], Awaitable[None]]], + Callable[[FileMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["report"], **rest: Any + ) -> Callable[ + [Callable[[ReportMessage], Awaitable[None]]], + Callable[[ReportMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["chat"], **rest: Any + ) -> Callable[ + [Callable[[ChatMessage], Awaitable[None]]], + Callable[[ChatMessage], Awaitable[None]], + ]: ... + + @overload + def on_message( + self, *, content_type: Literal["unknown"], **rest: Any + ) -> Callable[ + [Callable[[UnknownMessage], Awaitable[None]]], + Callable[[UnknownMessage], Awaitable[None]], + ]: ... + + @overload + def on_message(self, **rest: Any) -> Callable[[MessageHandler], MessageHandler]: ... + + def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: + predicate = compile_message_filter(filter_kw) + + def deco(fn: MessageHandler) -> MessageHandler: + self._message_handlers.append((predicate, fn)) + return fn + + return deco + + def on_command( + self, name: str | tuple[str, ...], **filter_kw: Any + ) -> Callable[[CommandHandler], CommandHandler]: + names = (name,) if isinstance(name, str) else tuple(name) + predicate = compile_message_filter(filter_kw) + + def deco(fn: CommandHandler) -> CommandHandler: + self._command_handlers.append((names, predicate, fn)) + return fn + + return deco + + # `on_event` is exposed as a property typed as the generated + # `OnEventDecorator` Protocol so per-tag narrowing applies — e.g. + # `@client.on_event("contactConnected")` types the handler's event + # parameter as `CEvt.ContactConnected`, not the unnarrowed + # `CEvt.ChatEvent` union. The Protocol's overload chain lives in + # generated code (one entry per event tag) so it stays in sync with + # the wire schema automatically. The runtime implementation is the + # plain handler-registration below. + @property + def on_event(self) -> CEvt.OnEventDecorator: + return self._register_event_handler # type: ignore[return-value] + + def _register_event_handler( + self, event: str, / + ) -> Callable[[EventHandler], EventHandler]: + def deco(fn: EventHandler) -> EventHandler: + self._event_handlers.setdefault(event, []).append(fn) + return fn + + return deco + + def use(self, middleware: Middleware) -> None: + self._middleware.append(middleware) + + # ------------------------------------------------------------------ # + # Lifecycle + # ------------------------------------------------------------------ # + + async def __aenter__(self) -> "Client": + # Order matters: libsimplex `/_start` requires an active user, so + # ensure (or create) the user first, THEN start the chat, THEN + # do post-start setup (profile sync; Bot adds address sync). + # Clear `_stop_event` here (not in `serve_forever`/`events`) so that + # a `stop()` call landing between `__aenter__` and the receive loop + # — e.g. a signal handler firing while signal handlers are being + # wired up — is preserved and causes the loop to exit immediately + # on entry. + self._stop_event.clear() + self._api = await ChatApi.init(self._db, self._confirm_migrations) + try: + user = await self._ensure_active_user() + await self._api.start_chat() + await self._post_start(user) + self._register_log_handlers() + return self + except BaseException: + # __aexit__ is only called when __aenter__ returns successfully — + # roll back the open chat controller here so a failure during + # init doesn't leak the FFI resource. + await self._shutdown_partial_init() + raise + + async def _shutdown_partial_init(self) -> None: + """Best-effort teardown for an `__aenter__` that didn't reach return.""" + api = self._api + if api is None: + return + if api.started: + try: + await api.stop_chat() + except Exception: + log.exception("stop_chat failed during init rollback") + try: + await api.close() + except Exception: + log.exception("close failed during init rollback") + self._api = None + + async def __aexit__(self, *exc_info: object) -> None: + self.stop() + api = self._api + if api is None: + return + # Null out the reference up-front so the Client appears closed even + # if stop_chat / close raise — otherwise `client.api` would still + # hand back a half-shutdown controller after `async with` exits. + self._api = None + try: + await api.stop_chat() + finally: + await api.close() + + async def _post_start(self, user: T.User) -> None: + """Hook for subclasses to add work between `start_chat` and serving. + + Default (Client): sync profile only. Bot overrides to also sync its + address and embed the connection link in the profile. + """ + await self._maybe_sync_profile(user, contact_link=None) + + def run(self) -> None: + """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. + + Configures `logging.basicConfig(level=INFO)` if the root logger has no + handlers yet, so startup messages and the announced address are + visible without callers having to set up logging. Embedders that + manage logging themselves are unaffected (basicConfig is a no-op when + handlers already exist). + """ + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + async def _main() -> None: + async with self: + loop = asyncio.get_running_loop() + # First Ctrl+C → graceful stop (~500ms, bounded by the + # receive-loop poll interval). Second Ctrl+C → force-exit + # immediately (in case stop_chat / close hang on a wedged + # FFI call). Standard CLI UX (jupyter, ipython, …). + sigint_count = 0 + + def on_interrupt() -> None: + nonlocal sigint_count + sigint_count += 1 + if sigint_count == 1: + log.info("stopping... (press Ctrl+C again to force exit)") + self.stop() + else: + os._exit(130) # 128 + SIGINT + + if hasattr(_signal, "SIGINT"): + try: + loop.add_signal_handler(_signal.SIGINT, on_interrupt) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: on_interrupt()) + await self.serve_forever() + + asyncio.run(_main()) + + async def serve_forever(self) -> None: + if self._serving: + raise RuntimeError("already serving") + self._serving = True + try: + await self._receive_loop() + finally: + self._serving = False + + def stop(self) -> None: + self._stop_event.set() + + async def events(self) -> AsyncIterator[CEvt.ChatEvent]: + """Yield chat events one at a time — alternative to `serve_forever`. + + Runs the full dispatch pipeline on each event (internal waiters, + user `@on_event`/`@on_message`/`@on_command` handlers), then yields + the raw event for inspection. Use this when you want direct control + over the receive loop, e.g. to surface errors that `serve_forever` + would otherwise swallow, or to compose with other async iterators. + + Mutually exclusive with `serve_forever`. Stops when `stop()` is + called or when the consumer exits the `async for` loop (which + triggers the generator's `aclose`). Async-generator GC alone is + not reliable for cleanup — exit the loop explicitly. + """ + if self._serving: + raise RuntimeError( + "already serving — events() and serve_forever() are mutually exclusive" + ) + self._serving = True + try: + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + yield event + finally: + self._serving = False + + async def connect_to(self, link: str, *, timeout: float = 120.0) -> T.Contact: + """Connect to a SimpleX contact link, returning the resulting Contact. + + Idempotent: if the link is already known (via `api_connect_plan`) + the existing Contact is returned without re-handshaking. Otherwise + initiates the handshake and waits for the `contactConnected` event. + + Requires the receive loop to be running (`serve_forever` or + `events()`), since the handshake completes asynchronously. + + Concurrency caveat: pending `connect_to` waiters are a single FIFO + with no link↔waiter correlation. If you call `connect_to` for two + different links concurrently, or if a third party connects to your + address (Bot subclass with `auto_accept=True`) while a `connect_to` + is in flight, the returned Contact may not be the one you asked + for. Sequence concurrent connects, or call them one at a time. + + Raises: + asyncio.TimeoutError: handshake didn't complete within `timeout` + ValueError: timeout is not positive + RuntimeError: no active user, or receive loop not running + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # the handshake side-effect (api_connect_active_user) has + # already gone over the wire, leaving the caller with no + # Contact reference and a half-initiated connection. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "connect_to requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + api = self.api + user = await api.api_get_active_user() + if user is None: + raise RuntimeError("no active user") + + existing = await self._lookup_known_contact(user["userId"], link) + if existing is not None: + return existing + + loop = asyncio.get_running_loop() + waiter: asyncio.Future[T.Contact] = loop.create_future() + self._connect_waiters.append(waiter) + try: + try: + await api.api_connect_active_user(link) + except ContactAlreadyExistsError: + # Handshake mid-flight, or a previous incomplete attempt + # left the connection in a known-but-not-connected state. + # Either way: wait for the contactConnected event. + pass + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + if waiter in self._connect_waiters: + self._connect_waiters.remove(waiter) + + async def _lookup_known_contact(self, user_id: int, link: str) -> T.Contact | None: + """Resolve a link to an existing Contact via api_connect_plan, or None. + + Only ChatCommandError is swallowed (malformed link, etc.) — the + connect_to caller will fall back to the full handshake path. + Transport/FFI errors propagate so the caller sees the real cause. + """ + try: + plan, _ = await self.api.api_connect_plan(user_id, link) + except ChatCommandError: + return None + if plan["type"] == "contactAddress": + cap = plan["contactAddressPlan"] + if cap["type"] == "known": + return cap["contact"] + if plan["type"] == "invitationLink": + ilp = plan["invitationLinkPlan"] + if ilp["type"] == "known": + return ilp["contact"] + return None + + async def send_and_wait( + self, + contact_id: int, + text: str, + *, + timeout: float = 30.0, + ) -> "Message[T.MsgContent]": + """Send text to a direct contact and wait for the next reply from them. + + Waiters are FIFO per contact_id: two concurrent calls to the same + contact get two replies in send order. Concurrent calls to *different* + contacts run in parallel. Once a reply matches a waiter, user + message handlers do NOT fire for that message — the awaiter owns it. + + Correlation caveat: matching is by sender contact_id only — there + is no message-id correlation. ANY direct message from `contact_id` + arriving while a waiter is pending will resolve that waiter, even + if it was an unsolicited message (e.g. an auto-reply from a bot's + address settings, a delayed reply from a previous send, a push + notification). For strict request/response semantics, ensure the + peer is otherwise quiet, or use the `@on_message` callback model. + + Requires the receive loop to be running. Raises asyncio.TimeoutError + on timeout, ValueError if timeout is not positive. + """ + if timeout <= 0: + # Reject upfront — otherwise wait_for raises TimeoutError after + # api_send_text_message already went over the wire, surprising + # the caller with a sent message and no Future to await. + raise ValueError(f"timeout must be positive, got {timeout!r}") + if not self._serving: + raise RuntimeError( + "send_and_wait requires the receive loop to be running — " + "call serve_forever() (in a task) or iterate events() first" + ) + loop = asyncio.get_running_loop() + waiter: asyncio.Future[Message[Any]] = loop.create_future() + waiters = self._reply_waiters.setdefault(contact_id, []) + waiters.append(waiter) + try: + await self.api.api_send_text_message(["direct", contact_id], text) + return await asyncio.wait_for(waiter, timeout=timeout) + finally: + # Always clean up our slot, even on send error or timeout. Leaving + # an unresolved Future in the dict would make the next incoming + # message resolve a future no one is waiting on. + if waiter in waiters: + waiters.remove(waiter) + if not waiters: + self._reply_waiters.pop(contact_id, None) + + async def _receive_loop(self) -> None: + # Catch broad Exception so a single malformed event or transient + # native error doesn't crash the whole client. CancelledError must + # always re-raise so `stop()` and asyncio cancellation work. + # `wait_us=500_000` (500ms) bounds the worst-case Ctrl+C latency: + # the C call blocks the worker thread until timeout, and the loop + # only checks `_stop_event` between polls. + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=500_000) + except asyncio.CancelledError: + raise + except ChatAPIError as e: + # Async chat errors emitted via the Haskell `eToView` path — + # routine soft errors (stale connections after a peer deletes + # a chat, file cleanup failures, etc.) intermixed with + # CRITICAL agent failures the operator must see. Mirror the + # desktop policy in SimpleXAPI.kt:3332-3340: escalate + # CRITICAL agent errors, keep everything else at debug. + chat_err: Any = e.chat_error or {} + agent_err: Any = ( + chat_err.get("agentError", {}) if chat_err.get("type") == "errorAgent" else {} + ) + if agent_err.get("type") == "CRITICAL": + log.error( + "chat agent CRITICAL: %s (offerRestart=%s)", + agent_err.get("criticalErr"), + agent_err.get("offerRestart"), + ) + else: + log.debug("chat event error: %s", chat_err.get("type")) + continue + except Exception: + log.exception("recv_chat_event failed") + # Bound the spin rate when the FFI is wedged on a persistent + # error (vs the timeout path, which already paces itself). + await asyncio.sleep(0.5) + continue + if event is None: + continue + try: + await self._dispatch_event(event) + except asyncio.CancelledError: + raise + except Exception: + log.exception("dispatch_event failed for tag=%s", event.get("type")) + + # ------------------------------------------------------------------ # + # Dispatch + # ------------------------------------------------------------------ # + + async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: + tag = event["type"] + # Resolve internal waiters BEFORE user handlers. A pending + # `connect_to` consumes the contactConnected; a pending + # `send_and_wait` consumes the matching incoming message — user + # handlers don't fire for that message. This matches the mental + # model: the awaiter explicitly asked for this event. + if tag == "contactConnected" and self._connect_waiters: + contact: T.Contact = event["contact"] # type: ignore[typeddict-item] + waiter = self._connect_waiters.pop(0) + if not waiter.done(): + waiter.set_result(contact) + for h in self._event_handlers.get(tag, []): + try: + await h(event) + except Exception: + log.exception("on_event handler failed") + if tag == "newChatItems": + evt: CEvt.NewChatItems = event # type: ignore[assignment] + for ci in evt["chatItems"]: + content = ci["chatItem"]["content"] + if content["type"] != "rcvMsgContent": + continue + msg_content = content["msgContent"] # type: ignore[index] + msg: Message[T.MsgContent] = Message(chat_item=ci, content=msg_content, client=self) + # If a send_and_wait is pending for this sender, fulfil it + # and skip the user dispatch chain — the awaiter "owns" this + # reply. FIFO within a contact_id. + if self._maybe_resolve_reply_waiter(msg): + continue + await self._dispatch_message(msg) + + def _maybe_resolve_reply_waiter(self, msg: Message[T.MsgContent]) -> bool: + chat_info = msg.chat_info + if chat_info.get("type") != "direct": + return False + contact_id = chat_info.get("contact", {}).get("contactId") # type: ignore[union-attr] + if contact_id is None: + return False + waiters = self._reply_waiters.get(contact_id) + if not waiters: + return False + # Skip waiters whose callers have already given up (cancelled by + # wait_for timing out at the same loop tick). Without this skip, + # a reply arriving in the narrow timeout-race window would be + # silently dropped because the FIFO would pop a done waiter and + # neither resolve it nor dispatch to user handlers. + while waiters: + waiter = waiters.pop(0) + if not waiter.done(): + if not waiters: + self._reply_waiters.pop(contact_id, None) + waiter.set_result(msg) + return True + self._reply_waiters.pop(contact_id, None) + return False + + async def _dispatch_message(self, msg: Message[Any]) -> None: + # First-match-wins. The squaring bot's `@on_message(text=NUMBER_RE)` + # and catch-all `@on_message(content_type="text")` both match a number + # like "1"; we want only the first to fire. Registration order is the + # priority order — register the most-specific filters first. + # + # Slash-commands are tried first against command handlers; if no + # command handler matches, fall through to message handlers (so + # `@on_message` can still catch unknown slash-commands). + cmd = self._parse_command(msg) + if cmd is not None: + for names, predicate, handler in self._command_handlers: + if cmd.keyword in names and predicate(msg): + await self._invoke_command_with_middleware(handler, msg, cmd) + return + for predicate, handler in self._message_handlers: + if predicate(msg): + await self._invoke_with_middleware(handler, msg) + return + + async def _invoke_with_middleware(self, handler: MessageHandler, message: Message[Any]) -> None: + # Fast path: most clients register no middleware. Skip the closure-chain + # construction and the empty-data dict on every dispatch. + if not self._middleware: + try: + await handler(message) + except Exception: + log.exception("message handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("message handler failed") + + async def _invoke_command_with_middleware( + self, handler: CommandHandler, message: Message[Any], cmd: ParsedCommand + ) -> None: + if not self._middleware: + try: + await handler(message, cmd) + except Exception: + log.exception("command handler failed") + return + + async def call(m: Message[Any], _data: dict[str, object]) -> None: + await handler(m, cmd) + + chain: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + + async def _wrapped( + m: Message[Any], + d: dict[str, object], + mw: Middleware = mw, + inner: Callable[[Message[Any], dict[str, object]], Awaitable[None]] = inner, + ) -> None: + await mw(inner, m, d) + + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("command handler failed") + + @staticmethod + def _parse_command(msg: Message[Any]) -> ParsedCommand | None: + parsed = util.ci_bot_command(msg.chat_item["chatItem"]) + if parsed is None: + return None + keyword, args = parsed + return ParsedCommand(keyword=keyword, args=args) + + # ------------------------------------------------------------------ # + # Profile sync + # ------------------------------------------------------------------ # + + async def _ensure_active_user(self) -> T.User: + """Get or create the active user. Must run before `start_chat`. + + Mirrors Node `createBotUser` (bot.ts:158-166). The chat controller + won't accept `/_start` without a user, so this phase has to land + before lifecycle proceeds. + """ + api = self.api + user = await api.api_get_active_user() + if user is None: + log.info("No active user in database, creating...") + user = await api.api_create_active_user(self._profile_to_wire()) + log.info("user: %s", user["profile"]["displayName"]) + return user + + async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None: + """Update the user profile on the wire if its fields changed. + + `contact_link` is only set by Bot (to embed its address). Mirrors + Node `updateBotUserProfile` (bot.ts:199-214). Field-by-field + comparison because user["profile"] is LocalProfile (has extra + fields profileId, localAlias, preferences, peerType) so a full + dict equality would always differ. + """ + if not self._update_profile: + return + new_profile = self._profile_to_wire() + if contact_link is not None: + new_profile["contactLink"] = contact_link + cur = user["profile"] + changed = ( + cur["displayName"] != new_profile["displayName"] + or cur.get("fullName", "") != new_profile.get("fullName", "") + or cur.get("shortDescr") != new_profile.get("shortDescr") + or cur.get("image") != new_profile.get("image") + or cur.get("preferences") != new_profile.get("preferences") + or cur.get("peerType") != new_profile.get("peerType") + or cur.get("contactLink") != new_profile.get("contactLink") + ) + if changed: + log.info("profile changed, updating...") + await self.api.api_update_profile(user["userId"], new_profile) + + def _profile_to_wire(self) -> T.Profile: + """Convert the user-facing Profile dataclass to wire format. + + Base version produces a plain user profile. Bot overrides this to + add the bot-specific extensions (peerType=bot, command list, + calls/voice/files prefs). + """ + p: T.Profile = { + "displayName": self._profile.display_name, + "fullName": self._profile.full_name, + } + if self._profile.short_descr is not None: + p["shortDescr"] = self._profile.short_descr + if self._profile.image is not None: + p["image"] = self._profile.image + return p + + # ------------------------------------------------------------------ # + # Log subscriptions (mirror Node subscribeLogEvents bot.ts:142-156) + # ------------------------------------------------------------------ # + + def _register_log_handlers(self) -> None: + # Idempotent: re-entering the async context must not stack duplicate + # log handlers. Always-on error handlers run regardless of + # log_contacts/log_network so messageError/chatError/chatErrors + # don't disappear into the void. + if self._defaults_registered: + return + self._defaults_registered = True + self._event_handlers.setdefault("messageError", []).append(self._log_message_error) + self._event_handlers.setdefault("chatError", []).append(self._log_chat_error) + self._event_handlers.setdefault("chatErrors", []).append(self._log_chat_errors) + if self._log_contacts: + self._event_handlers.setdefault("contactConnected", []).append( + self._log_contact_connected + ) + self._event_handlers.setdefault("contactDeletedByContact", []).append( + self._log_contact_deleted + ) + if self._log_network: + self._event_handlers.setdefault("hostConnected", []).append(self._log_host_connected) + self._event_handlers.setdefault("hostDisconnected", []).append( + self._log_host_disconnected + ) + self._event_handlers.setdefault("subscriptionStatus", []).append( + self._log_subscription_status + ) + + @staticmethod + async def _log_contact_connected(evt: CEvt.ChatEvent) -> None: + log.info("%s connected", evt["contact"]["profile"]["displayName"]) # type: ignore[index] + + @staticmethod + async def _log_contact_deleted(evt: CEvt.ChatEvent) -> None: + log.info( + "%s deleted connection", + evt["contact"]["profile"]["displayName"], # type: ignore[index] + ) + + @staticmethod + async def _log_host_connected(evt: CEvt.ChatEvent) -> None: + log.info("connected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_host_disconnected(evt: CEvt.ChatEvent) -> None: + log.info("disconnected server %s", evt["transportHost"]) # type: ignore[index] + + @staticmethod + async def _log_subscription_status(evt: CEvt.ChatEvent) -> None: + log.info( + "%d subscription(s) %s", + len(evt["connections"]), # type: ignore[index] + evt["subscriptionStatus"]["type"], # type: ignore[index] + ) + + @staticmethod + async def _log_message_error(evt: CEvt.ChatEvent) -> None: + log.warning("messageError: %s", evt.get("severity", "?")) # type: ignore[union-attr] + + @staticmethod + async def _log_chat_error(evt: CEvt.ChatEvent) -> None: + err = evt.get("chatError") # type: ignore[union-attr] + log.error("chatError: %s", err.get("type") if isinstance(err, dict) else err) + + @staticmethod + async def _log_chat_errors(evt: CEvt.ChatEvent) -> None: + errs = evt.get("chatErrors") or [] # type: ignore[union-attr] + log.error("chatErrors: %d errors", len(errs)) + + +__all__ = [ + "BotProfile", # backwards-compat alias for Profile + "ChatMessage", + "Client", + "CommandHandler", + "EventHandler", + "FileMessage", + "ImageMessage", + "LinkMessage", + "Message", + "MessageHandler", + "Middleware", + "ParsedCommand", + "Profile", + "ReportMessage", + "TextMessage", + "UnknownMessage", + "VideoMessage", + "VoiceMessage", +] diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py new file mode 100644 index 0000000000..075db34b52 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -0,0 +1,200 @@ +"""Internal typed async wrapper around libsimplex's 8 C ABI functions. + +Users interact with `Bot` / `ChatApi`. This module is exposed as +`simplex_chat.core` for tests and the api.ChatApi class only. +""" + +from __future__ import annotations + +import asyncio +import ctypes +import json +from enum import StrEnum +from typing import Any, TypedDict + +from . import _native +from .types import T, CR, CEvt + + +class ChatAPIError(Exception): + """Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error.""" + + def __init__(self, message: str, chat_error: T.ChatError | None = None): + super().__init__(message) + self.chat_error = chat_error + + +class ChatInitError(Exception): + """Raised when chat_migrate_init returns a DBMigrationResult error.""" + + def __init__(self, message: str, db_migration_error: dict[str, Any]): + super().__init__(message) + self.db_migration_error = db_migration_error + + +class MigrationConfirmation(StrEnum): + YES_UP = "yesUp" + YES_UP_DOWN = "yesUpDown" + CONSOLE = "console" + ERROR = "error" + + +class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields + fileKey: str + fileNonce: str + + +def _read_and_free(ptr: int | None) -> str: + """Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer. + + Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165. + """ + if not ptr: + raise RuntimeError("null pointer returned from libsimplex") + try: + return ctypes.string_at(ptr).decode("utf-8") + finally: + _native.libc().free(ctypes.c_void_p(ptr)) + + +async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: + def _call() -> str: + ptr = _native.lib().chat_send_cmd(ctrl, cmd.encode("utf-8")) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat command error: {err.get('type')}", err) # type: ignore[arg-type] + raise ChatAPIError(f"invalid chat command result: {raw[:200]}") + + +async def chat_recv_msg_wait(ctrl: int, wait_us: int = 500_000) -> CEvt.ChatEvent | None: + def _call() -> str: + # On timeout, the C side returns a non-NULL pointer to a single NUL byte + # (see Mobile.hs `fromMaybe ""`), so `_read_and_free` returns "" — no + # NULL-pointer guard is needed here. + ptr = _native.lib().chat_recv_msg_wait(ctrl, wait_us) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + if not raw: + return None + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat event error: {err.get('type')}", err) # type: ignore[arg-type] + raise ChatAPIError(f"invalid chat event: {raw[:200]}") + + +async def chat_migrate_init(db_path: str, db_key: str, confirm: MigrationConfirmation) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" + + def _call() -> tuple[int, str]: + ctrl = ctypes.c_void_p() + ptr = _native.lib().chat_migrate_init( + db_path.encode("utf-8"), + db_key.encode("utf-8"), + confirm.encode("utf-8"), + ctypes.byref(ctrl), + ) + return (ctrl.value or 0, _read_and_free(ptr)) + + ctrl_val, raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if parsed.get("type") == "ok": + if not ctrl_val: + # ABI invariant: type=="ok" → out-param written. Defensive guard so a + # broken libsimplex doesn't hand us a NULL controller that would only + # crash on first use much later. + raise RuntimeError("chat_migrate_init returned ok but did not set ctrl pointer") + return ctrl_val + raise ChatInitError( + "Database or migration error (see db_migration_error)", + parsed, + ) + + +async def chat_close_store(ctrl: int) -> None: + def _call() -> str: + ptr = _native.lib().chat_close_store(ctrl) + return _read_and_free(ptr) + + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs: + def _call() -> str: + ptr = _native.lib().chat_write_file(ctrl, path.encode("utf-8"), data, len(data)) + return _read_and_free(ptr) + + raw = await asyncio.to_thread(_call) + return _crypto_args_result(raw) + + +async def chat_read_file(path: str, args: CryptoArgs) -> bytes: + def _call() -> bytes: + ptr = _native.lib().chat_read_file( + path.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + ) + if not ptr: + raise RuntimeError("chat_read_file returned null") + addr = ctypes.cast(ptr, ctypes.c_void_p).value + assert addr is not None # `if not ptr` above already filtered NULL + try: + status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0] + if status == 1: + msg = ctypes.string_at(addr + 1).decode("utf-8") + raise RuntimeError(msg) + if status != 0: + raise RuntimeError(f"unexpected status {status} from chat_read_file") + # `addr + 1` is unaligned for a uint32 read. On the supported platforms + # (linux-x86_64, linux-aarch64, macos-aarch64, windows-x86_64) this is + # silently handled; matches the Node.js binding (cpp/simplex.cc:344). + length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0] + return ctypes.string_at(addr + 5, length) + finally: + _native.libc().free(ctypes.c_void_p(addr)) + + return await asyncio.to_thread(_call) + + +async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs: + def _call() -> str: + ptr = _native.lib().chat_encrypt_file(ctrl, src.encode("utf-8"), dst.encode("utf-8")) + return _read_and_free(ptr) + + return _crypto_args_result(await asyncio.to_thread(_call)) + + +async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None: + def _call() -> str: + ptr = _native.lib().chat_decrypt_file( + src.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + dst.encode("utf-8"), + ) + return _read_and_free(ptr) + + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +def _crypto_args_result(raw: str) -> CryptoArgs: + parsed = json.loads(raw) + if parsed.get("type") == "result": + return parsed["cryptoArgs"] + if parsed.get("type") == "error": + raise RuntimeError(parsed.get("writeError", "unknown write error")) + raise RuntimeError(f"unexpected result: {raw[:200]}") diff --git a/packages/simplex-chat-python/src/simplex_chat/filters.py b/packages/simplex-chat-python/src/simplex_chat/filters.py new file mode 100644 index 0000000000..8af15c1c66 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/filters.py @@ -0,0 +1,54 @@ +"""Compile kwarg-based message filters into a single predicate.""" + +from __future__ import annotations + +import re +from typing import Any, Callable + + +def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: + """Compile filter kwargs into a single predicate function. + + Multiple kwargs combine with AND; tuples within a kwarg combine with OR. + `when` is the last predicate evaluated. + """ + predicates: list[Callable[[Any], bool]] = [] + + if (ct := kw.get("content_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.content.get("type") in ct_set) + + if (t := kw.get("text")) is not None: + if isinstance(t, re.Pattern): + predicates.append(lambda m: bool(t.search(m.content.get("text", "") or ""))) + else: + predicates.append(lambda m: m.content.get("text") == t) + + if (cht := kw.get("chat_type")) is not None: + cht_set = (cht,) if isinstance(cht, str) else tuple(cht) + predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in cht_set) + + if (gid := kw.get("group_id")) is not None: + gid_set: tuple[int, ...] = (gid,) if isinstance(gid, int) else tuple(gid) + + def gid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set + + predicates.append(gid_match) + + if (cid := kw.get("contact_id")) is not None: + cid_set: tuple[int, ...] = (cid,) if isinstance(cid, int) else tuple(cid) + + def cid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "direct" and ci["contact"]["contactId"] in cid_set + + predicates.append(cid_match) + + if (when := kw.get("when")) is not None: + predicates.append(when) + + if not predicates: + return lambda _m: True + return lambda m: all(p(m) for p in predicates) diff --git a/packages/simplex-chat-python/src/simplex_chat/py.typed b/packages/simplex-chat-python/src/simplex_chat/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/simplex-chat-python/src/simplex_chat/types/__init__.py b/packages/simplex-chat-python/src/simplex_chat/types/__init__.py new file mode 100644 index 0000000000..4d21965dd4 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/__init__.py @@ -0,0 +1,16 @@ +"""SimpleX Chat wire types — auto-generated from Haskell. + +Re-exports the four generated modules as namespaces: + +- ``T`` — :mod:`._types` (records, enums, discriminated unions) +- ``CC`` — :mod:`._commands` (command TypedDicts + ``_cmd_string`` helpers) +- ``CR`` — :mod:`._responses` (``ChatResponse`` and member TypedDicts) +- ``CEvt`` — :mod:`._events` (``ChatEvent`` and member TypedDicts) +""" + +from . import _commands as CC +from . import _events as CEvt +from . import _responses as CR +from . import _types as T + +__all__ = ["T", "CC", "CR", "CEvt"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py new file mode 100644 index 0000000000..3847f44811 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -0,0 +1,717 @@ +# API Commands +# This file is generated automatically. +from __future__ import annotations +import json +from typing import NotRequired, TypedDict +from . import _types as T +from . import _responses as CR + +# Address commands +# Bots can use these commands to automatically check and create address when initialized + +# Create bot address. +# Network usage: interactive. +class APICreateMyAddress(TypedDict): + userId: int # int64 + + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError + + +# Delete bot address. +# Network usage: background. +class APIDeleteMyAddress(TypedDict): + userId: int # int64 + + +def APIDeleteMyAddress_cmd_string(self: APIDeleteMyAddress) -> str: + return '/_delete_address ' + str(self['userId']) + +APIDeleteMyAddress_Response = CR.UserContactLinkDeleted | CR.ChatCmdError + + +# Get bot address and settings. +# Network usage: no. +class APIShowMyAddress(TypedDict): + userId: int # int64 + + +def APIShowMyAddress_cmd_string(self: APIShowMyAddress) -> str: + return '/_show_address ' + str(self['userId']) + +APIShowMyAddress_Response = CR.UserContactLink | CR.ChatCmdError + + +# Add address to bot profile. +# Network usage: interactive. +class APISetProfileAddress(TypedDict): + userId: int # int64 + enable: bool + + +def APISetProfileAddress_cmd_string(self: APISetProfileAddress) -> str: + return '/_profile_address ' + str(self['userId']) + ' ' + ('on' if self['enable'] else 'off') + +APISetProfileAddress_Response = CR.UserProfileUpdated | CR.ChatCmdError + + +# Set bot address settings. +# Network usage: interactive. +class APISetAddressSettings(TypedDict): + userId: int # int64 + settings: "T.AddressSettings" + + +def APISetAddressSettings_cmd_string(self: APISetAddressSettings) -> str: + return '/_address_settings ' + str(self['userId']) + ' ' + json.dumps(self['settings']) + +APISetAddressSettings_Response = CR.UserContactLinkUpdated | CR.ChatCmdError + + +# Message commands +# Commands to send, update, delete, moderate messages and set message reactions + +# Send messages. +# Network usage: background. +class APISendMessages(TypedDict): + sendRef: "T.ChatRef" + liveMessage: bool + ttl: NotRequired[int] # int + composedMessages: list["T.ComposedMessage"] # non-empty + + +def APISendMessages_cmd_string(self: APISendMessages) -> str: + return '/_send ' + T.ChatRef_cmd_string(self['sendRef']) + (' live=on' if self['liveMessage'] else '') + ((' ttl=' + str(self.get('ttl'))) if self.get('ttl') is not None else '') + ' json ' + json.dumps(self['composedMessages']) + +APISendMessages_Response = CR.NewChatItems | CR.ChatCmdError + + +# Update message. +# Network usage: background. +class APIUpdateChatItem(TypedDict): + chatRef: "T.ChatRef" + chatItemId: int # int64 + liveMessage: bool + updatedMessage: "T.UpdatedMessage" + + +def APIUpdateChatItem_cmd_string(self: APIUpdateChatItem) -> str: + return '/_update item ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + str(self['chatItemId']) + (' live=on' if self['liveMessage'] else '') + ' json ' + json.dumps(self['updatedMessage']) + +APIUpdateChatItem_Response = CR.ChatItemUpdated | CR.ChatItemNotChanged | CR.ChatCmdError + + +# Delete message. +# Network usage: background. +class APIDeleteChatItem(TypedDict): + chatRef: "T.ChatRef" + chatItemIds: list[int] # int64, non-empty + deleteMode: "T.CIDeleteMode" + + +def APIDeleteChatItem_cmd_string(self: APIDeleteChatItem) -> str: + return '/_delete item ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + ','.join(map(str, self['chatItemIds'])) + ' ' + str(self['deleteMode']) + +APIDeleteChatItem_Response = CR.ChatItemsDeleted | CR.ChatCmdError + + +# Moderate message. Requires Moderator role (and higher than message author's). +# Network usage: background. +class APIDeleteMemberChatItem(TypedDict): + groupId: int # int64 + chatItemIds: list[int] # int64, non-empty + + +def APIDeleteMemberChatItem_cmd_string(self: APIDeleteMemberChatItem) -> str: + return '/_delete member item #' + str(self['groupId']) + ' ' + ','.join(map(str, self['chatItemIds'])) + +APIDeleteMemberChatItem_Response = CR.ChatItemsDeleted | CR.ChatCmdError + + +# Add/remove message reaction. +# Network usage: background. +class APIChatItemReaction(TypedDict): + chatRef: "T.ChatRef" + chatItemId: int # int64 + add: bool + reaction: "T.MsgReaction" + + +def APIChatItemReaction_cmd_string(self: APIChatItemReaction) -> str: + return '/_reaction ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + str(self['chatItemId']) + ' ' + ('on' if self['add'] else 'off') + ' ' + json.dumps(self['reaction']) + +APIChatItemReaction_Response = CR.ChatItemReaction | CR.ChatCmdError + + +# File commands +# Commands to receive and to cancel files. Files are sent as part of the message, there are no separate commands to send files. + +# Receive file. +# Network usage: no. +class ReceiveFile(TypedDict): + fileId: int # int64 + userApprovedRelays: bool + storeEncrypted: NotRequired[bool] + fileInline: NotRequired[bool] + filePath: NotRequired[str] + + +def ReceiveFile_cmd_string(self: ReceiveFile) -> str: + return '/freceive ' + str(self['fileId']) + (' approved_relays=on' if self['userApprovedRelays'] else '') + ((' encrypt=' + ('on' if self.get('storeEncrypted') else 'off')) if self.get('storeEncrypted') is not None else '') + ((' inline=' + ('on' if self.get('fileInline') else 'off')) if self.get('fileInline') is not None else '') + ((' ' + self.get('filePath')) if self.get('filePath') is not None else '') + +ReceiveFile_Response = CR.RcvFileAccepted | CR.RcvFileAcceptedSndCancelled | CR.ChatCmdError + + +# Cancel file. +# Network usage: background. +class CancelFile(TypedDict): + fileId: int # int64 + + +def CancelFile_cmd_string(self: CancelFile) -> str: + return '/fcancel ' + str(self['fileId']) + +CancelFile_Response = CR.SndFileCancelled | CR.RcvFileCancelled | CR.ChatCmdError + + +# Group commands +# Commands to manage and moderate groups. These commands can be used with business chats as well - they are groups. E.g., a common scenario would be to add human agents to business chat with the customer who connected via business address. + +# Add contact to group. Requires bot to have Admin role. +# Network usage: interactive. +class APIAddMember(TypedDict): + groupId: int # int64 + contactId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAddMember_cmd_string(self: APIAddMember) -> str: + return '/_add #' + str(self['groupId']) + ' ' + str(self['contactId']) + ' ' + str(self['memberRole']) + +APIAddMember_Response = CR.SentGroupInvitation | CR.ChatCmdError + + +# Join group. +# Network usage: interactive. +class APIJoinGroup(TypedDict): + groupId: int # int64 + + +def APIJoinGroup_cmd_string(self: APIJoinGroup) -> str: + return '/_join #' + str(self['groupId']) + +APIJoinGroup_Response = CR.UserAcceptedGroupSent | CR.ChatCmdError + + +# Accept group member. Requires Admin role. +# Network usage: background. +class APIAcceptMember(TypedDict): + groupId: int # int64 + groupMemberId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIAcceptMember_cmd_string(self: APIAcceptMember) -> str: + return '/_accept member #' + str(self['groupId']) + ' ' + str(self['groupMemberId']) + ' ' + str(self['memberRole']) + +APIAcceptMember_Response = CR.MemberAccepted | CR.ChatCmdError + + +# Set members role. Requires Admin role. +# Network usage: background. +class APIMembersRole(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + memberRole: "T.GroupMemberRole" + + +def APIMembersRole_cmd_string(self: APIMembersRole) -> str: + return '/_member role #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + ' ' + str(self['memberRole']) + +APIMembersRole_Response = CR.MembersRoleUser | CR.ChatCmdError + + +# Block members. Requires Moderator role. +# Network usage: background. +class APIBlockMembersForAll(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + blocked: bool + + +def APIBlockMembersForAll_cmd_string(self: APIBlockMembersForAll) -> str: + return '/_block #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + ' blocked=' + ('on' if self['blocked'] else 'off') + +APIBlockMembersForAll_Response = CR.MembersBlockedForAllUser | CR.ChatCmdError + + +# Remove members. Requires Admin role. +# Network usage: background. +class APIRemoveMembers(TypedDict): + groupId: int # int64 + groupMemberIds: list[int] # int64, non-empty + withMessages: bool + + +def APIRemoveMembers_cmd_string(self: APIRemoveMembers) -> str: + return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '') + +APIRemoveMembers_Response = CR.UserDeletedMembers | CR.ChatCmdError + + +# Leave group. +# Network usage: background. +class APILeaveGroup(TypedDict): + groupId: int # int64 + + +def APILeaveGroup_cmd_string(self: APILeaveGroup) -> str: + return '/_leave #' + str(self['groupId']) + +APILeaveGroup_Response = CR.LeftMemberUser | CR.ChatCmdError + + +# Get group members. +# Network usage: no. +class APIListMembers(TypedDict): + groupId: int # int64 + + +def APIListMembers_cmd_string(self: APIListMembers) -> str: + return '/_members #' + str(self['groupId']) + +APIListMembers_Response = CR.GroupMembers | CR.ChatCmdError + + +# Create group. +# Network usage: no. +class APINewGroup(TypedDict): + userId: int # int64 + incognito: bool + groupProfile: "T.GroupProfile" + + +def APINewGroup_cmd_string(self: APINewGroup) -> str: + return '/_group ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ' ' + json.dumps(self['groupProfile']) + +APINewGroup_Response = CR.GroupCreated | CR.ChatCmdError + + +# Create public group. +# Network usage: interactive. +class APINewPublicGroup(TypedDict): + userId: int # int64 + incognito: bool + relayIds: list[int] # int64, non-empty + groupProfile: "T.GroupProfile" + + +def APINewPublicGroup_cmd_string(self: APINewPublicGroup) -> str: + return '/_public group ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + ' ' + ','.join(map(str, self['relayIds'])) + ' ' + json.dumps(self['groupProfile']) + +APINewPublicGroup_Response = CR.PublicGroupCreated | CR.PublicGroupCreationFailed | CR.ChatCmdError + + +# Get group relays. +# Network usage: no. +class APIGetGroupRelays(TypedDict): + groupId: int # int64 + + +def APIGetGroupRelays_cmd_string(self: APIGetGroupRelays) -> str: + return '/_get relays #' + str(self['groupId']) + +APIGetGroupRelays_Response = CR.GroupRelays | CR.ChatCmdError + + +# Add relays to group. +# Network usage: interactive. +class APIAddGroupRelays(TypedDict): + groupId: int # int64 + relayIds: list[int] # int64, non-empty + + +def APIAddGroupRelays_cmd_string(self: APIAddGroupRelays) -> str: + return '/_add relays #' + str(self['groupId']) + ' ' + ','.join(map(str, self['relayIds'])) + +APIAddGroupRelays_Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError + + +# Clear relay rejection for a channel (relay operator). +# Network usage: background. +class APIAllowRelayGroup(TypedDict): + groupId: int # int64 + + +def APIAllowRelayGroup_cmd_string(self: APIAllowRelayGroup) -> str: + return '/_relay allow #' + str(self['groupId']) + +APIAllowRelayGroup_Response = CR.RelayGroupAllowed | CR.ChatCmdError + + +# Update group profile. +# Network usage: background. +class APIUpdateGroupProfile(TypedDict): + groupId: int # int64 + groupProfile: "T.GroupProfile" + + +def APIUpdateGroupProfile_cmd_string(self: APIUpdateGroupProfile) -> str: + return '/_group_profile #' + str(self['groupId']) + ' ' + json.dumps(self['groupProfile']) + +APIUpdateGroupProfile_Response = CR.GroupUpdated | CR.ChatCmdError + + +# Group link commands +# These commands can be used by bots that manage multiple public groups + +# Create group link. +# Network usage: interactive. +class APICreateGroupLink(TypedDict): + groupId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APICreateGroupLink_cmd_string(self: APICreateGroupLink) -> str: + return '/_create link #' + str(self['groupId']) + ' ' + str(self['memberRole']) + +APICreateGroupLink_Response = CR.GroupLinkCreated | CR.ChatCmdError + + +# Set member role for group link. +# Network usage: no. +class APIGroupLinkMemberRole(TypedDict): + groupId: int # int64 + memberRole: "T.GroupMemberRole" + + +def APIGroupLinkMemberRole_cmd_string(self: APIGroupLinkMemberRole) -> str: + return '/_set link role #' + str(self['groupId']) + ' ' + str(self['memberRole']) + +APIGroupLinkMemberRole_Response = CR.GroupLink | CR.ChatCmdError + + +# Delete group link. +# Network usage: background. +class APIDeleteGroupLink(TypedDict): + groupId: int # int64 + + +def APIDeleteGroupLink_cmd_string(self: APIDeleteGroupLink) -> str: + return '/_delete link #' + str(self['groupId']) + +APIDeleteGroupLink_Response = CR.GroupLinkDeleted | CR.ChatCmdError + + +# Get group link. +# Network usage: no. +class APIGetGroupLink(TypedDict): + groupId: int # int64 + + +def APIGetGroupLink_cmd_string(self: APIGetGroupLink) -> str: + return '/_get link #' + str(self['groupId']) + +APIGetGroupLink_Response = CR.GroupLink | CR.ChatCmdError + + +# Connection commands +# These commands may be used to create connections. Most bots do not need to use them - bot users will connect via bot address with auto-accept enabled. + +# Create 1-time invitation link. +# Network usage: interactive. +class APIAddContact(TypedDict): + userId: int # int64 + incognito: bool + + +def APIAddContact_cmd_string(self: APIAddContact) -> str: + return '/_connect ' + str(self['userId']) + (' incognito=on' if self['incognito'] else '') + +APIAddContact_Response = CR.Invitation | CR.ChatCmdError + + +# Determine SimpleX link type and if the bot is already connected via this link. +# Network usage: interactive. +class APIConnectPlan(TypedDict): + userId: int # int64 + connectionLink: NotRequired[str] + resolveKnown: bool + linkOwnerSig: NotRequired["T.LinkOwnerSig"] + + +def APIConnectPlan_cmd_string(self: APIConnectPlan) -> str: + return '/_connect plan ' + str(self['userId']) + ' ' + self.get('connectionLink') + +APIConnectPlan_Response = CR.ConnectionPlan | CR.ChatCmdError + + +# Connect via prepared SimpleX link. The link can be 1-time invitation link, contact address or group link. +# Network usage: interactive. +class APIConnect(TypedDict): + userId: int # int64 + incognito: bool + preparedLink_: NotRequired["T.CreatedConnLink"] + + +def APIConnect_cmd_string(self: APIConnect) -> str: + return '/_connect ' + str(self['userId']) + ((' ' + T.CreatedConnLink_cmd_string(self.get('preparedLink_'))) if self.get('preparedLink_') is not None else '') + +APIConnect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + +# Connect via SimpleX link as string in the active user profile. +# Network usage: interactive. +class Connect(TypedDict): + incognito: bool + connLink_: NotRequired[str] + + +def Connect_cmd_string(self: Connect) -> str: + return '/connect' + ((' ' + self.get('connLink_')) if self.get('connLink_') is not None else '') + +Connect_Response = CR.SentConfirmation | CR.ContactAlreadyExists | CR.SentInvitation | CR.ChatCmdError + + +# Accept contact request. +# Network usage: interactive. +class APIAcceptContact(TypedDict): + contactReqId: int # int64 + + +def APIAcceptContact_cmd_string(self: APIAcceptContact) -> str: + return '/_accept ' + str(self['contactReqId']) + +APIAcceptContact_Response = CR.AcceptingContactRequest | CR.ChatCmdError + + +# Reject contact request. The user who sent the request is **not notified**. +# Network usage: no. +class APIRejectContact(TypedDict): + contactReqId: int # int64 + + +def APIRejectContact_cmd_string(self: APIRejectContact) -> str: + return '/_reject ' + str(self['contactReqId']) + +APIRejectContact_Response = CR.ContactRequestRejected | CR.ChatCmdError + + +# Chat commands +# Commands to list and delete conversations. + +# Get contacts. +# Network usage: no. +class APIListContacts(TypedDict): + userId: int # int64 + + +def APIListContacts_cmd_string(self: APIListContacts) -> str: + return '/_contacts ' + str(self['userId']) + +APIListContacts_Response = CR.ContactsList | CR.ChatCmdError + + +# Get groups. +# Network usage: no. +class APIListGroups(TypedDict): + userId: int # int64 + contactId_: NotRequired[int] # int64 + search: NotRequired[str] + + +def APIListGroups_cmd_string(self: APIListGroups) -> str: + return '/_groups ' + str(self['userId']) + ((' @' + str(self.get('contactId_'))) if self.get('contactId_') is not None else '') + ((' ' + self.get('search')) if self.get('search') is not None else '') + +APIListGroups_Response = CR.GroupsList | CR.ChatCmdError + + +# Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases). +# Network usage: no. +class APIGetChats(TypedDict): + userId: int # int64 + pendingConnections: bool + pagination: "T.PaginationByTime" + query: "T.ChatListQuery" + + +def APIGetChats_cmd_string(self: APIGetChats) -> str: + return '/_get chats ' + str(self['userId']) + (' pcc=on' if self['pendingConnections'] else '') + ' ' + T.PaginationByTime_cmd_string(self['pagination']) + ' ' + json.dumps(self['query']) + +APIGetChats_Response = CR.ApiChats | CR.ChatCmdError + + +# Delete chat. +# Network usage: background. +class APIDeleteChat(TypedDict): + chatRef: "T.ChatRef" + chatDeleteMode: "T.ChatDeleteMode" + + +def APIDeleteChat_cmd_string(self: APIDeleteChat) -> str: + return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode']) + +APIDeleteChat_Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError + + +# Set group custom data. +# Network usage: no. +class APISetGroupCustomData(TypedDict): + groupId: int # int64 + customData: NotRequired[dict[str, object]] + + +def APISetGroupCustomData_cmd_string(self: APISetGroupCustomData) -> str: + return '/_set custom #' + str(self['groupId']) + ((' ' + json.dumps(self.get('customData'))) if self.get('customData') is not None else '') + +APISetGroupCustomData_Response = CR.CmdOk | CR.ChatCmdError + + +# Set contact custom data. +# Network usage: no. +class APISetContactCustomData(TypedDict): + contactId: int # int64 + customData: NotRequired[dict[str, object]] + + +def APISetContactCustomData_cmd_string(self: APISetContactCustomData) -> str: + return '/_set custom @' + str(self['contactId']) + ((' ' + json.dumps(self.get('customData'))) if self.get('customData') is not None else '') + +APISetContactCustomData_Response = CR.CmdOk | CR.ChatCmdError + + +# Set auto-accept member contacts. +# Network usage: no. +class APISetUserAutoAcceptMemberContacts(TypedDict): + userId: int # int64 + onOff: bool + + +def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemberContacts) -> str: + return '/_set accept member contacts ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off') + +APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError + + +# 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). + +# Get active user profile. +# Network usage: no. +class ShowActiveUser(TypedDict): + pass + + +def ShowActiveUser_cmd_string(self: ShowActiveUser) -> str: + return '/user' + +ShowActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Create new user profile. +# Network usage: no. +class CreateActiveUser(TypedDict): + newUser: "T.NewUser" + + +def CreateActiveUser_cmd_string(self: CreateActiveUser) -> str: + return '/_create user ' + json.dumps(self['newUser']) + +CreateActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Get all user profiles. +# Network usage: no. +class ListUsers(TypedDict): + pass + + +def ListUsers_cmd_string(self: ListUsers) -> str: + return '/users' + +ListUsers_Response = CR.UsersList | CR.ChatCmdError + + +# Set active user profile. +# Network usage: no. +class APISetActiveUser(TypedDict): + userId: int # int64 + viewPwd: NotRequired[str] + + +def APISetActiveUser_cmd_string(self: APISetActiveUser) -> str: + return '/_user ' + str(self['userId']) + ((' ' + json.dumps(self.get('viewPwd'))) if self.get('viewPwd') is not None else '') + +APISetActiveUser_Response = CR.ActiveUser | CR.ChatCmdError + + +# Delete user profile. +# Network usage: background. +class APIDeleteUser(TypedDict): + userId: int # int64 + delSMPQueues: bool + viewPwd: NotRequired[str] + + +def APIDeleteUser_cmd_string(self: APIDeleteUser) -> str: + return '/_delete user ' + str(self['userId']) + ' del_smp=' + ('on' if self['delSMPQueues'] else 'off') + ((' ' + json.dumps(self.get('viewPwd'))) if self.get('viewPwd') is not None else '') + +APIDeleteUser_Response = CR.CmdOk | CR.ChatCmdError + + +# Update user profile. +# Network usage: background. +class APIUpdateProfile(TypedDict): + userId: int # int64 + profile: "T.Profile" + + +def APIUpdateProfile_cmd_string(self: APIUpdateProfile) -> str: + return '/_profile ' + str(self['userId']) + ' ' + json.dumps(self['profile']) + +APIUpdateProfile_Response = CR.UserProfileUpdated | CR.UserProfileNoChange | CR.ChatCmdError + + +# Configure chat preference overrides for the contact. +# Network usage: background. +class APISetContactPrefs(TypedDict): + contactId: int # int64 + preferences: "T.Preferences" + + +def APISetContactPrefs_cmd_string(self: APISetContactPrefs) -> str: + return '/_set prefs @' + str(self['contactId']) + ' ' + json.dumps(self['preferences']) + +APISetContactPrefs_Response = CR.ContactPrefsUpdated | CR.ChatCmdError + + +# Chat management +# These commands should not be used with CLI-based bots + +# Start chat controller. +# Network usage: no. +class StartChat(TypedDict): + mainApp: bool + enableSndFiles: bool + + +def StartChat_cmd_string(self: StartChat) -> str: + return '/_start' + +StartChat_Response = CR.ChatStarted | CR.ChatRunning + + +# Stop chat controller. +# Network usage: no. +class APIStopChat(TypedDict): + pass + + +def APIStopChat_cmd_string(self: APIStopChat) -> str: + return '/_stop' + +APIStopChat_Response = CR.ChatStopped + diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_events.py b/packages/simplex-chat-python/src/simplex_chat/types/_events.py new file mode 100644 index 0000000000..7b7c724c92 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_events.py @@ -0,0 +1,695 @@ +# API Events +# This file is generated automatically. +from __future__ import annotations +from collections.abc import Awaitable, Callable +from typing import Literal, NotRequired, Protocol, TypedDict, overload +from . import _types as T + +class ContactConnected(TypedDict): + type: Literal["contactConnected"] + user: "T.User" + contact: "T.Contact" + userCustomProfile: NotRequired["T.Profile"] + +class ContactUpdated(TypedDict): + type: Literal["contactUpdated"] + user: "T.User" + fromContact: "T.Contact" + toContact: "T.Contact" + +class ContactDeletedByContact(TypedDict): + type: Literal["contactDeletedByContact"] + user: "T.User" + contact: "T.Contact" + +class ReceivedContactRequest(TypedDict): + type: Literal["receivedContactRequest"] + user: "T.User" + contactRequest: "T.UserContactRequest" + chat_: NotRequired["T.AChat"] + +class NewMemberContactReceivedInv(TypedDict): + type: Literal["newMemberContactReceivedInv"] + user: "T.User" + contact: "T.Contact" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class ContactSndReady(TypedDict): + type: Literal["contactSndReady"] + user: "T.User" + contact: "T.Contact" + +class NewChatItems(TypedDict): + type: Literal["newChatItems"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class ChatItemReaction(TypedDict): + type: Literal["chatItemReaction"] + user: "T.User" + added: bool + reaction: "T.ACIReaction" + +class ChatItemsDeleted(TypedDict): + type: Literal["chatItemsDeleted"] + user: "T.User" + chatItemDeletions: list["T.ChatItemDeletion"] + byUser: bool + timed: bool + +class ChatItemUpdated(TypedDict): + type: Literal["chatItemUpdated"] + user: "T.User" + chatItem: "T.AChatItem" + +class GroupChatItemsDeleted(TypedDict): + type: Literal["groupChatItemsDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + chatItemIDs: list[int] # int64 + byUser: bool + member_: NotRequired["T.GroupMember"] + +class ChatItemsStatusesUpdated(TypedDict): + type: Literal["chatItemsStatusesUpdated"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class ReceivedGroupInvitation(TypedDict): + type: Literal["receivedGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + fromMemberRole: "T.GroupMemberRole" + memberRole: "T.GroupMemberRole" + +class UserJoinedGroup(TypedDict): + type: Literal["userJoinedGroup"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + +class GroupUpdated(TypedDict): + type: Literal["groupUpdated"] + user: "T.User" + fromGroup: "T.GroupInfo" + toGroup: "T.GroupInfo" + member_: NotRequired["T.GroupMember"] + msgSigned: NotRequired["T.MsgSigStatus"] + +class JoinedGroupMember(TypedDict): + type: Literal["joinedGroupMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class MemberRole(TypedDict): + type: Literal["memberRole"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + member: "T.GroupMember" + fromRole: "T.GroupMemberRole" + toRole: "T.GroupMemberRole" + msgSigned: NotRequired["T.MsgSigStatus"] + +class DeletedMember(TypedDict): + type: Literal["deletedMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + deletedMember: "T.GroupMember" + withMessages: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class LeftMember(TypedDict): + type: Literal["leftMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + msgSigned: NotRequired["T.MsgSigStatus"] + +class DeletedMemberUser(TypedDict): + type: Literal["deletedMemberUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + withMessages: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class GroupDeleted(TypedDict): + type: Literal["groupDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + msgSigned: NotRequired["T.MsgSigStatus"] + +class ConnectedToGroupMember(TypedDict): + type: Literal["connectedToGroupMember"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + memberContact: NotRequired["T.Contact"] + +class MemberAcceptedByOther(TypedDict): + type: Literal["memberAcceptedByOther"] + user: "T.User" + groupInfo: "T.GroupInfo" + acceptingMember: "T.GroupMember" + member: "T.GroupMember" + +class MemberBlockedForAll(TypedDict): + type: Literal["memberBlockedForAll"] + user: "T.User" + groupInfo: "T.GroupInfo" + byMember: "T.GroupMember" + member: "T.GroupMember" + blocked: bool + msgSigned: NotRequired["T.MsgSigStatus"] + +class GroupMemberUpdated(TypedDict): + type: Literal["groupMemberUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + fromMember: "T.GroupMember" + toMember: "T.GroupMember" + +class GroupLinkDataUpdated(TypedDict): + type: Literal["groupLinkDataUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + relaysChanged: bool + +class GroupRelayUpdated(TypedDict): + type: Literal["groupRelayUpdated"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + groupRelay: "T.GroupRelay" + +class RcvFileDescrReady(TypedDict): + type: Literal["rcvFileDescrReady"] + user: "T.User" + chatItem: "T.AChatItem" + rcvFileTransfer: "T.RcvFileTransfer" + rcvFileDescr: "T.RcvFileDescr" + +class RcvFileComplete(TypedDict): + type: Literal["rcvFileComplete"] + user: "T.User" + chatItem: "T.AChatItem" + +class SndFileCompleteXFTP(TypedDict): + type: Literal["sndFileCompleteXFTP"] + user: "T.User" + chatItem: "T.AChatItem" + fileTransferMeta: "T.FileTransferMeta" + +class RcvFileStart(TypedDict): + type: Literal["rcvFileStart"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileSndCancelled(TypedDict): + type: Literal["rcvFileSndCancelled"] + user: "T.User" + chatItem: "T.AChatItem" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileAccepted(TypedDict): + type: Literal["rcvFileAccepted"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileError(TypedDict): + type: Literal["rcvFileError"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + agentError: "T.AgentErrorType" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileWarning(TypedDict): + type: Literal["rcvFileWarning"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + agentError: "T.AgentErrorType" + rcvFileTransfer: "T.RcvFileTransfer" + +class SndFileError(TypedDict): + type: Literal["sndFileError"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + errorMessage: str + +class SndFileWarning(TypedDict): + type: Literal["sndFileWarning"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + errorMessage: str + +class AcceptingContactRequest(TypedDict): + type: Literal["acceptingContactRequest"] + user: "T.User" + contact: "T.Contact" + +class AcceptingBusinessRequest(TypedDict): + type: Literal["acceptingBusinessRequest"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class ContactConnecting(TypedDict): + type: Literal["contactConnecting"] + user: "T.User" + contact: "T.Contact" + +class BusinessLinkConnecting(TypedDict): + type: Literal["businessLinkConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + fromContact: "T.Contact" + +class JoinedGroupMemberConnecting(TypedDict): + type: Literal["joinedGroupMemberConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + member: "T.GroupMember" + +class SentGroupInvitation(TypedDict): + type: Literal["sentGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + member: "T.GroupMember" + +class GroupLinkConnecting(TypedDict): + type: Literal["groupLinkConnecting"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostMember: "T.GroupMember" + +class HostConnected(TypedDict): + type: Literal["hostConnected"] + protocol: str + transportHost: str + +class HostDisconnected(TypedDict): + type: Literal["hostDisconnected"] + protocol: str + transportHost: str + +class SubscriptionStatus(TypedDict): + type: Literal["subscriptionStatus"] + server: str + subscriptionStatus: "T.SubscriptionStatus" + connections: list[str] + +class MessageError(TypedDict): + type: Literal["messageError"] + user: "T.User" + severity: str + errorMessage: str + +class ChatError(TypedDict): + type: Literal["chatError"] + chatError: "T.ChatError" + +class ChatErrors(TypedDict): + type: Literal["chatErrors"] + chatErrors: list["T.ChatError"] + +ChatEvent = ( + ContactConnected + | ContactUpdated + | ContactDeletedByContact + | ReceivedContactRequest + | NewMemberContactReceivedInv + | ContactSndReady + | NewChatItems + | ChatItemReaction + | ChatItemsDeleted + | ChatItemUpdated + | GroupChatItemsDeleted + | ChatItemsStatusesUpdated + | ReceivedGroupInvitation + | UserJoinedGroup + | GroupUpdated + | JoinedGroupMember + | MemberRole + | DeletedMember + | LeftMember + | DeletedMemberUser + | GroupDeleted + | ConnectedToGroupMember + | MemberAcceptedByOther + | MemberBlockedForAll + | GroupMemberUpdated + | GroupLinkDataUpdated + | GroupRelayUpdated + | RcvFileDescrReady + | RcvFileComplete + | SndFileCompleteXFTP + | RcvFileStart + | RcvFileSndCancelled + | RcvFileAccepted + | RcvFileError + | RcvFileWarning + | SndFileError + | SndFileWarning + | AcceptingContactRequest + | AcceptingBusinessRequest + | ContactConnecting + | BusinessLinkConnecting + | JoinedGroupMemberConnecting + | SentGroupInvitation + | GroupLinkConnecting + | HostConnected + | HostDisconnected + | SubscriptionStatus + | MessageError + | ChatError + | ChatErrors +) + +ChatEvent_Tag = Literal["contactConnected", "contactUpdated", "contactDeletedByContact", "receivedContactRequest", "newMemberContactReceivedInv", "contactSndReady", "newChatItems", "chatItemReaction", "chatItemsDeleted", "chatItemUpdated", "groupChatItemsDeleted", "chatItemsStatusesUpdated", "receivedGroupInvitation", "userJoinedGroup", "groupUpdated", "joinedGroupMember", "memberRole", "deletedMember", "leftMember", "deletedMemberUser", "groupDeleted", "connectedToGroupMember", "memberAcceptedByOther", "memberBlockedForAll", "groupMemberUpdated", "groupLinkDataUpdated", "groupRelayUpdated", "rcvFileDescrReady", "rcvFileComplete", "sndFileCompleteXFTP", "rcvFileStart", "rcvFileSndCancelled", "rcvFileAccepted", "rcvFileError", "rcvFileWarning", "sndFileError", "sndFileWarning", "acceptingContactRequest", "acceptingBusinessRequest", "contactConnecting", "businessLinkConnecting", "joinedGroupMemberConnecting", "sentGroupInvitation", "groupLinkConnecting", "hostConnected", "hostDisconnected", "subscriptionStatus", "messageError", "chatError", "chatErrors"] + + +class OnEventDecorator(Protocol): + """Per-tag narrowing protocol for ``Client.on_event``. + + ``@client.on_event("contactConnected")`` types the handler's + ``evt`` parameter as :class:`ContactConnected` rather than the + unnarrowed :data:`ChatEvent` union. + """ + + @overload + def __call__(self, event: Literal["contactConnected"], /) -> Callable[ + [Callable[["ContactConnected"], Awaitable[None]]], + Callable[["ContactConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactUpdated"], /) -> Callable[ + [Callable[["ContactUpdated"], Awaitable[None]]], + Callable[["ContactUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactDeletedByContact"], /) -> Callable[ + [Callable[["ContactDeletedByContact"], Awaitable[None]]], + Callable[["ContactDeletedByContact"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedContactRequest"], /) -> Callable[ + [Callable[["ReceivedContactRequest"], Awaitable[None]]], + Callable[["ReceivedContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newMemberContactReceivedInv"], /) -> Callable[ + [Callable[["NewMemberContactReceivedInv"], Awaitable[None]]], + Callable[["NewMemberContactReceivedInv"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactSndReady"], /) -> Callable[ + [Callable[["ContactSndReady"], Awaitable[None]]], + Callable[["ContactSndReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["newChatItems"], /) -> Callable[ + [Callable[["NewChatItems"], Awaitable[None]]], + Callable[["NewChatItems"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemReaction"], /) -> Callable[ + [Callable[["ChatItemReaction"], Awaitable[None]]], + Callable[["ChatItemReaction"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsDeleted"], /) -> Callable[ + [Callable[["ChatItemsDeleted"], Awaitable[None]]], + Callable[["ChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemUpdated"], /) -> Callable[ + [Callable[["ChatItemUpdated"], Awaitable[None]]], + Callable[["ChatItemUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupChatItemsDeleted"], /) -> Callable[ + [Callable[["GroupChatItemsDeleted"], Awaitable[None]]], + Callable[["GroupChatItemsDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatItemsStatusesUpdated"], /) -> Callable[ + [Callable[["ChatItemsStatusesUpdated"], Awaitable[None]]], + Callable[["ChatItemsStatusesUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["receivedGroupInvitation"], /) -> Callable[ + [Callable[["ReceivedGroupInvitation"], Awaitable[None]]], + Callable[["ReceivedGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["userJoinedGroup"], /) -> Callable[ + [Callable[["UserJoinedGroup"], Awaitable[None]]], + Callable[["UserJoinedGroup"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupUpdated"], /) -> Callable[ + [Callable[["GroupUpdated"], Awaitable[None]]], + Callable[["GroupUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMember"], /) -> Callable[ + [Callable[["JoinedGroupMember"], Awaitable[None]]], + Callable[["JoinedGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberRole"], /) -> Callable[ + [Callable[["MemberRole"], Awaitable[None]]], + Callable[["MemberRole"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMember"], /) -> Callable[ + [Callable[["DeletedMember"], Awaitable[None]]], + Callable[["DeletedMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["leftMember"], /) -> Callable[ + [Callable[["LeftMember"], Awaitable[None]]], + Callable[["LeftMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["deletedMemberUser"], /) -> Callable[ + [Callable[["DeletedMemberUser"], Awaitable[None]]], + Callable[["DeletedMemberUser"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupDeleted"], /) -> Callable[ + [Callable[["GroupDeleted"], Awaitable[None]]], + Callable[["GroupDeleted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["connectedToGroupMember"], /) -> Callable[ + [Callable[["ConnectedToGroupMember"], Awaitable[None]]], + Callable[["ConnectedToGroupMember"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberAcceptedByOther"], /) -> Callable[ + [Callable[["MemberAcceptedByOther"], Awaitable[None]]], + Callable[["MemberAcceptedByOther"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["memberBlockedForAll"], /) -> Callable[ + [Callable[["MemberBlockedForAll"], Awaitable[None]]], + Callable[["MemberBlockedForAll"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupMemberUpdated"], /) -> Callable[ + [Callable[["GroupMemberUpdated"], Awaitable[None]]], + Callable[["GroupMemberUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkDataUpdated"], /) -> Callable[ + [Callable[["GroupLinkDataUpdated"], Awaitable[None]]], + Callable[["GroupLinkDataUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupRelayUpdated"], /) -> Callable[ + [Callable[["GroupRelayUpdated"], Awaitable[None]]], + Callable[["GroupRelayUpdated"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileDescrReady"], /) -> Callable[ + [Callable[["RcvFileDescrReady"], Awaitable[None]]], + Callable[["RcvFileDescrReady"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileComplete"], /) -> Callable[ + [Callable[["RcvFileComplete"], Awaitable[None]]], + Callable[["RcvFileComplete"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileCompleteXFTP"], /) -> Callable[ + [Callable[["SndFileCompleteXFTP"], Awaitable[None]]], + Callable[["SndFileCompleteXFTP"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileStart"], /) -> Callable[ + [Callable[["RcvFileStart"], Awaitable[None]]], + Callable[["RcvFileStart"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileSndCancelled"], /) -> Callable[ + [Callable[["RcvFileSndCancelled"], Awaitable[None]]], + Callable[["RcvFileSndCancelled"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileAccepted"], /) -> Callable[ + [Callable[["RcvFileAccepted"], Awaitable[None]]], + Callable[["RcvFileAccepted"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileError"], /) -> Callable[ + [Callable[["RcvFileError"], Awaitable[None]]], + Callable[["RcvFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["rcvFileWarning"], /) -> Callable[ + [Callable[["RcvFileWarning"], Awaitable[None]]], + Callable[["RcvFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileError"], /) -> Callable[ + [Callable[["SndFileError"], Awaitable[None]]], + Callable[["SndFileError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sndFileWarning"], /) -> Callable[ + [Callable[["SndFileWarning"], Awaitable[None]]], + Callable[["SndFileWarning"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingContactRequest"], /) -> Callable[ + [Callable[["AcceptingContactRequest"], Awaitable[None]]], + Callable[["AcceptingContactRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["acceptingBusinessRequest"], /) -> Callable[ + [Callable[["AcceptingBusinessRequest"], Awaitable[None]]], + Callable[["AcceptingBusinessRequest"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["contactConnecting"], /) -> Callable[ + [Callable[["ContactConnecting"], Awaitable[None]]], + Callable[["ContactConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["businessLinkConnecting"], /) -> Callable[ + [Callable[["BusinessLinkConnecting"], Awaitable[None]]], + Callable[["BusinessLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["joinedGroupMemberConnecting"], /) -> Callable[ + [Callable[["JoinedGroupMemberConnecting"], Awaitable[None]]], + Callable[["JoinedGroupMemberConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["sentGroupInvitation"], /) -> Callable[ + [Callable[["SentGroupInvitation"], Awaitable[None]]], + Callable[["SentGroupInvitation"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["groupLinkConnecting"], /) -> Callable[ + [Callable[["GroupLinkConnecting"], Awaitable[None]]], + Callable[["GroupLinkConnecting"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostConnected"], /) -> Callable[ + [Callable[["HostConnected"], Awaitable[None]]], + Callable[["HostConnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["hostDisconnected"], /) -> Callable[ + [Callable[["HostDisconnected"], Awaitable[None]]], + Callable[["HostDisconnected"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["subscriptionStatus"], /) -> Callable[ + [Callable[["SubscriptionStatus"], Awaitable[None]]], + Callable[["SubscriptionStatus"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["messageError"], /) -> Callable[ + [Callable[["MessageError"], Awaitable[None]]], + Callable[["MessageError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatError"], /) -> Callable[ + [Callable[["ChatError"], Awaitable[None]]], + Callable[["ChatError"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: Literal["chatErrors"], /) -> Callable[ + [Callable[["ChatErrors"], Awaitable[None]]], + Callable[["ChatErrors"], Awaitable[None]], + ]: ... + + @overload + def __call__(self, event: str, /) -> Callable[ + [Callable[["ChatEvent"], Awaitable[None]]], + Callable[["ChatEvent"], Awaitable[None]], + ]: ... diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_responses.py b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py new file mode 100644 index 0000000000..e85de02c78 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_responses.py @@ -0,0 +1,366 @@ +# API Responses +# This file is generated automatically. +from __future__ import annotations +from typing import Literal, NotRequired, TypedDict +from . import _types as T + +class AcceptingContactRequest(TypedDict): + type: Literal["acceptingContactRequest"] + user: "T.User" + contact: "T.Contact" + +class ActiveUser(TypedDict): + type: Literal["activeUser"] + user: "T.User" + +class ChatItemNotChanged(TypedDict): + type: Literal["chatItemNotChanged"] + user: "T.User" + chatItem: "T.AChatItem" + +class ChatItemReaction(TypedDict): + type: Literal["chatItemReaction"] + user: "T.User" + added: bool + reaction: "T.ACIReaction" + +class ChatItemUpdated(TypedDict): + type: Literal["chatItemUpdated"] + user: "T.User" + chatItem: "T.AChatItem" + +class ChatItemsDeleted(TypedDict): + type: Literal["chatItemsDeleted"] + user: "T.User" + chatItemDeletions: list["T.ChatItemDeletion"] + byUser: bool + timed: bool + +class ChatRunning(TypedDict): + type: Literal["chatRunning"] + +class ChatStarted(TypedDict): + type: Literal["chatStarted"] + +class ChatStopped(TypedDict): + type: Literal["chatStopped"] + +class CmdOk(TypedDict): + type: Literal["cmdOk"] + user_: NotRequired["T.User"] + +class ChatCmdError(TypedDict): + type: Literal["chatCmdError"] + chatError: "T.ChatError" + +class ConnectionPlan(TypedDict): + type: Literal["connectionPlan"] + user: "T.User" + connLink: "T.CreatedConnLink" + connectionPlan: "T.ConnectionPlan" + +class ContactAlreadyExists(TypedDict): + type: Literal["contactAlreadyExists"] + user: "T.User" + contact: "T.Contact" + +class ContactConnectionDeleted(TypedDict): + type: Literal["contactConnectionDeleted"] + user: "T.User" + connection: "T.PendingContactConnection" + +class ContactDeleted(TypedDict): + type: Literal["contactDeleted"] + user: "T.User" + contact: "T.Contact" + +class ContactPrefsUpdated(TypedDict): + type: Literal["contactPrefsUpdated"] + user: "T.User" + fromContact: "T.Contact" + toContact: "T.Contact" + +class ContactRequestRejected(TypedDict): + type: Literal["contactRequestRejected"] + user: "T.User" + contactRequest: "T.UserContactRequest" + contact_: NotRequired["T.Contact"] + +class ContactsList(TypedDict): + type: Literal["contactsList"] + user: "T.User" + contacts: list["T.Contact"] + +class GroupDeletedUser(TypedDict): + type: Literal["groupDeletedUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + msgSigned: bool + +class GroupLink(TypedDict): + type: Literal["groupLink"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + +class GroupLinkCreated(TypedDict): + type: Literal["groupLinkCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + +class GroupLinkDeleted(TypedDict): + type: Literal["groupLinkDeleted"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class GroupCreated(TypedDict): + type: Literal["groupCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class PublicGroupCreated(TypedDict): + type: Literal["publicGroupCreated"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + +class PublicGroupCreationFailed(TypedDict): + type: Literal["publicGroupCreationFailed"] + user: "T.User" + addRelayResults: list["T.AddRelayResult"] + +class GroupRelays(TypedDict): + type: Literal["groupRelays"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupRelays: list["T.GroupRelay"] + +class GroupRelaysAdded(TypedDict): + type: Literal["groupRelaysAdded"] + user: "T.User" + groupInfo: "T.GroupInfo" + groupLink: "T.GroupLink" + groupRelays: list["T.GroupRelay"] + +class GroupRelaysAddFailed(TypedDict): + type: Literal["groupRelaysAddFailed"] + user: "T.User" + addRelayResults: list["T.AddRelayResult"] + +class RelayGroupAllowed(TypedDict): + type: Literal["relayGroupAllowed"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class GroupMembers(TypedDict): + type: Literal["groupMembers"] + user: "T.User" + group: "T.Group" + +class GroupUpdated(TypedDict): + type: Literal["groupUpdated"] + user: "T.User" + fromGroup: "T.GroupInfo" + toGroup: "T.GroupInfo" + member_: NotRequired["T.GroupMember"] + msgSigned: bool + +class GroupsList(TypedDict): + type: Literal["groupsList"] + user: "T.User" + groups: list["T.GroupInfo"] + +class Invitation(TypedDict): + type: Literal["invitation"] + user: "T.User" + connLinkInvitation: "T.CreatedConnLink" + connection: "T.PendingContactConnection" + +class LeftMemberUser(TypedDict): + type: Literal["leftMemberUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + +class MemberAccepted(TypedDict): + type: Literal["memberAccepted"] + user: "T.User" + groupInfo: "T.GroupInfo" + member: "T.GroupMember" + +class MembersBlockedForAllUser(TypedDict): + type: Literal["membersBlockedForAllUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + blocked: bool + msgSigned: bool + +class MembersRoleUser(TypedDict): + type: Literal["membersRoleUser"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + toRole: "T.GroupMemberRole" + msgSigned: bool + +class NewChatItems(TypedDict): + type: Literal["newChatItems"] + user: "T.User" + chatItems: list["T.AChatItem"] + +class RcvFileAccepted(TypedDict): + type: Literal["rcvFileAccepted"] + user: "T.User" + chatItem: "T.AChatItem" + +class RcvFileAcceptedSndCancelled(TypedDict): + type: Literal["rcvFileAcceptedSndCancelled"] + user: "T.User" + rcvFileTransfer: "T.RcvFileTransfer" + +class RcvFileCancelled(TypedDict): + type: Literal["rcvFileCancelled"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + rcvFileTransfer: "T.RcvFileTransfer" + +class SentConfirmation(TypedDict): + type: Literal["sentConfirmation"] + user: "T.User" + connection: "T.PendingContactConnection" + customUserProfile: NotRequired["T.Profile"] + +class SentGroupInvitation(TypedDict): + type: Literal["sentGroupInvitation"] + user: "T.User" + groupInfo: "T.GroupInfo" + contact: "T.Contact" + member: "T.GroupMember" + +class SentInvitation(TypedDict): + type: Literal["sentInvitation"] + user: "T.User" + connection: "T.PendingContactConnection" + customUserProfile: NotRequired["T.Profile"] + +class SndFileCancelled(TypedDict): + type: Literal["sndFileCancelled"] + user: "T.User" + chatItem_: NotRequired["T.AChatItem"] + fileTransferMeta: "T.FileTransferMeta" + sndFileTransfers: list["T.SndFileTransfer"] + +class UserAcceptedGroupSent(TypedDict): + type: Literal["userAcceptedGroupSent"] + user: "T.User" + groupInfo: "T.GroupInfo" + hostContact: NotRequired["T.Contact"] + +class UserContactLink(TypedDict): + type: Literal["userContactLink"] + user: "T.User" + contactLink: "T.UserContactLink" + +class UserContactLinkCreated(TypedDict): + type: Literal["userContactLinkCreated"] + user: "T.User" + connLinkContact: "T.CreatedConnLink" + +class UserContactLinkDeleted(TypedDict): + type: Literal["userContactLinkDeleted"] + user: "T.User" + +class UserContactLinkUpdated(TypedDict): + type: Literal["userContactLinkUpdated"] + user: "T.User" + contactLink: "T.UserContactLink" + +class UserDeletedMembers(TypedDict): + type: Literal["userDeletedMembers"] + user: "T.User" + groupInfo: "T.GroupInfo" + members: list["T.GroupMember"] + withMessages: bool + msgSigned: bool + +class UserProfileUpdated(TypedDict): + type: Literal["userProfileUpdated"] + user: "T.User" + fromProfile: "T.Profile" + toProfile: "T.Profile" + updateSummary: "T.UserProfileUpdateSummary" + +class UserProfileNoChange(TypedDict): + type: Literal["userProfileNoChange"] + user: "T.User" + +class UsersList(TypedDict): + type: Literal["usersList"] + users: list["T.UserInfo"] + +class ApiChats(TypedDict): + type: Literal["apiChats"] + user: "T.User" + chats: list["T.AChat"] + +ChatResponse = ( + AcceptingContactRequest + | ActiveUser + | ChatItemNotChanged + | ChatItemReaction + | ChatItemUpdated + | ChatItemsDeleted + | ChatRunning + | ChatStarted + | ChatStopped + | CmdOk + | ChatCmdError + | ConnectionPlan + | ContactAlreadyExists + | ContactConnectionDeleted + | ContactDeleted + | ContactPrefsUpdated + | ContactRequestRejected + | ContactsList + | GroupDeletedUser + | GroupLink + | GroupLinkCreated + | GroupLinkDeleted + | GroupCreated + | PublicGroupCreated + | PublicGroupCreationFailed + | GroupRelays + | GroupRelaysAdded + | GroupRelaysAddFailed + | RelayGroupAllowed + | GroupMembers + | GroupUpdated + | GroupsList + | Invitation + | LeftMemberUser + | MemberAccepted + | MembersBlockedForAllUser + | MembersRoleUser + | NewChatItems + | RcvFileAccepted + | RcvFileAcceptedSndCancelled + | RcvFileCancelled + | SentConfirmation + | SentGroupInvitation + | SentInvitation + | SndFileCancelled + | UserAcceptedGroupSent + | UserContactLink + | UserContactLinkCreated + | UserContactLinkDeleted + | UserContactLinkUpdated + | UserDeletedMembers + | UserProfileUpdated + | UserProfileNoChange + | UsersList + | ApiChats +) + +ChatResponse_Tag = Literal["acceptingContactRequest", "activeUser", "chatItemNotChanged", "chatItemReaction", "chatItemUpdated", "chatItemsDeleted", "chatRunning", "chatStarted", "chatStopped", "cmdOk", "chatCmdError", "connectionPlan", "contactAlreadyExists", "contactConnectionDeleted", "contactDeleted", "contactPrefsUpdated", "contactRequestRejected", "contactsList", "groupDeletedUser", "groupLink", "groupLinkCreated", "groupLinkDeleted", "groupCreated", "publicGroupCreated", "publicGroupCreationFailed", "groupRelays", "groupRelaysAdded", "groupRelaysAddFailed", "relayGroupAllowed", "groupMembers", "groupUpdated", "groupsList", "invitation", "leftMemberUser", "memberAccepted", "membersBlockedForAllUser", "membersRoleUser", "newChatItems", "rcvFileAccepted", "rcvFileAcceptedSndCancelled", "rcvFileCancelled", "sentConfirmation", "sentGroupInvitation", "sentInvitation", "sndFileCancelled", "userAcceptedGroupSent", "userContactLink", "userContactLinkCreated", "userContactLinkDeleted", "userContactLinkUpdated", "userDeletedMembers", "userProfileUpdated", "userProfileNoChange", "usersList", "apiChats"] diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py new file mode 100644 index 0000000000..b2fc00a44c --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -0,0 +1,3506 @@ +# API Types +# This file is generated automatically. +from __future__ import annotations +from typing import Literal, NotRequired, TypedDict + +class ACIReaction(TypedDict): + chatInfo: "ChatInfo" + chatReaction: "CIReaction" + +class AChat(TypedDict): + chatInfo: "ChatInfo" + chatItems: list["ChatItem"] + chatStats: "ChatStats" + +class AChatItem(TypedDict): + chatInfo: "ChatInfo" + chatItem: "ChatItem" + +class AddRelayResult(TypedDict): + relay: "UserChatRelay" + relayError: NotRequired["ChatError"] + +class AddressSettings(TypedDict): + businessAddress: bool + autoAccept: NotRequired["AutoAccept"] + autoReply: NotRequired["MsgContent"] + +class AgentCryptoError_DECRYPT_AES(TypedDict): + type: Literal["DECRYPT_AES"] + +class AgentCryptoError_DECRYPT_CB(TypedDict): + type: Literal["DECRYPT_CB"] + +class AgentCryptoError_RATCHET_HEADER(TypedDict): + type: Literal["RATCHET_HEADER"] + +class AgentCryptoError_RATCHET_SYNC(TypedDict): + type: Literal["RATCHET_SYNC"] + +AgentCryptoError = ( + AgentCryptoError_DECRYPT_AES + | AgentCryptoError_DECRYPT_CB + | AgentCryptoError_RATCHET_HEADER + | AgentCryptoError_RATCHET_SYNC +) + +AgentCryptoError_Tag = Literal["DECRYPT_AES", "DECRYPT_CB", "RATCHET_HEADER", "RATCHET_SYNC"] + +class AgentErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandErrorType" + errContext: str + +class AgentErrorType_CONN(TypedDict): + type: Literal["CONN"] + connErr: "ConnectionErrorType" + errContext: str + +class AgentErrorType_NO_USER(TypedDict): + type: Literal["NO_USER"] + +class AgentErrorType_SMP(TypedDict): + type: Literal["SMP"] + serverAddress: str + smpErr: "ErrorType" + +class AgentErrorType_NTF(TypedDict): + type: Literal["NTF"] + serverAddress: str + ntfErr: "ErrorType" + +class AgentErrorType_XFTP(TypedDict): + type: Literal["XFTP"] + serverAddress: str + xftpErr: "XFTPErrorType" + +class AgentErrorType_FILE(TypedDict): + type: Literal["FILE"] + fileErr: "FileErrorType" + +class AgentErrorType_PROXY(TypedDict): + type: Literal["PROXY"] + proxyServer: str + relayServer: str + proxyErr: "ProxyClientError" + +class AgentErrorType_RCP(TypedDict): + type: Literal["RCP"] + rcpErr: "RCErrorType" + +class AgentErrorType_BROKER(TypedDict): + type: Literal["BROKER"] + brokerAddress: str + brokerErr: "BrokerErrorType" + +class AgentErrorType_AGENT(TypedDict): + type: Literal["AGENT"] + agentErr: "SMPAgentError" + +class AgentErrorType_NOTICE(TypedDict): + type: Literal["NOTICE"] + server: str + preset: bool + expiresAt: NotRequired[str] # ISO-8601 timestamp + +class AgentErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + internalErr: str + +class AgentErrorType_CRITICAL(TypedDict): + type: Literal["CRITICAL"] + offerRestart: bool + criticalErr: str + +class AgentErrorType_INACTIVE(TypedDict): + type: Literal["INACTIVE"] + +AgentErrorType = ( + AgentErrorType_CMD + | AgentErrorType_CONN + | AgentErrorType_NO_USER + | AgentErrorType_SMP + | AgentErrorType_NTF + | AgentErrorType_XFTP + | AgentErrorType_FILE + | AgentErrorType_PROXY + | AgentErrorType_RCP + | AgentErrorType_BROKER + | AgentErrorType_AGENT + | AgentErrorType_NOTICE + | AgentErrorType_INTERNAL + | AgentErrorType_CRITICAL + | AgentErrorType_INACTIVE +) + +AgentErrorType_Tag = Literal["CMD", "CONN", "NO_USER", "SMP", "NTF", "XFTP", "FILE", "PROXY", "RCP", "BROKER", "AGENT", "NOTICE", "INTERNAL", "CRITICAL", "INACTIVE"] + +class AutoAccept(TypedDict): + acceptIncognito: bool + +class BlockingInfo(TypedDict): + reason: "BlockingReason" + notice: NotRequired["ClientNotice"] + +BlockingReason = Literal["spam", "content"] + +class BrokerErrorType_RESPONSE(TypedDict): + type: Literal["RESPONSE"] + respErr: str + +class BrokerErrorType_UNEXPECTED(TypedDict): + type: Literal["UNEXPECTED"] + respErr: str + +class BrokerErrorType_NETWORK(TypedDict): + type: Literal["NETWORK"] + networkError: "NetworkError" + +class BrokerErrorType_HOST(TypedDict): + type: Literal["HOST"] + +class BrokerErrorType_NO_SERVICE(TypedDict): + type: Literal["NO_SERVICE"] + +class BrokerErrorType_TRANSPORT(TypedDict): + type: Literal["TRANSPORT"] + transportErr: "TransportError" + +class BrokerErrorType_TIMEOUT(TypedDict): + type: Literal["TIMEOUT"] + +BrokerErrorType = ( + BrokerErrorType_RESPONSE + | BrokerErrorType_UNEXPECTED + | BrokerErrorType_NETWORK + | BrokerErrorType_HOST + | BrokerErrorType_NO_SERVICE + | BrokerErrorType_TRANSPORT + | BrokerErrorType_TIMEOUT +) + +BrokerErrorType_Tag = Literal["RESPONSE", "UNEXPECTED", "NETWORK", "HOST", "NO_SERVICE", "TRANSPORT", "TIMEOUT"] + +class BusinessChatInfo(TypedDict): + chatType: "BusinessChatType" + businessId: str + customerId: str + +BusinessChatType = Literal["business", "customer"] + +CICallStatus = Literal["pending", "missed", "rejected", "accepted", "negotiated", "progress", "ended", "error"] + +class CIContent_sndMsgContent(TypedDict): + type: Literal["sndMsgContent"] + msgContent: "MsgContent" + +class CIContent_rcvMsgContent(TypedDict): + type: Literal["rcvMsgContent"] + msgContent: "MsgContent" + +class CIContent_sndDeleted(TypedDict): + type: Literal["sndDeleted"] + deleteMode: "CIDeleteMode" + +class CIContent_rcvDeleted(TypedDict): + type: Literal["rcvDeleted"] + deleteMode: "CIDeleteMode" + +class CIContent_sndCall(TypedDict): + type: Literal["sndCall"] + status: "CICallStatus" + duration: int # int + +class CIContent_rcvCall(TypedDict): + type: Literal["rcvCall"] + status: "CICallStatus" + duration: int # int + +class CIContent_rcvIntegrityError(TypedDict): + type: Literal["rcvIntegrityError"] + msgError: "MsgErrorType" + +class CIContent_rcvDecryptionError(TypedDict): + type: Literal["rcvDecryptionError"] + msgDecryptError: "MsgDecryptError" + msgCount: int # word32 + +class CIContent_rcvMsgError(TypedDict): + type: Literal["rcvMsgError"] + rcvMsgError: "RcvMsgError" + +class CIContent_rcvGroupInvitation(TypedDict): + type: Literal["rcvGroupInvitation"] + groupInvitation: "CIGroupInvitation" + memberRole: "GroupMemberRole" + +class CIContent_sndGroupInvitation(TypedDict): + type: Literal["sndGroupInvitation"] + groupInvitation: "CIGroupInvitation" + memberRole: "GroupMemberRole" + +class CIContent_rcvDirectEvent(TypedDict): + type: Literal["rcvDirectEvent"] + rcvDirectEvent: "RcvDirectEvent" + +class CIContent_rcvGroupEvent(TypedDict): + type: Literal["rcvGroupEvent"] + rcvGroupEvent: "RcvGroupEvent" + +class CIContent_sndGroupEvent(TypedDict): + type: Literal["sndGroupEvent"] + sndGroupEvent: "SndGroupEvent" + +class CIContent_rcvConnEvent(TypedDict): + type: Literal["rcvConnEvent"] + rcvConnEvent: "RcvConnEvent" + +class CIContent_sndConnEvent(TypedDict): + type: Literal["sndConnEvent"] + sndConnEvent: "SndConnEvent" + +class CIContent_rcvChatFeature(TypedDict): + type: Literal["rcvChatFeature"] + feature: "ChatFeature" + enabled: "PrefEnabled" + param: NotRequired[int] # int + +class CIContent_sndChatFeature(TypedDict): + type: Literal["sndChatFeature"] + feature: "ChatFeature" + enabled: "PrefEnabled" + param: NotRequired[int] # int + +class CIContent_rcvChatPreference(TypedDict): + type: Literal["rcvChatPreference"] + feature: "ChatFeature" + allowed: "FeatureAllowed" + param: NotRequired[int] # int + +class CIContent_sndChatPreference(TypedDict): + type: Literal["sndChatPreference"] + feature: "ChatFeature" + allowed: "FeatureAllowed" + param: NotRequired[int] # int + +class CIContent_rcvGroupFeature(TypedDict): + type: Literal["rcvGroupFeature"] + groupFeature: "GroupFeature" + preference: "GroupPreference" + param: NotRequired[int] # int + memberRole_: NotRequired["GroupMemberRole"] + +class CIContent_sndGroupFeature(TypedDict): + type: Literal["sndGroupFeature"] + groupFeature: "GroupFeature" + preference: "GroupPreference" + param: NotRequired[int] # int + memberRole_: NotRequired["GroupMemberRole"] + +class CIContent_rcvChatFeatureRejected(TypedDict): + type: Literal["rcvChatFeatureRejected"] + feature: "ChatFeature" + +class CIContent_rcvGroupFeatureRejected(TypedDict): + type: Literal["rcvGroupFeatureRejected"] + groupFeature: "GroupFeature" + +class CIContent_sndModerated(TypedDict): + type: Literal["sndModerated"] + +class CIContent_rcvModerated(TypedDict): + type: Literal["rcvModerated"] + +class CIContent_rcvBlocked(TypedDict): + type: Literal["rcvBlocked"] + +class CIContent_sndDirectE2EEInfo(TypedDict): + type: Literal["sndDirectE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_rcvDirectE2EEInfo(TypedDict): + type: Literal["rcvDirectE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_sndGroupE2EEInfo(TypedDict): + type: Literal["sndGroupE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_rcvGroupE2EEInfo(TypedDict): + type: Literal["rcvGroupE2EEInfo"] + e2eeInfo: "E2EInfo" + +class CIContent_chatBanner(TypedDict): + type: Literal["chatBanner"] + +CIContent = ( + CIContent_sndMsgContent + | CIContent_rcvMsgContent + | CIContent_sndDeleted + | CIContent_rcvDeleted + | CIContent_sndCall + | CIContent_rcvCall + | CIContent_rcvIntegrityError + | CIContent_rcvDecryptionError + | CIContent_rcvMsgError + | CIContent_rcvGroupInvitation + | CIContent_sndGroupInvitation + | CIContent_rcvDirectEvent + | CIContent_rcvGroupEvent + | CIContent_sndGroupEvent + | CIContent_rcvConnEvent + | CIContent_sndConnEvent + | CIContent_rcvChatFeature + | CIContent_sndChatFeature + | CIContent_rcvChatPreference + | CIContent_sndChatPreference + | CIContent_rcvGroupFeature + | CIContent_sndGroupFeature + | CIContent_rcvChatFeatureRejected + | CIContent_rcvGroupFeatureRejected + | CIContent_sndModerated + | CIContent_rcvModerated + | CIContent_rcvBlocked + | CIContent_sndDirectE2EEInfo + | CIContent_rcvDirectE2EEInfo + | CIContent_sndGroupE2EEInfo + | CIContent_rcvGroupE2EEInfo + | CIContent_chatBanner +) + +CIContent_Tag = Literal["sndMsgContent", "rcvMsgContent", "sndDeleted", "rcvDeleted", "sndCall", "rcvCall", "rcvIntegrityError", "rcvDecryptionError", "rcvMsgError", "rcvGroupInvitation", "sndGroupInvitation", "rcvDirectEvent", "rcvGroupEvent", "sndGroupEvent", "rcvConnEvent", "sndConnEvent", "rcvChatFeature", "sndChatFeature", "rcvChatPreference", "sndChatPreference", "rcvGroupFeature", "sndGroupFeature", "rcvChatFeatureRejected", "rcvGroupFeatureRejected", "sndModerated", "rcvModerated", "rcvBlocked", "sndDirectE2EEInfo", "rcvDirectE2EEInfo", "sndGroupE2EEInfo", "rcvGroupE2EEInfo", "chatBanner"] + +CIDeleteMode = Literal["broadcast", "internal", "internalMark", "history"] + +class CIDeleted_deleted(TypedDict): + type: Literal["deleted"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + chatType: "ChatType" + +class CIDeleted_blocked(TypedDict): + type: Literal["blocked"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + +class CIDeleted_blockedByAdmin(TypedDict): + type: Literal["blockedByAdmin"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + +class CIDeleted_moderated(TypedDict): + type: Literal["moderated"] + deletedTs: NotRequired[str] # ISO-8601 timestamp + byGroupMember: "GroupMember" + +CIDeleted = CIDeleted_deleted | CIDeleted_blocked | CIDeleted_blockedByAdmin | CIDeleted_moderated + +CIDeleted_Tag = Literal["deleted", "blocked", "blockedByAdmin", "moderated"] + +class CIDirection_directSnd(TypedDict): + type: Literal["directSnd"] + +class CIDirection_directRcv(TypedDict): + type: Literal["directRcv"] + +class CIDirection_groupSnd(TypedDict): + type: Literal["groupSnd"] + +class CIDirection_groupRcv(TypedDict): + type: Literal["groupRcv"] + groupMember: "GroupMember" + +class CIDirection_channelRcv(TypedDict): + type: Literal["channelRcv"] + +class CIDirection_localSnd(TypedDict): + type: Literal["localSnd"] + +class CIDirection_localRcv(TypedDict): + type: Literal["localRcv"] + +CIDirection = ( + CIDirection_directSnd + | CIDirection_directRcv + | CIDirection_groupSnd + | CIDirection_groupRcv + | CIDirection_channelRcv + | CIDirection_localSnd + | CIDirection_localRcv +) + +CIDirection_Tag = Literal["directSnd", "directRcv", "groupSnd", "groupRcv", "channelRcv", "localSnd", "localRcv"] + +class CIFile(TypedDict): + fileId: int # int64 + fileName: str + fileSize: int # int64 + fileSource: NotRequired["CryptoFile"] + fileStatus: "CIFileStatus" + fileProtocol: "FileProtocol" + +class CIFileStatus_sndStored(TypedDict): + type: Literal["sndStored"] + +class CIFileStatus_sndTransfer(TypedDict): + type: Literal["sndTransfer"] + sndProgress: int # int64 + sndTotal: int # int64 + +class CIFileStatus_sndCancelled(TypedDict): + type: Literal["sndCancelled"] + +class CIFileStatus_sndComplete(TypedDict): + type: Literal["sndComplete"] + +class CIFileStatus_sndError(TypedDict): + type: Literal["sndError"] + sndFileError: "FileError" + +class CIFileStatus_sndWarning(TypedDict): + type: Literal["sndWarning"] + sndFileError: "FileError" + +class CIFileStatus_rcvInvitation(TypedDict): + type: Literal["rcvInvitation"] + +class CIFileStatus_rcvAccepted(TypedDict): + type: Literal["rcvAccepted"] + +class CIFileStatus_rcvTransfer(TypedDict): + type: Literal["rcvTransfer"] + rcvProgress: int # int64 + rcvTotal: int # int64 + +class CIFileStatus_rcvAborted(TypedDict): + type: Literal["rcvAborted"] + +class CIFileStatus_rcvComplete(TypedDict): + type: Literal["rcvComplete"] + +class CIFileStatus_rcvCancelled(TypedDict): + type: Literal["rcvCancelled"] + +class CIFileStatus_rcvError(TypedDict): + type: Literal["rcvError"] + rcvFileError: "FileError" + +class CIFileStatus_rcvWarning(TypedDict): + type: Literal["rcvWarning"] + rcvFileError: "FileError" + +class CIFileStatus_invalid(TypedDict): + type: Literal["invalid"] + text: str + +CIFileStatus = ( + CIFileStatus_sndStored + | CIFileStatus_sndTransfer + | CIFileStatus_sndCancelled + | CIFileStatus_sndComplete + | CIFileStatus_sndError + | CIFileStatus_sndWarning + | CIFileStatus_rcvInvitation + | CIFileStatus_rcvAccepted + | CIFileStatus_rcvTransfer + | CIFileStatus_rcvAborted + | CIFileStatus_rcvComplete + | CIFileStatus_rcvCancelled + | CIFileStatus_rcvError + | CIFileStatus_rcvWarning + | CIFileStatus_invalid +) + +CIFileStatus_Tag = Literal["sndStored", "sndTransfer", "sndCancelled", "sndComplete", "sndError", "sndWarning", "rcvInvitation", "rcvAccepted", "rcvTransfer", "rcvAborted", "rcvComplete", "rcvCancelled", "rcvError", "rcvWarning", "invalid"] + +class CIForwardedFrom_unknown(TypedDict): + type: Literal["unknown"] + +class CIForwardedFrom_contact(TypedDict): + type: Literal["contact"] + chatName: str + msgDir: "MsgDirection" + contactId: NotRequired[int] # int64 + chatItemId: NotRequired[int] # int64 + +class CIForwardedFrom_group(TypedDict): + type: Literal["group"] + chatName: str + msgDir: "MsgDirection" + groupId: NotRequired[int] # int64 + chatItemId: NotRequired[int] # int64 + +CIForwardedFrom = CIForwardedFrom_unknown | CIForwardedFrom_contact | CIForwardedFrom_group + +CIForwardedFrom_Tag = Literal["unknown", "contact", "group"] + +class CIGroupInvitation(TypedDict): + groupId: int # int64 + groupMemberId: int # int64 + localDisplayName: str + groupProfile: "GroupProfile" + status: "CIGroupInvitationStatus" + +CIGroupInvitationStatus = Literal["pending", "accepted", "rejected", "expired"] + +class CIMention(TypedDict): + memberId: str + memberRef: NotRequired["CIMentionMember"] + +class CIMentionMember(TypedDict): + groupMemberId: int # int64 + displayName: str + localAlias: NotRequired[str] + memberRole: "GroupMemberRole" + +class CIMeta(TypedDict): + itemId: int # int64 + itemTs: str # ISO-8601 timestamp + itemText: str + itemStatus: "CIStatus" + sentViaProxy: NotRequired[bool] + itemSharedMsgId: NotRequired[str] + itemForwarded: NotRequired["CIForwardedFrom"] + itemDeleted: NotRequired["CIDeleted"] + itemEdited: bool + itemTimed: NotRequired["CITimed"] + itemLive: NotRequired[bool] + userMention: bool + hasLink: bool + deletable: bool + editable: bool + forwardedByMember: NotRequired[int] # int64 + showGroupAsSender: bool + msgSigned: NotRequired["MsgSigStatus"] + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + +class CIQuote(TypedDict): + chatDir: NotRequired["CIDirection"] + itemId: NotRequired[int] # int64 + sharedMsgId: NotRequired[str] + sentAt: str # ISO-8601 timestamp + content: "MsgContent" + formattedText: NotRequired[list["FormattedText"]] + +class CIReaction(TypedDict): + chatDir: "CIDirection" + chatItem: "ChatItem" + sentAt: str # ISO-8601 timestamp + reaction: "MsgReaction" + +class CIReactionCount(TypedDict): + reaction: "MsgReaction" + userReacted: bool + totalReacted: int # int + +class CIStatus_sndNew(TypedDict): + type: Literal["sndNew"] + +class CIStatus_sndSent(TypedDict): + type: Literal["sndSent"] + sndProgress: "SndCIStatusProgress" + +class CIStatus_sndRcvd(TypedDict): + type: Literal["sndRcvd"] + msgRcptStatus: "MsgReceiptStatus" + sndProgress: "SndCIStatusProgress" + +class CIStatus_sndErrorAuth(TypedDict): + type: Literal["sndErrorAuth"] + +class CIStatus_sndError(TypedDict): + type: Literal["sndError"] + agentError: "SndError" + +class CIStatus_sndWarning(TypedDict): + type: Literal["sndWarning"] + agentError: "SndError" + +class CIStatus_rcvNew(TypedDict): + type: Literal["rcvNew"] + +class CIStatus_rcvRead(TypedDict): + type: Literal["rcvRead"] + +class CIStatus_invalid(TypedDict): + type: Literal["invalid"] + text: str + +CIStatus = ( + CIStatus_sndNew + | CIStatus_sndSent + | CIStatus_sndRcvd + | CIStatus_sndErrorAuth + | CIStatus_sndError + | CIStatus_sndWarning + | CIStatus_rcvNew + | CIStatus_rcvRead + | CIStatus_invalid +) + +CIStatus_Tag = Literal["sndNew", "sndSent", "sndRcvd", "sndErrorAuth", "sndError", "sndWarning", "rcvNew", "rcvRead", "invalid"] + +class CITimed(TypedDict): + ttl: int # int + deleteAt: NotRequired[str] # ISO-8601 timestamp + +class ChatBotCommand_command(TypedDict): + type: Literal["command"] + keyword: str + label: str + params: NotRequired[str] + +class ChatBotCommand_menu(TypedDict): + type: Literal["menu"] + label: str + commands: list["ChatBotCommand"] + +ChatBotCommand = ChatBotCommand_command | ChatBotCommand_menu + +ChatBotCommand_Tag = Literal["command", "menu"] + +class ChatDeleteMode_full(TypedDict): + type: Literal["full"] + notify: bool + +class ChatDeleteMode_entity(TypedDict): + type: Literal["entity"] + notify: bool + +class ChatDeleteMode_messages(TypedDict): + type: Literal["messages"] + +ChatDeleteMode = ChatDeleteMode_full | ChatDeleteMode_entity | ChatDeleteMode_messages + +ChatDeleteMode_Tag = Literal["full", "entity", "messages"] + + +def ChatDeleteMode_cmd_string(self: ChatDeleteMode) -> str: + return str(self['type']) + ('' if str(self['type']) == 'messages' else (' notify=off' if not self['notify'] else '')) # type: ignore[typeddict-item] + +class ChatError_error(TypedDict): + type: Literal["error"] + errorType: "ChatErrorType" + +class ChatError_errorAgent(TypedDict): + type: Literal["errorAgent"] + agentError: "AgentErrorType" + agentConnId: str + connectionEntity_: NotRequired["ConnectionEntity"] + +class ChatError_errorStore(TypedDict): + type: Literal["errorStore"] + storeError: "StoreError" + +ChatError = ChatError_error | ChatError_errorAgent | ChatError_errorStore + +ChatError_Tag = Literal["error", "errorAgent", "errorStore"] + +class ChatErrorType_noActiveUser(TypedDict): + type: Literal["noActiveUser"] + +class ChatErrorType_noConnectionUser(TypedDict): + type: Literal["noConnectionUser"] + agentConnId: str + +class ChatErrorType_noSndFileUser(TypedDict): + type: Literal["noSndFileUser"] + agentSndFileId: str + +class ChatErrorType_noRcvFileUser(TypedDict): + type: Literal["noRcvFileUser"] + agentRcvFileId: str + +class ChatErrorType_userUnknown(TypedDict): + type: Literal["userUnknown"] + +class ChatErrorType_activeUserExists(TypedDict): + type: Literal["activeUserExists"] + +class ChatErrorType_userExists(TypedDict): + type: Literal["userExists"] + contactName: str + +class ChatErrorType_chatRelayExists(TypedDict): + type: Literal["chatRelayExists"] + +class ChatErrorType_differentActiveUser(TypedDict): + type: Literal["differentActiveUser"] + commandUserId: int # int64 + activeUserId: int # int64 + +class ChatErrorType_cantDeleteActiveUser(TypedDict): + type: Literal["cantDeleteActiveUser"] + userId: int # int64 + +class ChatErrorType_cantDeleteLastUser(TypedDict): + type: Literal["cantDeleteLastUser"] + userId: int # int64 + +class ChatErrorType_cantHideLastUser(TypedDict): + type: Literal["cantHideLastUser"] + userId: int # int64 + +class ChatErrorType_hiddenUserAlwaysMuted(TypedDict): + type: Literal["hiddenUserAlwaysMuted"] + userId: int # int64 + +class ChatErrorType_emptyUserPassword(TypedDict): + type: Literal["emptyUserPassword"] + userId: int # int64 + +class ChatErrorType_userAlreadyHidden(TypedDict): + type: Literal["userAlreadyHidden"] + userId: int # int64 + +class ChatErrorType_userNotHidden(TypedDict): + type: Literal["userNotHidden"] + userId: int # int64 + +class ChatErrorType_invalidDisplayName(TypedDict): + type: Literal["invalidDisplayName"] + displayName: str + validName: str + +class ChatErrorType_chatNotStarted(TypedDict): + type: Literal["chatNotStarted"] + +class ChatErrorType_chatNotStopped(TypedDict): + type: Literal["chatNotStopped"] + +class ChatErrorType_chatStoreChanged(TypedDict): + type: Literal["chatStoreChanged"] + +class ChatErrorType_invalidConnReq(TypedDict): + type: Literal["invalidConnReq"] + +class ChatErrorType_unsupportedConnReq(TypedDict): + type: Literal["unsupportedConnReq"] + +class ChatErrorType_connReqMessageProhibited(TypedDict): + type: Literal["connReqMessageProhibited"] + +class ChatErrorType_contactNotReady(TypedDict): + type: Literal["contactNotReady"] + contact: "Contact" + +class ChatErrorType_contactNotActive(TypedDict): + type: Literal["contactNotActive"] + contact: "Contact" + +class ChatErrorType_contactDisabled(TypedDict): + type: Literal["contactDisabled"] + contact: "Contact" + +class ChatErrorType_connectionDisabled(TypedDict): + type: Literal["connectionDisabled"] + connection: "Connection" + +class ChatErrorType_groupUserRole(TypedDict): + type: Literal["groupUserRole"] + groupInfo: "GroupInfo" + requiredRole: "GroupMemberRole" + +class ChatErrorType_groupMemberInitialRole(TypedDict): + type: Literal["groupMemberInitialRole"] + groupInfo: "GroupInfo" + initialRole: "GroupMemberRole" + +class ChatErrorType_contactIncognitoCantInvite(TypedDict): + type: Literal["contactIncognitoCantInvite"] + +class ChatErrorType_groupIncognitoCantInvite(TypedDict): + type: Literal["groupIncognitoCantInvite"] + +class ChatErrorType_groupContactRole(TypedDict): + type: Literal["groupContactRole"] + contactName: str + +class ChatErrorType_groupDuplicateMember(TypedDict): + type: Literal["groupDuplicateMember"] + contactName: str + +class ChatErrorType_groupDuplicateMemberId(TypedDict): + type: Literal["groupDuplicateMemberId"] + +class ChatErrorType_groupNotJoined(TypedDict): + type: Literal["groupNotJoined"] + groupInfo: "GroupInfo" + +class ChatErrorType_groupMemberNotActive(TypedDict): + type: Literal["groupMemberNotActive"] + +class ChatErrorType_cantBlockMemberForSelf(TypedDict): + type: Literal["cantBlockMemberForSelf"] + groupInfo: "GroupInfo" + member: "GroupMember" + setShowMessages: bool + +class ChatErrorType_groupMemberUserRemoved(TypedDict): + type: Literal["groupMemberUserRemoved"] + +class ChatErrorType_groupMemberNotFound(TypedDict): + type: Literal["groupMemberNotFound"] + +class ChatErrorType_groupCantResendInvitation(TypedDict): + type: Literal["groupCantResendInvitation"] + groupInfo: "GroupInfo" + contactName: str + +class ChatErrorType_groupInternal(TypedDict): + type: Literal["groupInternal"] + message: str + +class ChatErrorType_fileNotFound(TypedDict): + type: Literal["fileNotFound"] + message: str + +class ChatErrorType_fileSize(TypedDict): + type: Literal["fileSize"] + filePath: str + +class ChatErrorType_fileAlreadyReceiving(TypedDict): + type: Literal["fileAlreadyReceiving"] + message: str + +class ChatErrorType_fileCancelled(TypedDict): + type: Literal["fileCancelled"] + message: str + +class ChatErrorType_fileCancel(TypedDict): + type: Literal["fileCancel"] + fileId: int # int64 + message: str + +class ChatErrorType_fileAlreadyExists(TypedDict): + type: Literal["fileAlreadyExists"] + filePath: str + +class ChatErrorType_fileWrite(TypedDict): + type: Literal["fileWrite"] + filePath: str + message: str + +class ChatErrorType_fileSend(TypedDict): + type: Literal["fileSend"] + fileId: int # int64 + agentError: "AgentErrorType" + +class ChatErrorType_fileRcvChunk(TypedDict): + type: Literal["fileRcvChunk"] + message: str + +class ChatErrorType_fileInternal(TypedDict): + type: Literal["fileInternal"] + message: str + +class ChatErrorType_fileImageType(TypedDict): + type: Literal["fileImageType"] + filePath: str + +class ChatErrorType_fileImageSize(TypedDict): + type: Literal["fileImageSize"] + filePath: str + +class ChatErrorType_fileNotReceived(TypedDict): + type: Literal["fileNotReceived"] + fileId: int # int64 + +class ChatErrorType_fileNotApproved(TypedDict): + type: Literal["fileNotApproved"] + fileId: int # int64 + unknownServers: list[str] + +class ChatErrorType_fallbackToSMPProhibited(TypedDict): + type: Literal["fallbackToSMPProhibited"] + fileId: int # int64 + +class ChatErrorType_inlineFileProhibited(TypedDict): + type: Literal["inlineFileProhibited"] + fileId: int # int64 + +class ChatErrorType_invalidForward(TypedDict): + type: Literal["invalidForward"] + +class ChatErrorType_invalidChatItemUpdate(TypedDict): + type: Literal["invalidChatItemUpdate"] + +class ChatErrorType_invalidChatItemDelete(TypedDict): + type: Literal["invalidChatItemDelete"] + +class ChatErrorType_hasCurrentCall(TypedDict): + type: Literal["hasCurrentCall"] + +class ChatErrorType_noCurrentCall(TypedDict): + type: Literal["noCurrentCall"] + +class ChatErrorType_callContact(TypedDict): + type: Literal["callContact"] + contactId: int # int64 + +class ChatErrorType_directMessagesProhibited(TypedDict): + type: Literal["directMessagesProhibited"] + direction: "MsgDirection" + contact: "Contact" + +class ChatErrorType_agentVersion(TypedDict): + type: Literal["agentVersion"] + +class ChatErrorType_agentNoSubResult(TypedDict): + type: Literal["agentNoSubResult"] + agentConnId: str + +class ChatErrorType_commandError(TypedDict): + type: Literal["commandError"] + message: str + +class ChatErrorType_agentCommandError(TypedDict): + type: Literal["agentCommandError"] + message: str + +class ChatErrorType_invalidFileDescription(TypedDict): + type: Literal["invalidFileDescription"] + message: str + +class ChatErrorType_connectionIncognitoChangeProhibited(TypedDict): + type: Literal["connectionIncognitoChangeProhibited"] + +class ChatErrorType_connectionUserChangeProhibited(TypedDict): + type: Literal["connectionUserChangeProhibited"] + +class ChatErrorType_peerChatVRangeIncompatible(TypedDict): + type: Literal["peerChatVRangeIncompatible"] + +class ChatErrorType_relayTestError(TypedDict): + type: Literal["relayTestError"] + message: str + +class ChatErrorType_internalError(TypedDict): + type: Literal["internalError"] + message: str + +class ChatErrorType_exception(TypedDict): + type: Literal["exception"] + message: str + +ChatErrorType = ( + ChatErrorType_noActiveUser + | ChatErrorType_noConnectionUser + | ChatErrorType_noSndFileUser + | ChatErrorType_noRcvFileUser + | ChatErrorType_userUnknown + | ChatErrorType_activeUserExists + | ChatErrorType_userExists + | ChatErrorType_chatRelayExists + | ChatErrorType_differentActiveUser + | ChatErrorType_cantDeleteActiveUser + | ChatErrorType_cantDeleteLastUser + | ChatErrorType_cantHideLastUser + | ChatErrorType_hiddenUserAlwaysMuted + | ChatErrorType_emptyUserPassword + | ChatErrorType_userAlreadyHidden + | ChatErrorType_userNotHidden + | ChatErrorType_invalidDisplayName + | ChatErrorType_chatNotStarted + | ChatErrorType_chatNotStopped + | ChatErrorType_chatStoreChanged + | ChatErrorType_invalidConnReq + | ChatErrorType_unsupportedConnReq + | ChatErrorType_connReqMessageProhibited + | ChatErrorType_contactNotReady + | ChatErrorType_contactNotActive + | ChatErrorType_contactDisabled + | ChatErrorType_connectionDisabled + | ChatErrorType_groupUserRole + | ChatErrorType_groupMemberInitialRole + | ChatErrorType_contactIncognitoCantInvite + | ChatErrorType_groupIncognitoCantInvite + | ChatErrorType_groupContactRole + | ChatErrorType_groupDuplicateMember + | ChatErrorType_groupDuplicateMemberId + | ChatErrorType_groupNotJoined + | ChatErrorType_groupMemberNotActive + | ChatErrorType_cantBlockMemberForSelf + | ChatErrorType_groupMemberUserRemoved + | ChatErrorType_groupMemberNotFound + | ChatErrorType_groupCantResendInvitation + | ChatErrorType_groupInternal + | ChatErrorType_fileNotFound + | ChatErrorType_fileSize + | ChatErrorType_fileAlreadyReceiving + | ChatErrorType_fileCancelled + | ChatErrorType_fileCancel + | ChatErrorType_fileAlreadyExists + | ChatErrorType_fileWrite + | ChatErrorType_fileSend + | ChatErrorType_fileRcvChunk + | ChatErrorType_fileInternal + | ChatErrorType_fileImageType + | ChatErrorType_fileImageSize + | ChatErrorType_fileNotReceived + | ChatErrorType_fileNotApproved + | ChatErrorType_fallbackToSMPProhibited + | ChatErrorType_inlineFileProhibited + | ChatErrorType_invalidForward + | ChatErrorType_invalidChatItemUpdate + | ChatErrorType_invalidChatItemDelete + | ChatErrorType_hasCurrentCall + | ChatErrorType_noCurrentCall + | ChatErrorType_callContact + | ChatErrorType_directMessagesProhibited + | ChatErrorType_agentVersion + | ChatErrorType_agentNoSubResult + | ChatErrorType_commandError + | ChatErrorType_agentCommandError + | ChatErrorType_invalidFileDescription + | ChatErrorType_connectionIncognitoChangeProhibited + | ChatErrorType_connectionUserChangeProhibited + | ChatErrorType_peerChatVRangeIncompatible + | ChatErrorType_relayTestError + | ChatErrorType_internalError + | ChatErrorType_exception +) + +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "activeUserExists", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] + +ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] + +class ChatInfo_direct(TypedDict): + type: Literal["direct"] + contact: "Contact" + +class ChatInfo_group(TypedDict): + type: Literal["group"] + groupInfo: "GroupInfo" + groupChatScope: NotRequired["GroupChatScopeInfo"] + +class ChatInfo_local(TypedDict): + type: Literal["local"] + noteFolder: "NoteFolder" + +class ChatInfo_contactRequest(TypedDict): + type: Literal["contactRequest"] + contactRequest: "UserContactRequest" + +class ChatInfo_contactConnection(TypedDict): + type: Literal["contactConnection"] + contactConnection: "PendingContactConnection" + +ChatInfo = ( + ChatInfo_direct + | ChatInfo_group + | ChatInfo_local + | ChatInfo_contactRequest + | ChatInfo_contactConnection +) + +ChatInfo_Tag = Literal["direct", "group", "local", "contactRequest", "contactConnection"] + +class ChatItem(TypedDict): + chatDir: "CIDirection" + meta: "CIMeta" + content: "CIContent" + mentions: dict[str, "CIMention"] + formattedText: NotRequired[list["FormattedText"]] + quotedItem: NotRequired["CIQuote"] + reactions: list["CIReactionCount"] + file: NotRequired["CIFile"] + +# Message deletion result. + +class ChatItemDeletion(TypedDict): + deletedChatItem: "AChatItem" + toChatItem: NotRequired["AChatItem"] + +class ChatListQuery_filters(TypedDict): + type: Literal["filters"] + favorite: bool + unread: bool + +class ChatListQuery_search(TypedDict): + type: Literal["search"] + search: str + +ChatListQuery = ChatListQuery_filters | ChatListQuery_search + +ChatListQuery_Tag = Literal["filters", "search"] + +ChatPeerType = Literal["human", "bot"] + +# Used in API commands. Chat scope can only be passed with groups. + +class ChatRef(TypedDict): + chatType: "ChatType" + chatId: int # int64 + chatScope: NotRequired["GroupChatScope"] + + +def ChatRef_cmd_string(self: ChatRef) -> str: + return ChatType_cmd_string(self['chatType']) + str(self['chatId']) + ((GroupChatScope_cmd_string(self.get('chatScope'))) if self.get('chatScope') is not None else '') + +class ChatSettings(TypedDict): + enableNtfs: "MsgFilter" + sendRcpts: NotRequired[bool] + favorite: bool + +class ChatStats(TypedDict): + unreadCount: int # int + unreadMentions: int # int + reportsCount: int # int + minUnreadItemId: int # int64 + unreadChat: bool + +ChatType = Literal["direct", "group", "local"] + + +def ChatType_cmd_string(self: ChatType) -> str: + return '@' if str(self) == 'direct' else '#' if str(self) == 'group' else '*' if str(self) == 'local' else '' + +class ChatWallpaper(TypedDict): + preset: NotRequired[str] + imageFile: NotRequired[str] + background: NotRequired[str] + tint: NotRequired[str] + scaleType: NotRequired["ChatWallpaperScale"] + scale: NotRequired[float] # double + +ChatWallpaperScale = Literal["fill", "fit", "repeat"] + +class ClientNotice(TypedDict): + ttl: NotRequired[int] # int64 + +Color = Literal["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] + +class CommandError_UNKNOWN(TypedDict): + type: Literal["UNKNOWN"] + +class CommandError_SYNTAX(TypedDict): + type: Literal["SYNTAX"] + +class CommandError_PROHIBITED(TypedDict): + type: Literal["PROHIBITED"] + +class CommandError_NO_AUTH(TypedDict): + type: Literal["NO_AUTH"] + +class CommandError_HAS_AUTH(TypedDict): + type: Literal["HAS_AUTH"] + +class CommandError_NO_ENTITY(TypedDict): + type: Literal["NO_ENTITY"] + +CommandError = ( + CommandError_UNKNOWN + | CommandError_SYNTAX + | CommandError_PROHIBITED + | CommandError_NO_AUTH + | CommandError_HAS_AUTH + | CommandError_NO_ENTITY +) + +CommandError_Tag = Literal["UNKNOWN", "SYNTAX", "PROHIBITED", "NO_AUTH", "HAS_AUTH", "NO_ENTITY"] + +class CommandErrorType_PROHIBITED(TypedDict): + type: Literal["PROHIBITED"] + +class CommandErrorType_SYNTAX(TypedDict): + type: Literal["SYNTAX"] + +class CommandErrorType_NO_CONN(TypedDict): + type: Literal["NO_CONN"] + +class CommandErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class CommandErrorType_LARGE(TypedDict): + type: Literal["LARGE"] + +CommandErrorType = ( + CommandErrorType_PROHIBITED + | CommandErrorType_SYNTAX + | CommandErrorType_NO_CONN + | CommandErrorType_SIZE + | CommandErrorType_LARGE +) + +CommandErrorType_Tag = Literal["PROHIBITED", "SYNTAX", "NO_CONN", "SIZE", "LARGE"] + +class CommentsGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + duration: NotRequired[int] # int + +class ComposedMessage(TypedDict): + fileSource: NotRequired["CryptoFile"] + quotedItemId: NotRequired[int] # int64 + msgContent: "MsgContent" + mentions: dict[str, int] # str : int64 + +class ConnStatus_new(TypedDict): + type: Literal["new"] + +class ConnStatus_prepared(TypedDict): + type: Literal["prepared"] + +class ConnStatus_joined(TypedDict): + type: Literal["joined"] + +class ConnStatus_requested(TypedDict): + type: Literal["requested"] + +class ConnStatus_accepted(TypedDict): + type: Literal["accepted"] + +class ConnStatus_sndReady(TypedDict): + type: Literal["sndReady"] + +class ConnStatus_ready(TypedDict): + type: Literal["ready"] + +class ConnStatus_deleted(TypedDict): + type: Literal["deleted"] + +class ConnStatus_failed(TypedDict): + type: Literal["failed"] + connError: str + +ConnStatus = ( + ConnStatus_new + | ConnStatus_prepared + | ConnStatus_joined + | ConnStatus_requested + | ConnStatus_accepted + | ConnStatus_sndReady + | ConnStatus_ready + | ConnStatus_deleted + | ConnStatus_failed +) + +ConnStatus_Tag = Literal["new", "prepared", "joined", "requested", "accepted", "sndReady", "ready", "deleted", "failed"] + +ConnType = Literal["contact", "member", "user_contact"] + +class Connection(TypedDict): + connId: int # int64 + agentConnId: str + connChatVersion: int # int + peerChatVRange: "VersionRange" + connLevel: int # int + viaContact: NotRequired[int] # int64 + viaUserContactLink: NotRequired[int] # int64 + viaGroupLink: bool + groupLinkId: NotRequired[str] + xContactId: NotRequired[str] + customUserProfileId: NotRequired[int] # int64 + connType: "ConnType" + connStatus: "ConnStatus" + contactConnInitiated: bool + localAlias: str + entityId: NotRequired[int] # int64 + connectionCode: NotRequired["SecurityCode"] + pqSupport: bool + pqEncryption: bool + pqSndEnabled: NotRequired[bool] + pqRcvEnabled: NotRequired[bool] + authErrCounter: int # int + quotaErrCounter: int # int + createdAt: str # ISO-8601 timestamp + +class ConnectionEntity_rcvDirectMsgConnection(TypedDict): + type: Literal["rcvDirectMsgConnection"] + entityConnection: "Connection" + contact: NotRequired["Contact"] + +class ConnectionEntity_rcvGroupMsgConnection(TypedDict): + type: Literal["rcvGroupMsgConnection"] + entityConnection: "Connection" + groupInfo: "GroupInfo" + groupMember: "GroupMember" + +class ConnectionEntity_userContactConnection(TypedDict): + type: Literal["userContactConnection"] + entityConnection: "Connection" + userContact: "UserContact" + +ConnectionEntity = ( + ConnectionEntity_rcvDirectMsgConnection + | ConnectionEntity_rcvGroupMsgConnection + | ConnectionEntity_userContactConnection +) + +ConnectionEntity_Tag = Literal["rcvDirectMsgConnection", "rcvGroupMsgConnection", "userContactConnection"] + +class ConnectionErrorType_NOT_FOUND(TypedDict): + type: Literal["NOT_FOUND"] + +class ConnectionErrorType_DUPLICATE(TypedDict): + type: Literal["DUPLICATE"] + +class ConnectionErrorType_SIMPLEX(TypedDict): + type: Literal["SIMPLEX"] + +class ConnectionErrorType_NOT_ACCEPTED(TypedDict): + type: Literal["NOT_ACCEPTED"] + +class ConnectionErrorType_NOT_AVAILABLE(TypedDict): + type: Literal["NOT_AVAILABLE"] + +ConnectionErrorType = ( + ConnectionErrorType_NOT_FOUND + | ConnectionErrorType_DUPLICATE + | ConnectionErrorType_SIMPLEX + | ConnectionErrorType_NOT_ACCEPTED + | ConnectionErrorType_NOT_AVAILABLE +) + +ConnectionErrorType_Tag = Literal["NOT_FOUND", "DUPLICATE", "SIMPLEX", "NOT_ACCEPTED", "NOT_AVAILABLE"] + +ConnectionMode = Literal["INV", "CON"] + +class ConnectionPlan_invitationLink(TypedDict): + type: Literal["invitationLink"] + invitationLinkPlan: "InvitationLinkPlan" + +class ConnectionPlan_contactAddress(TypedDict): + type: Literal["contactAddress"] + contactAddressPlan: "ContactAddressPlan" + +class ConnectionPlan_groupLink(TypedDict): + type: Literal["groupLink"] + groupLinkPlan: "GroupLinkPlan" + +class ConnectionPlan_error(TypedDict): + type: Literal["error"] + chatError: "ChatError" + +ConnectionPlan = ( + ConnectionPlan_invitationLink + | ConnectionPlan_contactAddress + | ConnectionPlan_groupLink + | ConnectionPlan_error +) + +ConnectionPlan_Tag = Literal["invitationLink", "contactAddress", "groupLink", "error"] + +class Contact(TypedDict): + contactId: int # int64 + localDisplayName: str + profile: "LocalProfile" + activeConn: NotRequired["Connection"] + contactUsed: bool + contactStatus: "ContactStatus" + chatSettings: "ChatSettings" + userPreferences: "Preferences" + mergedPreferences: "ContactUserPreferences" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: NotRequired[str] # ISO-8601 timestamp + preparedContact: NotRequired["PreparedContact"] + contactRequestId: NotRequired[int] # int64 + contactGroupMemberId: NotRequired[int] # int64 + contactGrpInvSent: bool + groupDirectInv: NotRequired["GroupDirectInvitation"] + chatTags: list[int] # int64 + chatItemTTL: NotRequired[int] # int64 + uiThemes: NotRequired["UIThemeEntityOverrides"] + chatDeleted: bool + customData: NotRequired[dict[str, object]] + +class ContactAddressPlan_ok(TypedDict): + type: Literal["ok"] + contactSLinkData_: NotRequired["ContactShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class ContactAddressPlan_ownLink(TypedDict): + type: Literal["ownLink"] + +class ContactAddressPlan_connectingConfirmReconnect(TypedDict): + type: Literal["connectingConfirmReconnect"] + +class ContactAddressPlan_connectingProhibit(TypedDict): + type: Literal["connectingProhibit"] + contact: "Contact" + +class ContactAddressPlan_known(TypedDict): + type: Literal["known"] + contact: "Contact" + +class ContactAddressPlan_contactViaAddress(TypedDict): + type: Literal["contactViaAddress"] + contact: "Contact" + +ContactAddressPlan = ( + ContactAddressPlan_ok + | ContactAddressPlan_ownLink + | ContactAddressPlan_connectingConfirmReconnect + | ContactAddressPlan_connectingProhibit + | ContactAddressPlan_known + | ContactAddressPlan_contactViaAddress +) + +ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress"] + +class ContactShortLinkData(TypedDict): + profile: "Profile" + message: NotRequired["MsgContent"] + business: bool + +ContactStatus = Literal["active", "deleted", "deletedByUser"] + +class ContactUserPref_contact(TypedDict): + type: Literal["contact"] + preference: "SimplePreference" + +class ContactUserPref_user(TypedDict): + type: Literal["user"] + preference: "SimplePreference" + +ContactUserPref = ContactUserPref_contact | ContactUserPref_user + +ContactUserPref_Tag = Literal["contact", "user"] + +class ContactUserPreference(TypedDict): + enabled: "PrefEnabled" + userPreference: "ContactUserPref" + contactPreference: "SimplePreference" + +class ContactUserPreferences(TypedDict): + timedMessages: "ContactUserPreference" + fullDelete: "ContactUserPreference" + reactions: "ContactUserPreference" + voice: "ContactUserPreference" + files: "ContactUserPreference" + calls: "ContactUserPreference" + sessions: "ContactUserPreference" + commands: NotRequired[list["ChatBotCommand"]] + +class CreatedConnLink(TypedDict): + connFullLink: str + connShortLink: NotRequired[str] + + +def CreatedConnLink_cmd_string(self: CreatedConnLink) -> str: + return self['connFullLink'] + ((' ' + self.get('connShortLink')) if self.get('connShortLink') is not None else '') + +class CryptoFile(TypedDict): + filePath: str + cryptoArgs: NotRequired["CryptoFileArgs"] + +class CryptoFileArgs(TypedDict): + fileKey: str + fileNonce: str + +class DroppedMsg(TypedDict): + brokerTs: str # ISO-8601 timestamp + attempts: int # int + +class E2EInfo(TypedDict): + public: NotRequired[bool] + pqEnabled: NotRequired[bool] + +class ErrorType_BLOCK(TypedDict): + type: Literal["BLOCK"] + +class ErrorType_SESSION(TypedDict): + type: Literal["SESSION"] + +class ErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandError" + +class ErrorType_PROXY(TypedDict): + type: Literal["PROXY"] + proxyErr: "ProxyError" + +class ErrorType_AUTH(TypedDict): + type: Literal["AUTH"] + +class ErrorType_BLOCKED(TypedDict): + type: Literal["BLOCKED"] + blockInfo: "BlockingInfo" + +class ErrorType_SERVICE(TypedDict): + type: Literal["SERVICE"] + +class ErrorType_CRYPTO(TypedDict): + type: Literal["CRYPTO"] + +class ErrorType_QUOTA(TypedDict): + type: Literal["QUOTA"] + +class ErrorType_STORE(TypedDict): + type: Literal["STORE"] + storeErr: str + +class ErrorType_NO_MSG(TypedDict): + type: Literal["NO_MSG"] + +class ErrorType_LARGE_MSG(TypedDict): + type: Literal["LARGE_MSG"] + +class ErrorType_EXPIRED(TypedDict): + type: Literal["EXPIRED"] + +class ErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + +class ErrorType_DUPLICATE_(TypedDict): + type: Literal["DUPLICATE_"] + +ErrorType = ( + ErrorType_BLOCK + | ErrorType_SESSION + | ErrorType_CMD + | ErrorType_PROXY + | ErrorType_AUTH + | ErrorType_BLOCKED + | ErrorType_SERVICE + | ErrorType_CRYPTO + | ErrorType_QUOTA + | ErrorType_STORE + | ErrorType_NO_MSG + | ErrorType_LARGE_MSG + | ErrorType_EXPIRED + | ErrorType_INTERNAL + | ErrorType_DUPLICATE_ +) + +ErrorType_Tag = Literal["BLOCK", "SESSION", "CMD", "PROXY", "AUTH", "BLOCKED", "SERVICE", "CRYPTO", "QUOTA", "STORE", "NO_MSG", "LARGE_MSG", "EXPIRED", "INTERNAL", "DUPLICATE_"] + +FeatureAllowed = Literal["always", "yes", "no"] + +class FileDescr(TypedDict): + fileDescrText: str + fileDescrPartNo: int # int + fileDescrComplete: bool + +class FileError_auth(TypedDict): + type: Literal["auth"] + +class FileError_blocked(TypedDict): + type: Literal["blocked"] + server: str + blockInfo: "BlockingInfo" + +class FileError_noFile(TypedDict): + type: Literal["noFile"] + +class FileError_relay(TypedDict): + type: Literal["relay"] + srvError: "SrvError" + +class FileError_other(TypedDict): + type: Literal["other"] + fileError: str + +FileError = ( + FileError_auth + | FileError_blocked + | FileError_noFile + | FileError_relay + | FileError_other +) + +FileError_Tag = Literal["auth", "blocked", "noFile", "relay", "other"] + +class FileErrorType_NOT_APPROVED(TypedDict): + type: Literal["NOT_APPROVED"] + +class FileErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class FileErrorType_REDIRECT(TypedDict): + type: Literal["REDIRECT"] + redirectError: str + +class FileErrorType_FILE_IO(TypedDict): + type: Literal["FILE_IO"] + fileIOError: str + +class FileErrorType_NO_FILE(TypedDict): + type: Literal["NO_FILE"] + +FileErrorType = ( + FileErrorType_NOT_APPROVED + | FileErrorType_SIZE + | FileErrorType_REDIRECT + | FileErrorType_FILE_IO + | FileErrorType_NO_FILE +) + +FileErrorType_Tag = Literal["NOT_APPROVED", "SIZE", "REDIRECT", "FILE_IO", "NO_FILE"] + +class FileInvitation(TypedDict): + fileName: str + fileSize: int # int64 + fileDigest: NotRequired[str] + fileConnReq: NotRequired[str] + fileInline: NotRequired["InlineFileMode"] + fileDescr: NotRequired["FileDescr"] + +FileProtocol = Literal["SMP", "XFTP", "LOCAL"] + +FileStatus = Literal["new", "accepted", "connected", "complete", "cancelled"] + +class FileTransferMeta(TypedDict): + fileId: int # int64 + xftpSndFile: NotRequired["XFTPSndFile"] + xftpRedirectFor: NotRequired[int] # int64 + fileName: str + filePath: str + fileSize: int # int64 + fileInline: NotRequired["InlineFileMode"] + chunkSize: int # int64 + cancelled: bool + +class Format_bold(TypedDict): + type: Literal["bold"] + +class Format_italic(TypedDict): + type: Literal["italic"] + +class Format_strikeThrough(TypedDict): + type: Literal["strikeThrough"] + +class Format_snippet(TypedDict): + type: Literal["snippet"] + +class Format_secret(TypedDict): + type: Literal["secret"] + +class Format_small(TypedDict): + type: Literal["small"] + +class Format_colored(TypedDict): + type: Literal["colored"] + color: "Color" + +class Format_uri(TypedDict): + type: Literal["uri"] + +class Format_hyperLink(TypedDict): + type: Literal["hyperLink"] + showText: NotRequired[str] + linkUri: str + +class Format_simplexLink(TypedDict): + type: Literal["simplexLink"] + showText: NotRequired[str] + linkType: "SimplexLinkType" + simplexUri: str + smpHosts: list[str] # non-empty + +class Format_command(TypedDict): + type: Literal["command"] + commandStr: str + +class Format_mention(TypedDict): + type: Literal["mention"] + memberName: str + +class Format_email(TypedDict): + type: Literal["email"] + +class Format_phone(TypedDict): + type: Literal["phone"] + +Format = ( + Format_bold + | Format_italic + | Format_strikeThrough + | Format_snippet + | Format_secret + | Format_small + | Format_colored + | Format_uri + | Format_hyperLink + | Format_simplexLink + | Format_command + | Format_mention + | Format_email + | Format_phone +) + +Format_Tag = Literal["bold", "italic", "strikeThrough", "snippet", "secret", "small", "colored", "uri", "hyperLink", "simplexLink", "command", "mention", "email", "phone"] + +class FormattedText(TypedDict): + format: NotRequired["Format"] + text: str + +class FullGroupPreferences(TypedDict): + timedMessages: "TimedMessagesGroupPreference" + directMessages: "RoleGroupPreference" + fullDelete: "GroupPreference" + reactions: "GroupPreference" + voice: "RoleGroupPreference" + files: "RoleGroupPreference" + simplexLinks: "RoleGroupPreference" + reports: "GroupPreference" + history: "GroupPreference" + support: "SupportGroupPreference" + sessions: "RoleGroupPreference" + comments: "CommentsGroupPreference" + commands: list["ChatBotCommand"] + +class FullPreferences(TypedDict): + timedMessages: "TimedMessagesPreference" + fullDelete: "SimplePreference" + reactions: "SimplePreference" + voice: "SimplePreference" + files: "SimplePreference" + calls: "SimplePreference" + sessions: "SimplePreference" + commands: list["ChatBotCommand"] + +class Group(TypedDict): + groupInfo: "GroupInfo" + members: list["GroupMember"] + +class GroupChatScope_memberSupport(TypedDict): + type: Literal["memberSupport"] + groupMemberId_: NotRequired[int] # int64 + +GroupChatScope = GroupChatScope_memberSupport + +GroupChatScope_Tag = Literal["memberSupport"] + + +def GroupChatScope_cmd_string(self: GroupChatScope) -> str: + return '(_support' + ((':' + str(self.get('groupMemberId_'))) if self.get('groupMemberId_') is not None else '') + ')' # type: ignore[typeddict-item] + +class GroupChatScopeInfo_memberSupport(TypedDict): + type: Literal["memberSupport"] + groupMember_: NotRequired["GroupMember"] + +GroupChatScopeInfo = GroupChatScopeInfo_memberSupport + +GroupChatScopeInfo_Tag = Literal["memberSupport"] + +class GroupDirectInvitation(TypedDict): + groupDirectInvLink: str + fromGroupId_: NotRequired[int] # int64 + fromGroupMemberId_: NotRequired[int] # int64 + fromGroupMemberConnId_: NotRequired[int] # int64 + groupDirectInvStartedConnection: bool + +GroupFeature = Literal["timedMessages", "directMessages", "fullDelete", "reactions", "voice", "files", "simplexLinks", "reports", "history", "support", "sessions", "comments"] + +GroupFeatureEnabled = Literal["on", "off"] + +class GroupInfo(TypedDict): + groupId: int # int64 + useRelays: bool + relayOwnStatus: NotRequired["RelayStatus"] + localDisplayName: str + groupProfile: "GroupProfile" + localAlias: str + businessChat: NotRequired["BusinessChatInfo"] + fullGroupPreferences: "FullGroupPreferences" + membership: "GroupMember" + chatSettings: "ChatSettings" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: NotRequired[str] # ISO-8601 timestamp + userMemberProfileSentAt: NotRequired[str] # ISO-8601 timestamp + preparedGroup: NotRequired["PreparedGroup"] + chatTags: list[int] # int64 + chatItemTTL: NotRequired[int] # int64 + uiThemes: NotRequired["UIThemeEntityOverrides"] + customData: NotRequired[dict[str, object]] + groupSummary: "GroupSummary" + membersRequireAttention: int # int + viaGroupLinkUri: NotRequired[str] + groupKeys: NotRequired["GroupKeys"] + +class GroupKeys(TypedDict): + publicGroupId: str + groupRootKey: "GroupRootKey" + memberPrivKey: str + +class GroupLink(TypedDict): + userContactLinkId: int # int64 + connLinkContact: "CreatedConnLink" + shortLinkDataSet: bool + shortLinkLargeDataSet: bool + groupLinkId: str + acceptMemberRole: "GroupMemberRole" + +class GroupLinkOwner(TypedDict): + memberId: str + memberKey: str + +class GroupLinkPlan_ok(TypedDict): + type: Literal["ok"] + groupSLinkInfo_: NotRequired["GroupShortLinkInfo"] + groupSLinkData_: NotRequired["GroupShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class GroupLinkPlan_ownLink(TypedDict): + type: Literal["ownLink"] + groupInfo: "GroupInfo" + +class GroupLinkPlan_connectingConfirmReconnect(TypedDict): + type: Literal["connectingConfirmReconnect"] + +class GroupLinkPlan_connectingProhibit(TypedDict): + type: Literal["connectingProhibit"] + groupInfo_: NotRequired["GroupInfo"] + +class GroupLinkPlan_known(TypedDict): + type: Literal["known"] + groupInfo: "GroupInfo" + groupUpdated: bool + ownerVerification: NotRequired["OwnerVerification"] + linkOwners: list["GroupLinkOwner"] + +class GroupLinkPlan_noRelays(TypedDict): + type: Literal["noRelays"] + groupSLinkData_: NotRequired["GroupShortLinkData"] + +GroupLinkPlan = ( + GroupLinkPlan_ok + | GroupLinkPlan_ownLink + | GroupLinkPlan_connectingConfirmReconnect + | GroupLinkPlan_connectingProhibit + | GroupLinkPlan_known + | GroupLinkPlan_noRelays +) + +GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays"] + +class GroupMember(TypedDict): + groupMemberId: int # int64 + groupId: int # int64 + indexInGroup: int # int64 + memberId: str + memberRole: "GroupMemberRole" + memberCategory: "GroupMemberCategory" + memberStatus: "GroupMemberStatus" + memberSettings: "GroupMemberSettings" + blockedByAdmin: bool + invitedBy: "InvitedBy" + invitedByGroupMemberId: NotRequired[int] # int64 + localDisplayName: str + memberProfile: "LocalProfile" + memberContactId: NotRequired[int] # int64 + memberContactProfileId: int # int64 + activeConn: NotRequired["Connection"] + memberChatVRange: "VersionRange" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + supportChat: NotRequired["GroupSupportChat"] + memberPubKey: NotRequired[str] + relayLink: NotRequired[str] + +class GroupMemberAdmission(TypedDict): + review: NotRequired["MemberCriteria"] + +GroupMemberCategory = Literal["user", "invitee", "host", "pre", "post"] + +class GroupMemberRef(TypedDict): + groupMemberId: int # int64 + profile: "Profile" + +GroupMemberRole = Literal["relay", "observer", "author", "member", "moderator", "admin", "owner"] + +class GroupMemberSettings(TypedDict): + showMessages: bool + +GroupMemberStatus = Literal["rejected", "removed", "left", "deleted", "unknown", "invited", "pending_approval", "pending_review", "introduced", "intro-inv", "accepted", "announced", "connected", "complete", "creator"] + +class GroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + +class GroupPreferences(TypedDict): + timedMessages: NotRequired["TimedMessagesGroupPreference"] + directMessages: NotRequired["RoleGroupPreference"] + fullDelete: NotRequired["GroupPreference"] + reactions: NotRequired["GroupPreference"] + voice: NotRequired["RoleGroupPreference"] + files: NotRequired["RoleGroupPreference"] + simplexLinks: NotRequired["RoleGroupPreference"] + reports: NotRequired["GroupPreference"] + history: NotRequired["GroupPreference"] + support: NotRequired["SupportGroupPreference"] + sessions: NotRequired["RoleGroupPreference"] + comments: NotRequired["CommentsGroupPreference"] + commands: NotRequired[list["ChatBotCommand"]] + +class GroupProfile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + description: NotRequired[str] + image: NotRequired[str] + publicGroup: NotRequired["PublicGroupProfile"] + groupPreferences: NotRequired["GroupPreferences"] + memberAdmission: NotRequired["GroupMemberAdmission"] + +class GroupRelay(TypedDict): + groupRelayId: int # int64 + groupMemberId: int # int64 + userChatRelay: "UserChatRelay" + relayStatus: "RelayStatus" + relayLink: NotRequired[str] + +class GroupRootKey_private(TypedDict): + type: Literal["private"] + rootPrivKey: str + +class GroupRootKey_public(TypedDict): + type: Literal["public"] + rootPubKey: str + +GroupRootKey = GroupRootKey_private | GroupRootKey_public + +GroupRootKey_Tag = Literal["private", "public"] + +class GroupShortLinkData(TypedDict): + groupProfile: "GroupProfile" + publicGroupData: NotRequired["PublicGroupData"] + +class GroupShortLinkInfo(TypedDict): + direct: bool + groupRelays: list[str] + publicGroupId: NotRequired[str] + +class GroupSummary(TypedDict): + currentMembers: int # int64 + publicMemberCount: NotRequired[int] # int64 + +class GroupSupportChat(TypedDict): + chatTs: str # ISO-8601 timestamp + unread: int # int64 + memberAttention: int # int64 + mentions: int # int64 + lastMsgFromMemberTs: NotRequired[str] # ISO-8601 timestamp + +GroupType = Literal["channel", "group"] + +HandshakeError = Literal["PARSE", "IDENTITY", "BAD_AUTH", "BAD_SERVICE"] + +InlineFileMode = Literal["offer", "sent"] + +class InvitationLinkPlan_ok(TypedDict): + type: Literal["ok"] + contactSLinkData_: NotRequired["ContactShortLinkData"] + ownerVerification: NotRequired["OwnerVerification"] + +class InvitationLinkPlan_ownLink(TypedDict): + type: Literal["ownLink"] + +class InvitationLinkPlan_connecting(TypedDict): + type: Literal["connecting"] + contact_: NotRequired["Contact"] + +class InvitationLinkPlan_known(TypedDict): + type: Literal["known"] + contact: "Contact" + +InvitationLinkPlan = ( + InvitationLinkPlan_ok + | InvitationLinkPlan_ownLink + | InvitationLinkPlan_connecting + | InvitationLinkPlan_known +) + +InvitationLinkPlan_Tag = Literal["ok", "ownLink", "connecting", "known"] + +class InvitedBy_contact(TypedDict): + type: Literal["contact"] + byContactId: int # int64 + +class InvitedBy_user(TypedDict): + type: Literal["user"] + +class InvitedBy_unknown(TypedDict): + type: Literal["unknown"] + +InvitedBy = InvitedBy_contact | InvitedBy_user | InvitedBy_unknown + +InvitedBy_Tag = Literal["contact", "user", "unknown"] + +class LinkContent_page(TypedDict): + type: Literal["page"] + +class LinkContent_image(TypedDict): + type: Literal["image"] + +class LinkContent_video(TypedDict): + type: Literal["video"] + duration: NotRequired[int] # int + +class LinkContent_unknown(TypedDict): + type: Literal["unknown"] + tag: str + json: dict[str, object] + +LinkContent = LinkContent_page | LinkContent_image | LinkContent_video | LinkContent_unknown + +LinkContent_Tag = Literal["page", "image", "video", "unknown"] + +class LinkOwnerSig(TypedDict): + ownerId: NotRequired[str] + chatBinding: str + ownerSig: str + +class LinkPreview(TypedDict): + uri: str + title: str + description: str + image: str + content: NotRequired["LinkContent"] + +class LocalProfile(TypedDict): + profileId: int # int64 + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + contactLink: NotRequired[str] + preferences: NotRequired["Preferences"] + peerType: NotRequired["ChatPeerType"] + localAlias: str + +MemberCriteria = Literal["all"] + +# Connection link sent in a message - only short links are allowed. + +class MsgChatLink_contact(TypedDict): + type: Literal["contact"] + connLink: str + profile: "Profile" + business: bool + +class MsgChatLink_invitation(TypedDict): + type: Literal["invitation"] + invLink: str + profile: "Profile" + +class MsgChatLink_group(TypedDict): + type: Literal["group"] + connLink: str + groupProfile: "GroupProfile" + +MsgChatLink = MsgChatLink_contact | MsgChatLink_invitation | MsgChatLink_group + +MsgChatLink_Tag = Literal["contact", "invitation", "group"] + +class MsgContent_text(TypedDict): + type: Literal["text"] + text: str + +class MsgContent_link(TypedDict): + type: Literal["link"] + text: str + preview: "LinkPreview" + +class MsgContent_image(TypedDict): + type: Literal["image"] + text: str + image: str + +class MsgContent_video(TypedDict): + type: Literal["video"] + text: str + image: str + duration: int # int + +class MsgContent_voice(TypedDict): + type: Literal["voice"] + text: str + duration: int # int + +class MsgContent_file(TypedDict): + type: Literal["file"] + text: str + +class MsgContent_report(TypedDict): + type: Literal["report"] + text: str + reason: "ReportReason" + +class MsgContent_chat(TypedDict): + type: Literal["chat"] + text: str + chatLink: "MsgChatLink" + ownerSig: NotRequired["LinkOwnerSig"] + +class MsgContent_unknown(TypedDict): + type: Literal["unknown"] + tag: str + text: str + json: dict[str, object] + +MsgContent = ( + MsgContent_text + | MsgContent_link + | MsgContent_image + | MsgContent_video + | MsgContent_voice + | MsgContent_file + | MsgContent_report + | MsgContent_chat + | MsgContent_unknown +) + +MsgContent_Tag = Literal["text", "link", "image", "video", "voice", "file", "report", "chat", "unknown"] + +MsgDecryptError = Literal["ratchetHeader", "tooManySkipped", "ratchetEarlier", "other", "ratchetSync"] + +MsgDirection = Literal["rcv", "snd"] + +class MsgErrorType_msgSkipped(TypedDict): + type: Literal["msgSkipped"] + fromMsgId: int # int64 + toMsgId: int # int64 + +class MsgErrorType_msgBadId(TypedDict): + type: Literal["msgBadId"] + msgId: int # int64 + +class MsgErrorType_msgBadHash(TypedDict): + type: Literal["msgBadHash"] + +class MsgErrorType_msgDuplicate(TypedDict): + type: Literal["msgDuplicate"] + +MsgErrorType = ( + MsgErrorType_msgSkipped + | MsgErrorType_msgBadId + | MsgErrorType_msgBadHash + | MsgErrorType_msgDuplicate +) + +MsgErrorType_Tag = Literal["msgSkipped", "msgBadId", "msgBadHash", "msgDuplicate"] + +MsgFilter = Literal["none", "all", "mentions"] + +class MsgReaction_emoji(TypedDict): + type: Literal["emoji"] + emoji: str + +class MsgReaction_unknown(TypedDict): + type: Literal["unknown"] + tag: str + json: dict[str, object] + +MsgReaction = MsgReaction_emoji | MsgReaction_unknown + +MsgReaction_Tag = Literal["emoji", "unknown"] + +MsgReceiptStatus = Literal["ok", "badMsgHash"] + +MsgSigStatus = Literal["verified", "signedNoKey"] + +class NetworkError_connectError(TypedDict): + type: Literal["connectError"] + connectError: str + +class NetworkError_tLSError(TypedDict): + type: Literal["tLSError"] + tlsError: str + +class NetworkError_unknownCAError(TypedDict): + type: Literal["unknownCAError"] + +class NetworkError_failedError(TypedDict): + type: Literal["failedError"] + +class NetworkError_timeoutError(TypedDict): + type: Literal["timeoutError"] + +class NetworkError_subscribeError(TypedDict): + type: Literal["subscribeError"] + subscribeError: str + +NetworkError = ( + NetworkError_connectError + | NetworkError_tLSError + | NetworkError_unknownCAError + | NetworkError_failedError + | NetworkError_timeoutError + | NetworkError_subscribeError +) + +NetworkError_Tag = Literal["connectError", "tLSError", "unknownCAError", "failedError", "timeoutError", "subscribeError"] + +class NewUser(TypedDict): + profile: NotRequired["Profile"] + pastTimestamp: bool + userChatRelay: bool + +class NoteFolder(TypedDict): + noteFolderId: int # int64 + userId: int # int64 + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + chatTs: str # ISO-8601 timestamp + favorite: bool + unread: bool + +class OwnerVerification_verified(TypedDict): + type: Literal["verified"] + +class OwnerVerification_failed(TypedDict): + type: Literal["failed"] + reason: str + +OwnerVerification = OwnerVerification_verified | OwnerVerification_failed + +OwnerVerification_Tag = Literal["verified", "failed"] + +class PaginationByTime_last(TypedDict): + type: Literal["last"] + count: int # int + +PaginationByTime = PaginationByTime_last + +PaginationByTime_Tag = Literal["last"] + + +def PaginationByTime_cmd_string(self: PaginationByTime) -> str: + return 'count=' + str(self['count']) # type: ignore[typeddict-item] + +class PendingContactConnection(TypedDict): + pccConnId: int # int64 + pccAgentConnId: str + pccConnStatus: "ConnStatus" + viaContactUri: bool + viaUserContactLink: NotRequired[int] # int64 + groupLinkId: NotRequired[str] + customUserProfileId: NotRequired[int] # int64 + connLinkInv: NotRequired["CreatedConnLink"] + localAlias: str + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + +class PrefEnabled(TypedDict): + forUser: bool + forContact: bool + +class Preferences(TypedDict): + timedMessages: NotRequired["TimedMessagesPreference"] + fullDelete: NotRequired["SimplePreference"] + reactions: NotRequired["SimplePreference"] + voice: NotRequired["SimplePreference"] + files: NotRequired["SimplePreference"] + calls: NotRequired["SimplePreference"] + sessions: NotRequired["SimplePreference"] + commands: NotRequired[list["ChatBotCommand"]] + +class PreparedContact(TypedDict): + connLinkToConnect: "CreatedConnLink" + uiConnLinkType: "ConnectionMode" + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class PreparedGroup(TypedDict): + connLinkToConnect: "CreatedConnLink" + connLinkPreparedConnection: bool + connLinkStartedConnection: bool + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class Profile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + contactLink: NotRequired[str] + preferences: NotRequired["Preferences"] + peerType: NotRequired["ChatPeerType"] + +class ProxyClientError_protocolError(TypedDict): + type: Literal["protocolError"] + protocolErr: "ErrorType" + +class ProxyClientError_unexpectedResponse(TypedDict): + type: Literal["unexpectedResponse"] + responseStr: str + +class ProxyClientError_responseError(TypedDict): + type: Literal["responseError"] + responseErr: "ErrorType" + +ProxyClientError = ( + ProxyClientError_protocolError + | ProxyClientError_unexpectedResponse + | ProxyClientError_responseError +) + +ProxyClientError_Tag = Literal["protocolError", "unexpectedResponse", "responseError"] + +class ProxyError_PROTOCOL(TypedDict): + type: Literal["PROTOCOL"] + protocolErr: "ErrorType" + +class ProxyError_BROKER(TypedDict): + type: Literal["BROKER"] + brokerErr: "BrokerErrorType" + +class ProxyError_BASIC_AUTH(TypedDict): + type: Literal["BASIC_AUTH"] + +class ProxyError_NO_SESSION(TypedDict): + type: Literal["NO_SESSION"] + +ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | ProxyError_NO_SESSION + +ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"] + +class PublicGroupData(TypedDict): + publicMemberCount: int # int64 + +class PublicGroupProfile(TypedDict): + groupType: "GroupType" + groupLink: str + publicGroupId: str + +class RCErrorType_internal(TypedDict): + type: Literal["internal"] + internalErr: str + +class RCErrorType_identity(TypedDict): + type: Literal["identity"] + +class RCErrorType_noLocalAddress(TypedDict): + type: Literal["noLocalAddress"] + +class RCErrorType_newController(TypedDict): + type: Literal["newController"] + +class RCErrorType_notDiscovered(TypedDict): + type: Literal["notDiscovered"] + +class RCErrorType_tLSStartFailed(TypedDict): + type: Literal["tLSStartFailed"] + +class RCErrorType_exception(TypedDict): + type: Literal["exception"] + exception: str + +class RCErrorType_ctrlAuth(TypedDict): + type: Literal["ctrlAuth"] + +class RCErrorType_ctrlNotFound(TypedDict): + type: Literal["ctrlNotFound"] + +class RCErrorType_ctrlError(TypedDict): + type: Literal["ctrlError"] + ctrlErr: str + +class RCErrorType_invitation(TypedDict): + type: Literal["invitation"] + +class RCErrorType_version(TypedDict): + type: Literal["version"] + +class RCErrorType_encrypt(TypedDict): + type: Literal["encrypt"] + +class RCErrorType_decrypt(TypedDict): + type: Literal["decrypt"] + +class RCErrorType_blockSize(TypedDict): + type: Literal["blockSize"] + +class RCErrorType_syntax(TypedDict): + type: Literal["syntax"] + syntaxErr: str + +RCErrorType = ( + RCErrorType_internal + | RCErrorType_identity + | RCErrorType_noLocalAddress + | RCErrorType_newController + | RCErrorType_notDiscovered + | RCErrorType_tLSStartFailed + | RCErrorType_exception + | RCErrorType_ctrlAuth + | RCErrorType_ctrlNotFound + | RCErrorType_ctrlError + | RCErrorType_invitation + | RCErrorType_version + | RCErrorType_encrypt + | RCErrorType_decrypt + | RCErrorType_blockSize + | RCErrorType_syntax +) + +RCErrorType_Tag = Literal["internal", "identity", "noLocalAddress", "newController", "notDiscovered", "tLSStartFailed", "exception", "ctrlAuth", "ctrlNotFound", "ctrlError", "invitation", "version", "encrypt", "decrypt", "blockSize", "syntax"] + +RatchetSyncState = Literal["ok", "allowed", "required", "started", "agreed"] + +class RcvConnEvent_switchQueue(TypedDict): + type: Literal["switchQueue"] + phase: "SwitchPhase" + +class RcvConnEvent_ratchetSync(TypedDict): + type: Literal["ratchetSync"] + syncStatus: "RatchetSyncState" + +class RcvConnEvent_verificationCodeReset(TypedDict): + type: Literal["verificationCodeReset"] + +class RcvConnEvent_pqEnabled(TypedDict): + type: Literal["pqEnabled"] + enabled: bool + +RcvConnEvent = ( + RcvConnEvent_switchQueue + | RcvConnEvent_ratchetSync + | RcvConnEvent_verificationCodeReset + | RcvConnEvent_pqEnabled +) + +RcvConnEvent_Tag = Literal["switchQueue", "ratchetSync", "verificationCodeReset", "pqEnabled"] + +class RcvDirectEvent_contactDeleted(TypedDict): + type: Literal["contactDeleted"] + +class RcvDirectEvent_profileUpdated(TypedDict): + type: Literal["profileUpdated"] + fromProfile: "Profile" + toProfile: "Profile" + +class RcvDirectEvent_groupInvLinkReceived(TypedDict): + type: Literal["groupInvLinkReceived"] + groupProfile: "GroupProfile" + +RcvDirectEvent = ( + RcvDirectEvent_contactDeleted + | RcvDirectEvent_profileUpdated + | RcvDirectEvent_groupInvLinkReceived +) + +RcvDirectEvent_Tag = Literal["contactDeleted", "profileUpdated", "groupInvLinkReceived"] + +class RcvFileDescr(TypedDict): + fileDescrId: int # int64 + fileDescrText: str + fileDescrPartNo: int # int + fileDescrComplete: bool + +class RcvFileStatus_new(TypedDict): + type: Literal["new"] + +class RcvFileStatus_accepted(TypedDict): + type: Literal["accepted"] + filePath: str + +class RcvFileStatus_connected(TypedDict): + type: Literal["connected"] + filePath: str + +class RcvFileStatus_complete(TypedDict): + type: Literal["complete"] + filePath: str + +class RcvFileStatus_cancelled(TypedDict): + type: Literal["cancelled"] + filePath_: NotRequired[str] + +RcvFileStatus = ( + RcvFileStatus_new + | RcvFileStatus_accepted + | RcvFileStatus_connected + | RcvFileStatus_complete + | RcvFileStatus_cancelled +) + +RcvFileStatus_Tag = Literal["new", "accepted", "connected", "complete", "cancelled"] + +class RcvFileTransfer(TypedDict): + fileId: int # int64 + xftpRcvFile: NotRequired["XFTPRcvFile"] + fileInvitation: "FileInvitation" + fileStatus: "RcvFileStatus" + rcvFileInline: NotRequired["InlineFileMode"] + senderDisplayName: str + chunkSize: int # int64 + cancelled: bool + grpMemberId: NotRequired[int] # int64 + cryptoArgs: NotRequired["CryptoFileArgs"] + +class RcvGroupEvent_memberAdded(TypedDict): + type: Literal["memberAdded"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_memberConnected(TypedDict): + type: Literal["memberConnected"] + +class RcvGroupEvent_memberAccepted(TypedDict): + type: Literal["memberAccepted"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_userAccepted(TypedDict): + type: Literal["userAccepted"] + +class RcvGroupEvent_memberLeft(TypedDict): + type: Literal["memberLeft"] + +class RcvGroupEvent_memberRole(TypedDict): + type: Literal["memberRole"] + groupMemberId: int # int64 + profile: "Profile" + role: "GroupMemberRole" + +class RcvGroupEvent_memberBlocked(TypedDict): + type: Literal["memberBlocked"] + groupMemberId: int # int64 + profile: "Profile" + blocked: bool + +class RcvGroupEvent_userRole(TypedDict): + type: Literal["userRole"] + role: "GroupMemberRole" + +class RcvGroupEvent_memberDeleted(TypedDict): + type: Literal["memberDeleted"] + groupMemberId: int # int64 + profile: "Profile" + +class RcvGroupEvent_userDeleted(TypedDict): + type: Literal["userDeleted"] + +class RcvGroupEvent_groupDeleted(TypedDict): + type: Literal["groupDeleted"] + +class RcvGroupEvent_groupUpdated(TypedDict): + type: Literal["groupUpdated"] + groupProfile: "GroupProfile" + +class RcvGroupEvent_invitedViaGroupLink(TypedDict): + type: Literal["invitedViaGroupLink"] + +class RcvGroupEvent_memberCreatedContact(TypedDict): + type: Literal["memberCreatedContact"] + +class RcvGroupEvent_memberProfileUpdated(TypedDict): + type: Literal["memberProfileUpdated"] + fromProfile: "Profile" + toProfile: "Profile" + +class RcvGroupEvent_newMemberPendingReview(TypedDict): + type: Literal["newMemberPendingReview"] + +class RcvGroupEvent_msgBadSignature(TypedDict): + type: Literal["msgBadSignature"] + +RcvGroupEvent = ( + RcvGroupEvent_memberAdded + | RcvGroupEvent_memberConnected + | RcvGroupEvent_memberAccepted + | RcvGroupEvent_userAccepted + | RcvGroupEvent_memberLeft + | RcvGroupEvent_memberRole + | RcvGroupEvent_memberBlocked + | RcvGroupEvent_userRole + | RcvGroupEvent_memberDeleted + | RcvGroupEvent_userDeleted + | RcvGroupEvent_groupDeleted + | RcvGroupEvent_groupUpdated + | RcvGroupEvent_invitedViaGroupLink + | RcvGroupEvent_memberCreatedContact + | RcvGroupEvent_memberProfileUpdated + | RcvGroupEvent_newMemberPendingReview + | RcvGroupEvent_msgBadSignature +) + +RcvGroupEvent_Tag = Literal["memberAdded", "memberConnected", "memberAccepted", "userAccepted", "memberLeft", "memberRole", "memberBlocked", "userRole", "memberDeleted", "userDeleted", "groupDeleted", "groupUpdated", "invitedViaGroupLink", "memberCreatedContact", "memberProfileUpdated", "newMemberPendingReview", "msgBadSignature"] + +class RcvMsgError_dropped(TypedDict): + type: Literal["dropped"] + attempts: int # int + +class RcvMsgError_parseError(TypedDict): + type: Literal["parseError"] + parseError: str + +RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError + +RcvMsgError_Tag = Literal["dropped", "parseError"] + +class RelayProfile(TypedDict): + displayName: str + fullName: str + shortDescr: NotRequired[str] + image: NotRequired[str] + +RelayStatus = Literal["new", "invited", "accepted", "active", "inactive", "rejected"] + +ReportReason = Literal["spam", "content", "community", "profile", "other"] + +class RoleGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + role: NotRequired["GroupMemberRole"] + +class SMPAgentError_A_MESSAGE(TypedDict): + type: Literal["A_MESSAGE"] + +class SMPAgentError_A_PROHIBITED(TypedDict): + type: Literal["A_PROHIBITED"] + prohibitedErr: str + +class SMPAgentError_A_VERSION(TypedDict): + type: Literal["A_VERSION"] + +class SMPAgentError_A_LINK(TypedDict): + type: Literal["A_LINK"] + linkErr: str + +class SMPAgentError_A_CRYPTO(TypedDict): + type: Literal["A_CRYPTO"] + cryptoErr: "AgentCryptoError" + +class SMPAgentError_A_DUPLICATE(TypedDict): + type: Literal["A_DUPLICATE"] + droppedMsg_: NotRequired["DroppedMsg"] + +class SMPAgentError_A_QUEUE(TypedDict): + type: Literal["A_QUEUE"] + queueErr: str + +SMPAgentError = ( + SMPAgentError_A_MESSAGE + | SMPAgentError_A_PROHIBITED + | SMPAgentError_A_VERSION + | SMPAgentError_A_LINK + | SMPAgentError_A_CRYPTO + | SMPAgentError_A_DUPLICATE + | SMPAgentError_A_QUEUE +) + +SMPAgentError_Tag = Literal["A_MESSAGE", "A_PROHIBITED", "A_VERSION", "A_LINK", "A_CRYPTO", "A_DUPLICATE", "A_QUEUE"] + +class SecurityCode(TypedDict): + securityCode: str + verifiedAt: str # ISO-8601 timestamp + +class SimplePreference(TypedDict): + allow: "FeatureAllowed" + +SimplexLinkType = Literal["contact", "invitation", "group", "channel", "relay"] + +SndCIStatusProgress = Literal["partial", "complete"] + +class SndConnEvent_switchQueue(TypedDict): + type: Literal["switchQueue"] + phase: "SwitchPhase" + member: NotRequired["GroupMemberRef"] + +class SndConnEvent_ratchetSync(TypedDict): + type: Literal["ratchetSync"] + syncStatus: "RatchetSyncState" + member: NotRequired["GroupMemberRef"] + +class SndConnEvent_pqEnabled(TypedDict): + type: Literal["pqEnabled"] + enabled: bool + +SndConnEvent = SndConnEvent_switchQueue | SndConnEvent_ratchetSync | SndConnEvent_pqEnabled + +SndConnEvent_Tag = Literal["switchQueue", "ratchetSync", "pqEnabled"] + +class SndError_auth(TypedDict): + type: Literal["auth"] + +class SndError_quota(TypedDict): + type: Literal["quota"] + +class SndError_expired(TypedDict): + type: Literal["expired"] + +class SndError_relay(TypedDict): + type: Literal["relay"] + srvError: "SrvError" + +class SndError_proxy(TypedDict): + type: Literal["proxy"] + proxyServer: str + srvError: "SrvError" + +class SndError_proxyRelay(TypedDict): + type: Literal["proxyRelay"] + proxyServer: str + srvError: "SrvError" + +class SndError_other(TypedDict): + type: Literal["other"] + sndError: str + +SndError = ( + SndError_auth + | SndError_quota + | SndError_expired + | SndError_relay + | SndError_proxy + | SndError_proxyRelay + | SndError_other +) + +SndError_Tag = Literal["auth", "quota", "expired", "relay", "proxy", "proxyRelay", "other"] + +class SndFileTransfer(TypedDict): + fileId: int # int64 + fileName: str + filePath: str + fileSize: int # int64 + chunkSize: int # int64 + recipientDisplayName: str + connId: int # int64 + agentConnId: str + groupMemberId: NotRequired[int] # int64 + fileStatus: "FileStatus" + fileDescrId: NotRequired[int] # int64 + fileInline: NotRequired["InlineFileMode"] + +class SndGroupEvent_memberRole(TypedDict): + type: Literal["memberRole"] + groupMemberId: int # int64 + profile: "Profile" + role: "GroupMemberRole" + +class SndGroupEvent_memberBlocked(TypedDict): + type: Literal["memberBlocked"] + groupMemberId: int # int64 + profile: "Profile" + blocked: bool + +class SndGroupEvent_userRole(TypedDict): + type: Literal["userRole"] + role: "GroupMemberRole" + +class SndGroupEvent_memberDeleted(TypedDict): + type: Literal["memberDeleted"] + groupMemberId: int # int64 + profile: "Profile" + +class SndGroupEvent_userLeft(TypedDict): + type: Literal["userLeft"] + +class SndGroupEvent_groupUpdated(TypedDict): + type: Literal["groupUpdated"] + groupProfile: "GroupProfile" + +class SndGroupEvent_memberAccepted(TypedDict): + type: Literal["memberAccepted"] + groupMemberId: int # int64 + profile: "Profile" + +class SndGroupEvent_userPendingReview(TypedDict): + type: Literal["userPendingReview"] + +SndGroupEvent = ( + SndGroupEvent_memberRole + | SndGroupEvent_memberBlocked + | SndGroupEvent_userRole + | SndGroupEvent_memberDeleted + | SndGroupEvent_userLeft + | SndGroupEvent_groupUpdated + | SndGroupEvent_memberAccepted + | SndGroupEvent_userPendingReview +) + +SndGroupEvent_Tag = Literal["memberRole", "memberBlocked", "userRole", "memberDeleted", "userLeft", "groupUpdated", "memberAccepted", "userPendingReview"] + +class SrvError_host(TypedDict): + type: Literal["host"] + +class SrvError_version(TypedDict): + type: Literal["version"] + +class SrvError_other(TypedDict): + type: Literal["other"] + srvError: str + +SrvError = SrvError_host | SrvError_version | SrvError_other + +SrvError_Tag = Literal["host", "version", "other"] + +class StoreError_duplicateName(TypedDict): + type: Literal["duplicateName"] + +class StoreError_userNotFound(TypedDict): + type: Literal["userNotFound"] + userId: int # int64 + +class StoreError_relayUserNotFound(TypedDict): + type: Literal["relayUserNotFound"] + +class StoreError_userNotFoundByName(TypedDict): + type: Literal["userNotFoundByName"] + contactName: str + +class StoreError_userNotFoundByContactId(TypedDict): + type: Literal["userNotFoundByContactId"] + contactId: int # int64 + +class StoreError_userNotFoundByGroupId(TypedDict): + type: Literal["userNotFoundByGroupId"] + groupId: int # int64 + +class StoreError_userNotFoundByFileId(TypedDict): + type: Literal["userNotFoundByFileId"] + fileId: int # int64 + +class StoreError_userNotFoundByContactRequestId(TypedDict): + type: Literal["userNotFoundByContactRequestId"] + contactRequestId: int # int64 + +class StoreError_contactNotFound(TypedDict): + type: Literal["contactNotFound"] + contactId: int # int64 + +class StoreError_contactNotFoundByName(TypedDict): + type: Literal["contactNotFoundByName"] + contactName: str + +class StoreError_contactNotFoundByMemberId(TypedDict): + type: Literal["contactNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_contactNotReady(TypedDict): + type: Literal["contactNotReady"] + contactName: str + +class StoreError_duplicateContactLink(TypedDict): + type: Literal["duplicateContactLink"] + +class StoreError_userContactLinkNotFound(TypedDict): + type: Literal["userContactLinkNotFound"] + +class StoreError_contactRequestNotFound(TypedDict): + type: Literal["contactRequestNotFound"] + contactRequestId: int # int64 + +class StoreError_contactRequestNotFoundByName(TypedDict): + type: Literal["contactRequestNotFoundByName"] + contactName: str + +class StoreError_invalidContactRequestEntity(TypedDict): + type: Literal["invalidContactRequestEntity"] + contactRequestId: int # int64 + +class StoreError_invalidBusinessChatContactRequest(TypedDict): + type: Literal["invalidBusinessChatContactRequest"] + +class StoreError_groupNotFound(TypedDict): + type: Literal["groupNotFound"] + groupId: int # int64 + +class StoreError_groupNotFoundByName(TypedDict): + type: Literal["groupNotFoundByName"] + groupName: str + +class StoreError_groupMemberNameNotFound(TypedDict): + type: Literal["groupMemberNameNotFound"] + groupId: int # int64 + groupMemberName: str + +class StoreError_groupMemberNotFound(TypedDict): + type: Literal["groupMemberNotFound"] + groupMemberId: int # int64 + +class StoreError_groupMemberNotFoundByIndex(TypedDict): + type: Literal["groupMemberNotFoundByIndex"] + groupMemberIndex: int # int64 + +class StoreError_memberRelationsVectorNotFound(TypedDict): + type: Literal["memberRelationsVectorNotFound"] + groupMemberId: int # int64 + +class StoreError_groupHostMemberNotFound(TypedDict): + type: Literal["groupHostMemberNotFound"] + groupId: int # int64 + +class StoreError_groupMemberNotFoundByMemberId(TypedDict): + type: Literal["groupMemberNotFoundByMemberId"] + memberId: str + +class StoreError_memberContactGroupMemberNotFound(TypedDict): + type: Literal["memberContactGroupMemberNotFound"] + contactId: int # int64 + +class StoreError_invalidMemberRelationUpdate(TypedDict): + type: Literal["invalidMemberRelationUpdate"] + +class StoreError_groupWithoutUser(TypedDict): + type: Literal["groupWithoutUser"] + +class StoreError_duplicateGroupMember(TypedDict): + type: Literal["duplicateGroupMember"] + +class StoreError_duplicateMemberId(TypedDict): + type: Literal["duplicateMemberId"] + +class StoreError_groupAlreadyJoined(TypedDict): + type: Literal["groupAlreadyJoined"] + +class StoreError_groupInvitationNotFound(TypedDict): + type: Literal["groupInvitationNotFound"] + +class StoreError_noteFolderAlreadyExists(TypedDict): + type: Literal["noteFolderAlreadyExists"] + noteFolderId: int # int64 + +class StoreError_noteFolderNotFound(TypedDict): + type: Literal["noteFolderNotFound"] + noteFolderId: int # int64 + +class StoreError_userNoteFolderNotFound(TypedDict): + type: Literal["userNoteFolderNotFound"] + +class StoreError_sndFileNotFound(TypedDict): + type: Literal["sndFileNotFound"] + fileId: int # int64 + +class StoreError_sndFileInvalid(TypedDict): + type: Literal["sndFileInvalid"] + fileId: int # int64 + +class StoreError_rcvFileNotFound(TypedDict): + type: Literal["rcvFileNotFound"] + fileId: int # int64 + +class StoreError_rcvFileDescrNotFound(TypedDict): + type: Literal["rcvFileDescrNotFound"] + fileId: int # int64 + +class StoreError_fileNotFound(TypedDict): + type: Literal["fileNotFound"] + fileId: int # int64 + +class StoreError_rcvFileInvalid(TypedDict): + type: Literal["rcvFileInvalid"] + fileId: int # int64 + +class StoreError_rcvFileInvalidDescrPart(TypedDict): + type: Literal["rcvFileInvalidDescrPart"] + +class StoreError_localFileNoTransfer(TypedDict): + type: Literal["localFileNoTransfer"] + fileId: int # int64 + +class StoreError_sharedMsgIdNotFoundByFileId(TypedDict): + type: Literal["sharedMsgIdNotFoundByFileId"] + fileId: int # int64 + +class StoreError_fileIdNotFoundBySharedMsgId(TypedDict): + type: Literal["fileIdNotFoundBySharedMsgId"] + sharedMsgId: str + +class StoreError_sndFileNotFoundXFTP(TypedDict): + type: Literal["sndFileNotFoundXFTP"] + agentSndFileId: str + +class StoreError_rcvFileNotFoundXFTP(TypedDict): + type: Literal["rcvFileNotFoundXFTP"] + agentRcvFileId: str + +class StoreError_connectionNotFound(TypedDict): + type: Literal["connectionNotFound"] + agentConnId: str + +class StoreError_connectionNotFoundById(TypedDict): + type: Literal["connectionNotFoundById"] + connId: int # int64 + +class StoreError_connectionNotFoundByMemberId(TypedDict): + type: Literal["connectionNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_pendingConnectionNotFound(TypedDict): + type: Literal["pendingConnectionNotFound"] + connId: int # int64 + +class StoreError_uniqueID(TypedDict): + type: Literal["uniqueID"] + +class StoreError_largeMsg(TypedDict): + type: Literal["largeMsg"] + +class StoreError_internalError(TypedDict): + type: Literal["internalError"] + message: str + +class StoreError_dBException(TypedDict): + type: Literal["dBException"] + message: str + +class StoreError_dBBusyError(TypedDict): + type: Literal["dBBusyError"] + message: str + +class StoreError_badChatItem(TypedDict): + type: Literal["badChatItem"] + itemId: int # int64 + itemTs: NotRequired[str] # ISO-8601 timestamp + +class StoreError_chatItemNotFound(TypedDict): + type: Literal["chatItemNotFound"] + itemId: int # int64 + +class StoreError_chatItemNotFoundByText(TypedDict): + type: Literal["chatItemNotFoundByText"] + text: str + +class StoreError_chatItemSharedMsgIdNotFound(TypedDict): + type: Literal["chatItemSharedMsgIdNotFound"] + sharedMsgId: str + +class StoreError_chatItemNotFoundByFileId(TypedDict): + type: Literal["chatItemNotFoundByFileId"] + fileId: int # int64 + +class StoreError_chatItemNotFoundByContactId(TypedDict): + type: Literal["chatItemNotFoundByContactId"] + contactId: int # int64 + +class StoreError_chatItemNotFoundByGroupId(TypedDict): + type: Literal["chatItemNotFoundByGroupId"] + groupId: int # int64 + +class StoreError_profileNotFound(TypedDict): + type: Literal["profileNotFound"] + profileId: int # int64 + +class StoreError_duplicateGroupLink(TypedDict): + type: Literal["duplicateGroupLink"] + groupInfo: "GroupInfo" + +class StoreError_groupLinkNotFound(TypedDict): + type: Literal["groupLinkNotFound"] + groupInfo: "GroupInfo" + +class StoreError_hostMemberIdNotFound(TypedDict): + type: Literal["hostMemberIdNotFound"] + groupId: int # int64 + +class StoreError_contactNotFoundByFileId(TypedDict): + type: Literal["contactNotFoundByFileId"] + fileId: int # int64 + +class StoreError_noGroupSndStatus(TypedDict): + type: Literal["noGroupSndStatus"] + itemId: int # int64 + groupMemberId: int # int64 + +class StoreError_duplicateGroupMessage(TypedDict): + type: Literal["duplicateGroupMessage"] + groupId: int # int64 + sharedMsgId: str + authorGroupMemberId: NotRequired[int] # int64 + forwardedByGroupMemberId: NotRequired[int] # int64 + +class StoreError_remoteHostNotFound(TypedDict): + type: Literal["remoteHostNotFound"] + remoteHostId: int # int64 + +class StoreError_remoteHostUnknown(TypedDict): + type: Literal["remoteHostUnknown"] + +class StoreError_remoteHostDuplicateCA(TypedDict): + type: Literal["remoteHostDuplicateCA"] + +class StoreError_remoteCtrlNotFound(TypedDict): + type: Literal["remoteCtrlNotFound"] + remoteCtrlId: int # int64 + +class StoreError_remoteCtrlDuplicateCA(TypedDict): + type: Literal["remoteCtrlDuplicateCA"] + +class StoreError_prohibitedDeleteUser(TypedDict): + type: Literal["prohibitedDeleteUser"] + userId: int # int64 + contactId: int # int64 + +class StoreError_operatorNotFound(TypedDict): + type: Literal["operatorNotFound"] + serverOperatorId: int # int64 + +class StoreError_usageConditionsNotFound(TypedDict): + type: Literal["usageConditionsNotFound"] + +class StoreError_userChatRelayNotFound(TypedDict): + type: Literal["userChatRelayNotFound"] + chatRelayId: int # int64 + +class StoreError_groupRelayNotFound(TypedDict): + type: Literal["groupRelayNotFound"] + groupRelayId: int # int64 + +class StoreError_groupRelayNotFoundByMemberId(TypedDict): + type: Literal["groupRelayNotFoundByMemberId"] + groupMemberId: int # int64 + +class StoreError_invalidQuote(TypedDict): + type: Literal["invalidQuote"] + +class StoreError_invalidMention(TypedDict): + type: Literal["invalidMention"] + +class StoreError_invalidDeliveryTask(TypedDict): + type: Literal["invalidDeliveryTask"] + taskId: int # int64 + +class StoreError_deliveryTaskNotFound(TypedDict): + type: Literal["deliveryTaskNotFound"] + taskId: int # int64 + +class StoreError_invalidDeliveryJob(TypedDict): + type: Literal["invalidDeliveryJob"] + jobId: int # int64 + +class StoreError_deliveryJobNotFound(TypedDict): + type: Literal["deliveryJobNotFound"] + jobId: int # int64 + +class StoreError_workItemError(TypedDict): + type: Literal["workItemError"] + errContext: str + +StoreError = ( + StoreError_duplicateName + | StoreError_userNotFound + | StoreError_relayUserNotFound + | StoreError_userNotFoundByName + | StoreError_userNotFoundByContactId + | StoreError_userNotFoundByGroupId + | StoreError_userNotFoundByFileId + | StoreError_userNotFoundByContactRequestId + | StoreError_contactNotFound + | StoreError_contactNotFoundByName + | StoreError_contactNotFoundByMemberId + | StoreError_contactNotReady + | StoreError_duplicateContactLink + | StoreError_userContactLinkNotFound + | StoreError_contactRequestNotFound + | StoreError_contactRequestNotFoundByName + | StoreError_invalidContactRequestEntity + | StoreError_invalidBusinessChatContactRequest + | StoreError_groupNotFound + | StoreError_groupNotFoundByName + | StoreError_groupMemberNameNotFound + | StoreError_groupMemberNotFound + | StoreError_groupMemberNotFoundByIndex + | StoreError_memberRelationsVectorNotFound + | StoreError_groupHostMemberNotFound + | StoreError_groupMemberNotFoundByMemberId + | StoreError_memberContactGroupMemberNotFound + | StoreError_invalidMemberRelationUpdate + | StoreError_groupWithoutUser + | StoreError_duplicateGroupMember + | StoreError_duplicateMemberId + | StoreError_groupAlreadyJoined + | StoreError_groupInvitationNotFound + | StoreError_noteFolderAlreadyExists + | StoreError_noteFolderNotFound + | StoreError_userNoteFolderNotFound + | StoreError_sndFileNotFound + | StoreError_sndFileInvalid + | StoreError_rcvFileNotFound + | StoreError_rcvFileDescrNotFound + | StoreError_fileNotFound + | StoreError_rcvFileInvalid + | StoreError_rcvFileInvalidDescrPart + | StoreError_localFileNoTransfer + | StoreError_sharedMsgIdNotFoundByFileId + | StoreError_fileIdNotFoundBySharedMsgId + | StoreError_sndFileNotFoundXFTP + | StoreError_rcvFileNotFoundXFTP + | StoreError_connectionNotFound + | StoreError_connectionNotFoundById + | StoreError_connectionNotFoundByMemberId + | StoreError_pendingConnectionNotFound + | StoreError_uniqueID + | StoreError_largeMsg + | StoreError_internalError + | StoreError_dBException + | StoreError_dBBusyError + | StoreError_badChatItem + | StoreError_chatItemNotFound + | StoreError_chatItemNotFoundByText + | StoreError_chatItemSharedMsgIdNotFound + | StoreError_chatItemNotFoundByFileId + | StoreError_chatItemNotFoundByContactId + | StoreError_chatItemNotFoundByGroupId + | StoreError_profileNotFound + | StoreError_duplicateGroupLink + | StoreError_groupLinkNotFound + | StoreError_hostMemberIdNotFound + | StoreError_contactNotFoundByFileId + | StoreError_noGroupSndStatus + | StoreError_duplicateGroupMessage + | StoreError_remoteHostNotFound + | StoreError_remoteHostUnknown + | StoreError_remoteHostDuplicateCA + | StoreError_remoteCtrlNotFound + | StoreError_remoteCtrlDuplicateCA + | StoreError_prohibitedDeleteUser + | StoreError_operatorNotFound + | StoreError_usageConditionsNotFound + | StoreError_userChatRelayNotFound + | StoreError_groupRelayNotFound + | StoreError_groupRelayNotFoundByMemberId + | StoreError_invalidQuote + | StoreError_invalidMention + | StoreError_invalidDeliveryTask + | StoreError_deliveryTaskNotFound + | StoreError_invalidDeliveryJob + | StoreError_deliveryJobNotFound + | StoreError_workItemError +) + +StoreError_Tag = Literal["duplicateName", "userNotFound", "relayUserNotFound", "userNotFoundByName", "userNotFoundByContactId", "userNotFoundByGroupId", "userNotFoundByFileId", "userNotFoundByContactRequestId", "contactNotFound", "contactNotFoundByName", "contactNotFoundByMemberId", "contactNotReady", "duplicateContactLink", "userContactLinkNotFound", "contactRequestNotFound", "contactRequestNotFoundByName", "invalidContactRequestEntity", "invalidBusinessChatContactRequest", "groupNotFound", "groupNotFoundByName", "groupMemberNameNotFound", "groupMemberNotFound", "groupMemberNotFoundByIndex", "memberRelationsVectorNotFound", "groupHostMemberNotFound", "groupMemberNotFoundByMemberId", "memberContactGroupMemberNotFound", "invalidMemberRelationUpdate", "groupWithoutUser", "duplicateGroupMember", "duplicateMemberId", "groupAlreadyJoined", "groupInvitationNotFound", "noteFolderAlreadyExists", "noteFolderNotFound", "userNoteFolderNotFound", "sndFileNotFound", "sndFileInvalid", "rcvFileNotFound", "rcvFileDescrNotFound", "fileNotFound", "rcvFileInvalid", "rcvFileInvalidDescrPart", "localFileNoTransfer", "sharedMsgIdNotFoundByFileId", "fileIdNotFoundBySharedMsgId", "sndFileNotFoundXFTP", "rcvFileNotFoundXFTP", "connectionNotFound", "connectionNotFoundById", "connectionNotFoundByMemberId", "pendingConnectionNotFound", "uniqueID", "largeMsg", "internalError", "dBException", "dBBusyError", "badChatItem", "chatItemNotFound", "chatItemNotFoundByText", "chatItemSharedMsgIdNotFound", "chatItemNotFoundByFileId", "chatItemNotFoundByContactId", "chatItemNotFoundByGroupId", "profileNotFound", "duplicateGroupLink", "groupLinkNotFound", "hostMemberIdNotFound", "contactNotFoundByFileId", "noGroupSndStatus", "duplicateGroupMessage", "remoteHostNotFound", "remoteHostUnknown", "remoteHostDuplicateCA", "remoteCtrlNotFound", "remoteCtrlDuplicateCA", "prohibitedDeleteUser", "operatorNotFound", "usageConditionsNotFound", "userChatRelayNotFound", "groupRelayNotFound", "groupRelayNotFoundByMemberId", "invalidQuote", "invalidMention", "invalidDeliveryTask", "deliveryTaskNotFound", "invalidDeliveryJob", "deliveryJobNotFound", "workItemError"] + +class SubscriptionStatus_active(TypedDict): + type: Literal["active"] + +class SubscriptionStatus_pending(TypedDict): + type: Literal["pending"] + +class SubscriptionStatus_removed(TypedDict): + type: Literal["removed"] + subError: str + +class SubscriptionStatus_noSub(TypedDict): + type: Literal["noSub"] + +SubscriptionStatus = ( + SubscriptionStatus_active + | SubscriptionStatus_pending + | SubscriptionStatus_removed + | SubscriptionStatus_noSub +) + +SubscriptionStatus_Tag = Literal["active", "pending", "removed", "noSub"] + +class SupportGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + +SwitchPhase = Literal["started", "confirmed", "secured", "completed"] + +class TimedMessagesGroupPreference(TypedDict): + enable: "GroupFeatureEnabled" + ttl: NotRequired[int] # int + +class TimedMessagesPreference(TypedDict): + allow: "FeatureAllowed" + ttl: NotRequired[int] # int + +class TransportError_badBlock(TypedDict): + type: Literal["badBlock"] + +class TransportError_version(TypedDict): + type: Literal["version"] + +class TransportError_largeMsg(TypedDict): + type: Literal["largeMsg"] + +class TransportError_badSession(TypedDict): + type: Literal["badSession"] + +class TransportError_noServerAuth(TypedDict): + type: Literal["noServerAuth"] + +class TransportError_handshake(TypedDict): + type: Literal["handshake"] + handshakeErr: "HandshakeError" + +TransportError = ( + TransportError_badBlock + | TransportError_version + | TransportError_largeMsg + | TransportError_badSession + | TransportError_noServerAuth + | TransportError_handshake +) + +TransportError_Tag = Literal["badBlock", "version", "largeMsg", "badSession", "noServerAuth", "handshake"] + +UIColorMode = Literal["light", "dark"] + +class UIColors(TypedDict): + accent: NotRequired[str] + accentVariant: NotRequired[str] + secondary: NotRequired[str] + secondaryVariant: NotRequired[str] + background: NotRequired[str] + menus: NotRequired[str] + title: NotRequired[str] + accentVariant2: NotRequired[str] + sentMessage: NotRequired[str] + sentReply: NotRequired[str] + receivedMessage: NotRequired[str] + receivedReply: NotRequired[str] + +class UIThemeEntityOverride(TypedDict): + mode: "UIColorMode" + wallpaper: NotRequired["ChatWallpaper"] + colors: "UIColors" + +class UIThemeEntityOverrides(TypedDict): + light: NotRequired["UIThemeEntityOverride"] + dark: NotRequired["UIThemeEntityOverride"] + +class UpdatedMessage(TypedDict): + msgContent: "MsgContent" + mentions: dict[str, int] # str : int64 + +class User(TypedDict): + userId: int # int64 + agentUserId: int # int64 + userContactId: int # int64 + localDisplayName: str + profile: "LocalProfile" + fullPreferences: "FullPreferences" + activeUser: bool + activeOrder: int # int64 + viewPwdHash: NotRequired["UserPwdHash"] + showNtfs: bool + sendRcptsContacts: bool + sendRcptsSmallGroups: bool + autoAcceptMemberContacts: bool + userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp + uiThemes: NotRequired["UIThemeEntityOverrides"] + userChatRelay: bool + +class UserChatRelay(TypedDict): + chatRelayId: int # int64 + address: str + relayProfile: "RelayProfile" + domains: list[str] + preset: bool + tested: NotRequired[bool] + enabled: bool + deleted: bool + +class UserContact(TypedDict): + userContactLinkId: int # int64 + connReqContact: str + groupId: NotRequired[int] # int64 + +class UserContactLink(TypedDict): + userContactLinkId: int # int64 + connLinkContact: "CreatedConnLink" + shortLinkDataSet: bool + shortLinkLargeDataSet: bool + addressSettings: "AddressSettings" + +class UserContactRequest(TypedDict): + contactRequestId: int # int64 + agentInvitationId: str + contactId_: NotRequired[int] # int64 + businessGroupId_: NotRequired[int] # int64 + userContactLinkId_: NotRequired[int] # int64 + cReqChatVRange: "VersionRange" + localDisplayName: str + profileId: int # int64 + profile: "Profile" + createdAt: str # ISO-8601 timestamp + updatedAt: str # ISO-8601 timestamp + xContactId: NotRequired[str] + pqSupport: bool + welcomeSharedMsgId: NotRequired[str] + requestSharedMsgId: NotRequired[str] + +class UserInfo(TypedDict): + user: "User" + unreadCount: int # int + +class UserProfileUpdateSummary(TypedDict): + updateSuccesses: int # int + updateFailures: int # int + changedContacts: list["Contact"] + +class UserPwdHash(TypedDict): + hash: str + salt: str + +class VersionRange(TypedDict): + minVersion: int # int + maxVersion: int # int + +class XFTPErrorType_BLOCK(TypedDict): + type: Literal["BLOCK"] + +class XFTPErrorType_SESSION(TypedDict): + type: Literal["SESSION"] + +class XFTPErrorType_HANDSHAKE(TypedDict): + type: Literal["HANDSHAKE"] + +class XFTPErrorType_CMD(TypedDict): + type: Literal["CMD"] + cmdErr: "CommandError" + +class XFTPErrorType_AUTH(TypedDict): + type: Literal["AUTH"] + +class XFTPErrorType_BLOCKED(TypedDict): + type: Literal["BLOCKED"] + blockInfo: "BlockingInfo" + +class XFTPErrorType_SIZE(TypedDict): + type: Literal["SIZE"] + +class XFTPErrorType_QUOTA(TypedDict): + type: Literal["QUOTA"] + +class XFTPErrorType_DIGEST(TypedDict): + type: Literal["DIGEST"] + +class XFTPErrorType_CRYPTO(TypedDict): + type: Literal["CRYPTO"] + +class XFTPErrorType_NO_FILE(TypedDict): + type: Literal["NO_FILE"] + +class XFTPErrorType_HAS_FILE(TypedDict): + type: Literal["HAS_FILE"] + +class XFTPErrorType_FILE_IO(TypedDict): + type: Literal["FILE_IO"] + +class XFTPErrorType_TIMEOUT(TypedDict): + type: Literal["TIMEOUT"] + +class XFTPErrorType_INTERNAL(TypedDict): + type: Literal["INTERNAL"] + +class XFTPErrorType_DUPLICATE_(TypedDict): + type: Literal["DUPLICATE_"] + +XFTPErrorType = ( + XFTPErrorType_BLOCK + | XFTPErrorType_SESSION + | XFTPErrorType_HANDSHAKE + | XFTPErrorType_CMD + | XFTPErrorType_AUTH + | XFTPErrorType_BLOCKED + | XFTPErrorType_SIZE + | XFTPErrorType_QUOTA + | XFTPErrorType_DIGEST + | XFTPErrorType_CRYPTO + | XFTPErrorType_NO_FILE + | XFTPErrorType_HAS_FILE + | XFTPErrorType_FILE_IO + | XFTPErrorType_TIMEOUT + | XFTPErrorType_INTERNAL + | XFTPErrorType_DUPLICATE_ +) + +XFTPErrorType_Tag = Literal["BLOCK", "SESSION", "HANDSHAKE", "CMD", "AUTH", "BLOCKED", "SIZE", "QUOTA", "DIGEST", "CRYPTO", "NO_FILE", "HAS_FILE", "FILE_IO", "TIMEOUT", "INTERNAL", "DUPLICATE_"] + +class XFTPRcvFile(TypedDict): + rcvFileDescription: "RcvFileDescr" + agentRcvFileId: NotRequired[str] + agentRcvFileDeleted: bool + userApprovedRelays: bool + +class XFTPSndFile(TypedDict): + agentSndFileId: str + privateSndFileDescr: NotRequired[str] + agentSndFileDeleted: bool + cryptoArgs: NotRequired["CryptoFileArgs"] diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py new file mode 100644 index 0000000000..158bb72a79 --- /dev/null +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -0,0 +1,128 @@ +"""Reusable helpers for working with chat events, types, and message content. + +Mirrors the Node `util.ts` exports — provides the same primitives bot +authors typically reach for: command parsing, sender display strings, +message-content extraction, profile field cleanup, and ChatRef extraction +from a ChatInfo (handy when echoing into a different chat). +""" + +from __future__ import annotations + +import re +from typing import Any + +from .types import T + + +def chat_info_ref(c_info: T.ChatInfo) -> T.ChatRef | None: + """Extract a wire-format `ChatRef` from a `ChatInfo`. + + Returns `None` for non-chat infos (contactRequest, contactConnection) + that can't be the target of `api_send_messages`. For groups, the + `memberSupport` scope is forwarded so messages land in the right + thread; other scopes are dropped (matches Node `util.chatInfoRef`). + """ + t = c_info["type"] + if t == "direct": + return {"chatType": "direct", "chatId": c_info["contact"]["contactId"]} # type: ignore[index] + if t == "group": + ref: T.ChatRef = {"chatType": "group", "chatId": c_info["groupInfo"]["groupId"]} # type: ignore[index] + scope = c_info.get("groupChatScope") # type: ignore[union-attr] + if scope and scope.get("type") == "memberSupport": + member = scope.get("groupMember_") + ms_scope: T.GroupChatScope_memberSupport = {"type": "memberSupport"} + if member is not None: + ms_scope["groupMemberId_"] = member["groupMemberId"] + ref["chatScope"] = ms_scope + return ref + return None + + +def chat_info_name(c_info: T.ChatInfo) -> str: + """Display string for a chat: `@Alice`, `#GroupName`, `private notes`, etc.""" + t = c_info["type"] + if t == "direct": + return f"@{c_info['contact']['profile']['displayName']}" # type: ignore[index] + if t == "group": + scope = c_info.get("groupChatScope") # type: ignore[union-attr] + if scope and scope.get("type") == "memberSupport": + member = scope.get("groupMember_") + scope_name = f" {member['memberProfile']['displayName']}" if member else "" + return f"#{c_info['groupInfo']['groupProfile']['displayName']}(support{scope_name})" # type: ignore[index] + return f"#{c_info['groupInfo']['groupProfile']['displayName']}" # type: ignore[index] + if t == "local": + return "private notes" + if t == "contactRequest": + return f"request from @{c_info['contactRequest']['profile']['displayName']}" # type: ignore[index] + if t == "contactConnection": + alias = c_info["contactConnection"].get("localAlias") # type: ignore[index] + return f"pending connection ({alias})" if alias else "pending connection" + return f"<{t}>" + + +def sender_name(c_info: T.ChatInfo, chat_dir: T.CIDirection) -> str: + """Sender display: chat name plus group sender suffix when applicable.""" + base = chat_info_name(c_info) + if chat_dir["type"] == "groupRcv": + sender = chat_dir["groupMember"]["memberProfile"]["displayName"] # type: ignore[index] + return f"{base} @{sender}" + return base + + +def contact_address_str(link: T.CreatedConnLink) -> str: + """Prefer the short link, fall back to the full link.""" + return link.get("connShortLink") or link["connFullLink"] + + +def from_local_profile(local: T.LocalProfile) -> T.Profile: + """Strip extra LocalProfile fields (profileId, localAlias) and undefined values.""" + p: dict[str, Any] = {} + for key in ( + "displayName", + "fullName", + "shortDescr", + "image", + "contactLink", + "preferences", + "peerType", + ): + v = local.get(key) # type: ignore[misc] + if v is not None: + p[key] = v + return p # type: ignore[return-value] + + +def ci_content_text(chat_item: T.ChatItem) -> str | None: + """Extract the message text from a sent or received message item, if any.""" + content = chat_item["content"] + if content["type"] in ("sndMsgContent", "rcvMsgContent"): + msg = content.get("msgContent", {}) # type: ignore[union-attr] + return msg.get("text") + return None + + +_BOT_COMMAND_RE = re.compile(r"^/([^\s]+)(.*)$") + + +def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: + """Parse a `/keyword args...` slash-command from a chat item. + + Returns `(keyword, trimmed_params)` or `None` if the message isn't a + slash command. Mirrors Node `util.ciBotCommand` semantics. + """ + text = ci_content_text(chat_item) + if not text: + return None + text = text.strip() + m = _BOT_COMMAND_RE.match(text) + if not m: + return None + return m.group(1), m.group(2).strip() + + +def reaction_text(reaction: T.ACIReaction) -> str: + """Format an `ACIReaction` as the emoji character or tag string.""" + r = reaction["chatReaction"]["reaction"] # type: ignore[index] + if r["type"] == "emoji": + return r["emoji"] # type: ignore[index] + return r.get("tag", "") # type: ignore[union-attr] diff --git a/packages/simplex-chat-python/tests/test_bot_registration.py b/packages/simplex-chat-python/tests/test_bot_registration.py new file mode 100644 index 0000000000..f6f245c344 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_bot_registration.py @@ -0,0 +1,351 @@ +import pytest + +from simplex_chat import Bot, BotCommand, BotProfile, Client, Middleware, Profile, SqliteDb +from simplex_chat.api import ChatApi + + +def _bot() -> Bot: + return Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + +def test_decorator_registers_message_handler(): + bot = _bot() + + @bot.on_message(content_type="text") + async def h(msg): + pass + + assert len(bot._message_handlers) == 1 + + +def test_decorator_registers_command_handler(): + bot = _bot() + + @bot.on_command("ping") + async def h(msg, cmd): + pass + + assert len(bot._command_handlers) == 1 + assert bot._command_handlers[0][0] == ("ping",) + + +def test_decorator_registers_event_handler(): + bot = _bot() + + @bot.on_event("newChatItems") + async def h(evt): + pass + + assert "newChatItems" in bot._event_handlers + assert len(bot._event_handlers["newChatItems"]) == 1 + + +def test_api_property_raises_before_init(): + bot = _bot() + with pytest.raises(RuntimeError, match="not initialized"): + _ = bot.api + + +def test_command_keyword_tuple(): + bot = _bot() + + @bot.on_command(("p", "ping")) + async def h(msg, cmd): + pass + + assert bot._command_handlers[0][0] == ("p", "ping") + + +def test_bot_profile_to_wire_default(): + """Bot's profile wire-form sets peerType=bot and disables calls/voice.""" + bot = _bot() + p = bot._profile_to_wire() + assert p["displayName"] == "x" + assert p.get("peerType") == "bot" + prefs = p.get("preferences") or {} + assert prefs.get("calls", {}).get("allow") == "no" + assert prefs.get("voice", {}).get("allow") == "no" + assert prefs.get("files", {}).get("allow") == "no" # allow_files defaults to False + + +def test_bot_profile_to_wire_allow_files(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + allow_files=True, + ) + prefs = bot._profile_to_wire().get("preferences") or {} + assert prefs.get("files", {}).get("allow") == "yes" + + +def test_bot_profile_to_wire_with_commands(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + commands=[BotCommand(keyword="ping", label="Ping bot"), BotCommand("help", "Show help")], + ) + cmds = bot._profile_to_wire().get("preferences", {}).get("commands") or [] + assert len(cmds) == 2 + assert cmds[0] == {"type": "command", "keyword": "ping", "label": "Ping bot"} + assert cmds[1] == {"type": "command", "keyword": "help", "label": "Show help"} + + +def test_client_profile_to_wire_has_no_bot_extras(): + """Client's wire profile has no peerType=bot, no command list, no calls/voice prefs. + That's the whole point of having Client as a separate class.""" + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + p = c._profile_to_wire() + assert p["displayName"] == "x" + assert "peerType" not in p + assert "preferences" not in p + + +def test_bot_profile_alias_is_profile(): + """`BotProfile` is kept as an alias for backwards compatibility.""" + assert BotProfile is Profile + assert BotProfile(display_name="x") == Profile(display_name="x") + + +def test_dispatch_message_first_match_wins(): + """Two matching message handlers — only the first registered fires.""" + import asyncio + import re + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text", text=re.compile(r"^\d+$")) + async def number(_msg): + calls.append("number") + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("fallback") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "42"} + m.chat_item = { + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "42"}} + }, + "chatInfo": {"type": "direct"}, + } + m.text = "42" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["number"], f"expected only 'number' for '42', got {calls}" + + +def test_dispatch_message_falls_to_second_when_first_doesnt_match(): + """If the first handler's filter doesn't match, the second one fires.""" + import asyncio + import re + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text", text=re.compile(r"^\d+$")) + async def number(_msg): + calls.append("number") + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("fallback") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "hello"} + m.chat_item = { + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}} + }, + "chatInfo": {"type": "direct"}, + } + m.text = "hello" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["fallback"], f"expected 'fallback' for 'hello', got {calls}" + + +def test_register_log_handlers_idempotent(): + """Calling _register_log_handlers twice doesn't duplicate handlers.""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=True, + log_network=True, + ) + bot._register_log_handlers() + counts1 = {tag: len(hs) for tag, hs in bot._event_handlers.items()} + bot._register_log_handlers() + counts2 = {tag: len(hs) for tag, hs in bot._event_handlers.items()} + assert counts1 == counts2, f"handler count changed across calls: {counts1} -> {counts2}" + + +def test_default_error_handlers_always_registered(): + """messageError/chatError/chatErrors get default loggers regardless of opts.""" + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=False, + log_network=False, + ) + bot._register_log_handlers() + assert "messageError" in bot._event_handlers + assert "chatError" in bot._event_handlers + assert "chatErrors" in bot._event_handlers + + +def test_dispatch_command_suppresses_matching_message_handlers(): + """A `/help` message routed to a command handler must NOT also fire the + generic on_message text handler.""" + import asyncio + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("message") + + @bot.on_command("help") + async def help_cmd(_msg, _cmd): + calls.append("command") + + # Build a minimal Message-shaped object (handlers only inspect chat_item / text). + class M: + pass + + m = M() + m.content = {"type": "text", "text": "/help"} + m.chat_item = { + "chatItem": { + "content": { + "type": "rcvMsgContent", + "msgContent": {"type": "text", "text": "/help"}, + } + }, + "chatInfo": {"type": "direct"}, + } + m.text = "/help" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["command"], f"expected only 'command' to fire for /help, got {calls}" + + +def test_dispatch_unknown_command_falls_through_to_message_handlers(): + """A `/unknown` slash-command with no handler should still fire on_message.""" + import asyncio + + bot = _bot() + calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + calls.append("message") + + @bot.on_command("help") + async def help_cmd(_msg, _cmd): + calls.append("command") + + class M: + pass + + m = M() + m.content = {"type": "text", "text": "/unknown"} + m.chat_item = { + "chatItem": { + "content": { + "type": "rcvMsgContent", + "msgContent": {"type": "text", "text": "/unknown"}, + } + }, + "chatInfo": {"type": "direct"}, + } + m.text = "/unknown" + + asyncio.run(bot._dispatch_message(m)) # type: ignore[arg-type] + assert calls == ["message"], f"expected message fallback to fire for /unknown, got {calls}" + + +def test_chat_api_status_properties(): + """`initialized` and `started` reflect lifecycle state without invoking the FFI.""" + api = ChatApi(ctrl=12345) + assert api.initialized is True + assert api.started is False + assert api.ctrl == 12345 + # Simulate close: ctrl wiped, both properties false. + api._ctrl = None + api._started = False + assert api.initialized is False + assert api.started is False + with pytest.raises(RuntimeError, match="not initialized"): + _ = api.ctrl + + +def test_log_contacts_registers_handlers(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=True, + log_network=False, + ) + bot._register_log_handlers() + assert "contactConnected" in bot._event_handlers + assert "contactDeletedByContact" in bot._event_handlers + assert "hostConnected" not in bot._event_handlers + + +def test_log_network_registers_handlers(): + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + log_contacts=False, + log_network=True, + ) + bot._register_log_handlers() + assert "hostConnected" in bot._event_handlers + assert "hostDisconnected" in bot._event_handlers + assert "subscriptionStatus" in bot._event_handlers + assert "contactConnected" not in bot._event_handlers + + +def test_middleware_registration_and_invocation_order(): + """Middleware registered first wraps middleware registered later (outer first).""" + bot = _bot() + calls: list[str] = [] + + class Outer(Middleware): + async def __call__(self, handler, message, data): + calls.append("outer-before") + await handler(message, data) + calls.append("outer-after") + + class Inner(Middleware): + async def __call__(self, handler, message, data): + calls.append("inner-before") + await handler(message, data) + calls.append("inner-after") + + bot.use(Outer()) + bot.use(Inner()) + assert len(bot._middleware) == 2 + + async def handler(msg): + calls.append("handler") + + import asyncio + + asyncio.run(bot._invoke_with_middleware(handler, message=object())) # type: ignore[arg-type] + assert calls == [ + "outer-before", + "inner-before", + "handler", + "inner-after", + "outer-after", + ] diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py new file mode 100644 index 0000000000..7c01ae576a --- /dev/null +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -0,0 +1,616 @@ +"""Tests for Client class + connect_to / send_and_wait / events plumbing. + +Stubs out ChatApi so we exercise the dispatch and waiter logic without +spinning up the native libsimplex controller. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest + +from simplex_chat import ( + Bot, + BotProfile, + Client, + ContactAlreadyExistsError, + Profile, + SqliteDb, +) + + +class FakeApi: + """Drop-in replacement for ChatApi for tests that don't need the FFI. + + Records api_send_text_message calls; supports scripting api_connect_plan + and api_connect_active_user behaviour. + """ + + def __init__(self) -> None: + self.sent: list[tuple[Any, str]] = [] + self.connect_plan_result: Any = ("error", None) # default: no known contact + self.connect_should_raise: Exception | None = None + self.active_user: dict[str, Any] = {"userId": 1, "profile": {"displayName": "x"}} + + async def api_send_text_message(self, chat, text, in_reply_to=None): + self.sent.append((chat, text)) + return [] + + async def api_connect_plan(self, _user_id, _link): + kind = self.connect_plan_result[0] + if kind == "known_contact_address": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "known_invitation": + return ( + { + "type": "invitationLink", + "invitationLinkPlan": {"type": "known", "contact": self.connect_plan_result[1]}, + }, + {}, + ) + if kind == "ok": + return ( + { + "type": "contactAddress", + "contactAddressPlan": {"type": "ok"}, + }, + {}, + ) + # default "error" + return ({"type": "error", "chatError": {}}, {}) + + async def api_connect_active_user(self, _link): + if self.connect_should_raise is not None: + raise self.connect_should_raise + return "contact" + + async def api_get_active_user(self): + return self.active_user + + +def _bot_with_fake_api() -> tuple[Bot, FakeApi]: + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + api = FakeApi() + bot._api = api # type: ignore[assignment] + bot._serving = True # pretend receive loop is up + return bot, api + + +# --------------------------------------------------------------------------- +# Client class +# --------------------------------------------------------------------------- + + +def test_client_has_no_address_or_bot_profile_attributes(): + """Client should not carry bot-side state (address creation, auto-accept, + welcome, commands). That's the whole point of separating Client from Bot.""" + c = Client(profile=Profile(display_name="monitor"), db=SqliteDb(file_prefix="/tmp/test")) + for attr in ("_create_address", "_update_address", "_auto_accept", "_welcome", "_commands"): + assert not hasattr(c, attr), f"Client unexpectedly has Bot-only attribute {attr}" + # And the wire profile has no bot peerType + p = c._profile_to_wire() + assert "peerType" not in p + assert "preferences" not in p + + +def test_bot_is_a_client_subclass(): + """Bot should extend Client, so anywhere a Client is accepted, a Bot fits too.""" + assert issubclass(Bot, Client) + + +def test_client_exposes_messaging_methods(): + c = Client(profile=Profile(display_name="m"), db=SqliteDb(file_prefix="/tmp/test")) + assert hasattr(c, "connect_to") + assert hasattr(c, "send_and_wait") + assert hasattr(c, "events") + assert hasattr(c, "on_message") # decorators available on Client too + + +# --------------------------------------------------------------------------- +# send_and_wait +# --------------------------------------------------------------------------- + + +def test_send_and_wait_requires_serving(): + """Without the receive loop running, send_and_wait must raise — otherwise + callers would silently hang waiting for a reply that's never dispatched.""" + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + # _serving is False by default + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.send_and_wait(1, "hi")) + + +def test_send_and_wait_resolves_on_matching_reply(): + """A reply from the awaited contact should resolve the Future and skip + regular message dispatch.""" + bot, api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(_msg): + fallback_calls.append("fallback") + + async def go() -> str: + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=2.0)) + # Yield so the task gets to register its waiter. + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "pong"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + reply = await send_task + return reply.text or "" + + result = asyncio.run(go()) + assert result == "pong" + assert api.sent == [(["direct", 42], "ping")] + assert fallback_calls == [], "fallback handler should NOT fire when a waiter consumed the reply" + + +def test_send_and_wait_ignores_other_contacts(): + """Replies from a different contact must not resolve the waiter — that + would mis-correlate responses and is the bug send_and_wait exists to + prevent users from writing themselves.""" + bot, _api = _bot_with_fake_api() + + async def go(): + send_task = asyncio.create_task(bot.send_and_wait(42, "ping", timeout=0.5)) + await asyncio.sleep(0) + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 99}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "not for you"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + with pytest.raises(asyncio.TimeoutError): + await send_task + + asyncio.run(go()) + + +def test_send_and_wait_fifo_within_contact(): + """Two concurrent waiters on the same contact should resolve in send order.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + first = asyncio.create_task(bot.send_and_wait(42, "first", timeout=2.0)) + await asyncio.sleep(0) + second = asyncio.create_task(bot.send_and_wait(42, "second", timeout=2.0)) + await asyncio.sleep(0) + for text in ("reply1", "reply2"): + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + return (await first).text or "", (await second).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("reply1", "reply2") + + +def test_send_and_wait_cleans_up_state_on_timeout(): + """Timed-out waiters must be removed so they don't accidentally consume + later replies.""" + bot, _api = _bot_with_fake_api() + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.send_and_wait(42, "ping", timeout=0.05) + assert 42 not in bot._reply_waiters, f"leaked waiters: {bot._reply_waiters}" + + asyncio.run(go()) + + +def test_dispatch_skips_cancelled_waiters_and_falls_through_to_handlers(): + """Race fix: if a waiter is cancelled (wait_for timed out) but still in + the FIFO when a reply arrives, the dispatcher must skip it and either + resolve a live waiter OR fall through to user message handlers — not + silently drop the message.""" + bot, _api = _bot_with_fake_api() + fallback_calls: list[str] = [] + + @bot.on_message(content_type="text") + async def fallback(msg): + fallback_calls.append(msg.text or "") + + async def go(): + # Manually inject a cancelled waiter (simulating wait_for timeout + # cleanup losing the race with the inbound message). + loop = asyncio.get_running_loop() + stale: asyncio.Future = loop.create_future() + stale.cancel() + bot._reply_waiters[42] = [stale] + + evt = {"type": "newChatItems", "chatItems": [ + { + "chatInfo": {"type": "direct", "contact": {"contactId": 42}}, + "chatItem": { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "racing reply"}}, + }, + } + ]} + await bot._dispatch_event(evt) # type: ignore[arg-type] + + asyncio.run(go()) + assert fallback_calls == ["racing reply"], ( + "dispatcher dropped the message instead of falling through to user handlers; " + f"got {fallback_calls}" + ) + assert 42 not in bot._reply_waiters, "cancelled waiter wasn't cleaned up" + + +def test_send_and_wait_parallel_different_contacts(): + """Concurrent send_and_wait to different contacts must not block each other. + + The library docstring promises this; this test pins the behaviour so a + future refactor (e.g., adding a single lock) can't quietly break it.""" + bot, _api = _bot_with_fake_api() + + async def go() -> tuple[str, str]: + t_a = asyncio.create_task(bot.send_and_wait(10, "a", timeout=2.0)) + await asyncio.sleep(0) + t_b = asyncio.create_task(bot.send_and_wait(20, "b", timeout=2.0)) + await asyncio.sleep(0) + # Deliver reply for B first — order shouldn't matter. + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 20}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "B"}}}, + } + ]}) + await bot._dispatch_event({"type": "newChatItems", "chatItems": [ # type: ignore[arg-type] + { + "chatInfo": {"type": "direct", "contact": {"contactId": 10}}, + "chatItem": {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "A"}}}, + } + ]}) + return (await t_a).text or "", (await t_b).text or "" + + a, b = asyncio.run(go()) + assert (a, b) == ("A", "B") + + +# --------------------------------------------------------------------------- +# connect_to +# --------------------------------------------------------------------------- + + +def test_connect_to_returns_known_contact_without_handshake(): + """If the link is already known, connect_to skips api_connect entirely.""" + bot, api = _bot_with_fake_api() + existing = {"contactId": 7, "profile": {"displayName": "SimpleX Directory"}} + api.connect_plan_result = ("known_contact_address", existing) + + contact = asyncio.run(bot.connect_to("link", timeout=2.0)) + assert contact["contactId"] == 7 + # No connect issued: send buffer untouched. + assert api.sent == [] + + +def test_connect_to_waits_for_contactConnected(): + """For unknown links, connect_to issues the handshake and waits for the + contactConnected event before returning.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + new_contact = {"contactId": 11, "profile": {"displayName": "Friend"}} + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": new_contact}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 11 + + +def test_connect_to_tolerates_contact_already_exists(): + """ContactAlreadyExistsError must NOT abort connect_to — a previous + incomplete attempt may have left the connection mid-handshake; the + contactConnected event will still arrive.""" + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + api.connect_should_raise = ContactAlreadyExistsError( + "exists", {"type": "contactAlreadyExists"} # type: ignore[arg-type] + ) + + async def go(): + connect_task = asyncio.create_task(bot.connect_to("link", timeout=2.0)) + await asyncio.sleep(0) + await bot._dispatch_event({"type": "contactConnected", "contact": {"contactId": 5, "profile": {"displayName": "Friend"}}}) # type: ignore[arg-type] + return await connect_task + + contact = asyncio.run(go()) + assert contact["contactId"] == 5 + + +def test_connect_to_requires_serving(): + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + bot._api = FakeApi() # type: ignore[assignment] + with pytest.raises(RuntimeError, match="receive loop"): + asyncio.run(bot.connect_to("link")) + + +def test_connect_to_timeout_cleans_up_waiter(): + bot, api = _bot_with_fake_api() + api.connect_plan_result = ("ok", None) + + async def go(): + with pytest.raises(asyncio.TimeoutError): + await bot.connect_to("link", timeout=0.05) + assert bot._connect_waiters == [], "leaked connect waiter" + + asyncio.run(go()) + + +def test_connect_to_rejects_non_positive_timeout(): + """timeout<=0 must fail upfront — otherwise wait_for raises after the + handshake side-effect has already gone over the wire.""" + bot, _api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.001): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.connect_to("link", timeout=bad) + + asyncio.run(go()) + + +def test_send_and_wait_rejects_non_positive_timeout(): + """Same as connect_to: timeout<=0 would surprise the caller with a sent + message and no Future to await.""" + bot, api = _bot_with_fake_api() + + async def go(): + for bad in (0, -1, -0.5): + with pytest.raises(ValueError, match="timeout must be positive"): + await bot.send_and_wait(42, "ping", timeout=bad) + # And nothing was sent. + assert api.sent == [] + + asyncio.run(go()) + + +def test_stop_before_serve_forever_is_preserved(monkeypatch): + """If stop() is called between __aenter__ and serve_forever (e.g. a + signal handler fires during the window where run() wires SIGINT), the + pre-set _stop_event must NOT be cleared by serve_forever — otherwise + the signal is silently lost and the loop runs indefinitely.""" + import simplex_chat.client as client_mod + + class _FakeApi: + @classmethod + async def init(cls, *_a, **_kw): + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + pass + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def recv_chat_event(self, wait_us=0): + # Should NOT be reached — the loop should exit on the pre-set + # stop event before it ever polls for an event. + raise AssertionError("receive loop should have exited immediately") + + # _ensure_active_user / _maybe_sync_profile pokes + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _FakeApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + async with c: + c.stop() # signal fires before serve_forever + await c.serve_forever() # must not block + + asyncio.run(go()) + + +def test_aexit_nulls_api_even_if_close_raises(monkeypatch): + """If `close()` raises inside __aexit__, the Client must still appear + closed — `client.api` should refuse to hand back the half-shutdown + controller, and re-entering the context manager should re-init cleanly.""" + import simplex_chat.client as client_mod + + init_count = [0] + + class _BoomCloseApi: + @classmethod + async def init(cls, *_a, **_kw): + init_count[0] += 1 + return cls() + + @property + def started(self): + return False + + async def start_chat(self): + pass + + async def stop_chat(self): + pass + + async def close(self): + raise RuntimeError("close failed") + + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", _BoomCloseApi) + + c = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(RuntimeError, match="close failed"): + async with c: + pass + # _api must be None despite close() raising + assert c._api is None, "Client._api leaked after __aexit__ close() raised" + with pytest.raises(RuntimeError, match="not initialized"): + _ = c.api + # Re-enter must work + try: + async with c: + pass + except RuntimeError: + pass # close raises again, fine + assert init_count[0] == 2, "re-entry didn't re-init the controller" + + asyncio.run(go()) + + +def test_aenter_rolls_back_partial_init_on_post_start_failure(monkeypatch): + """If anything in __aenter__ raises after ChatApi.init succeeded — including + _post_start — the controller must be closed. Otherwise the with-block isn't + entered, __aexit__ never runs, and the FFI handle leaks.""" + import simplex_chat.client as client_mod + + closed: list[str] = [] + started: list[bool] = [False] + + class FakeChatApi: + @classmethod + async def init(cls, *_args, **_kwargs): + return cls() + + @property + def started(self) -> bool: + return started[0] + + async def start_chat(self): + started[0] = True + + async def stop_chat(self): + started[0] = False + closed.append("stop") + + async def close(self): + closed.append("close") + + # Stub the bits _ensure_active_user / _maybe_sync_profile reach for. + async def api_get_active_user(self): + return {"userId": 1, "profile": {"displayName": "x"}} + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + monkeypatch.setattr(client_mod, "ChatApi", FakeChatApi) + + class Boom(RuntimeError): + pass + + class BoomClient(Client): + async def _post_start(self, user): + raise Boom("kaboom") + + c = BoomClient(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + + async def go(): + with pytest.raises(Boom): + async with c: + pytest.fail("should not enter the with-block") + + asyncio.run(go()) + assert closed == ["stop", "close"], f"controller not cleaned up: {closed}" + assert c._api is None, "Client._api should be reset to None after rollback" + + +def test_lookup_known_contact_propagates_non_command_errors(): + """_lookup_known_contact must NOT mask transport / FFI errors as 'unknown + link' — only ChatCommandError (malformed link, etc.) should fall through + to the handshake path. Bare Exception catch would hide real bugs.""" + bot, api = _bot_with_fake_api() + + class BoomError(RuntimeError): + pass + + async def boom(_user_id, _link): + raise BoomError("FFI wedged") + + api.api_connect_plan = boom # type: ignore[assignment] + + async def go(): + with pytest.raises(BoomError): + await bot._lookup_known_contact(1, "link") + + asyncio.run(go()) + + +# --------------------------------------------------------------------------- +# Exception subclasses +# --------------------------------------------------------------------------- + + +def test_contact_already_exists_is_chat_command_error_subclass(): + """Callers should be able to catch the base class to handle all command + errors uniformly, and the specific subclass for targeted handling.""" + from simplex_chat import ChatCommandError, ContactAlreadyExistsError + + assert issubclass(ContactAlreadyExistsError, ChatCommandError) + + e = ContactAlreadyExistsError("x", {"type": "contactAlreadyExists"}) # type: ignore[arg-type] + assert isinstance(e, ChatCommandError) + assert e.response_type == "contactAlreadyExists" + + +def test_chat_command_error_response_type_property(): + from simplex_chat import ChatCommandError + + e = ChatCommandError("x", {"type": "someError"}) # type: ignore[arg-type] + assert e.response_type == "someError" + + +# --------------------------------------------------------------------------- +# events() mutual exclusion with serve_forever +# --------------------------------------------------------------------------- + + +def test_events_raises_if_already_serving(): + bot, _api = _bot_with_fake_api() + # _serving=True is set by _bot_with_fake_api + + async def go(): + with pytest.raises(RuntimeError, match="mutually exclusive"): + async for _ in bot.events(): + pass + + asyncio.run(go()) diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py new file mode 100644 index 0000000000..509d919cfd --- /dev/null +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -0,0 +1,41 @@ +"""Sanity checks on auto-generated wire types — catches generator regressions.""" + +import typing + +from simplex_chat.types import CC, CEvt, CR, T + + +def test_types_module_imports(): + """Every generated module imports cleanly with no SyntaxError.""" + assert T is not None and CC is not None and CR is not None and CEvt is not None + + +def test_chat_type_is_literal_enum(): + """ChatType should be a Literal of expected member set.""" + args = typing.get_args(T.ChatType) + assert "direct" in args + assert "group" in args + assert "local" in args + + +def test_known_command_has_cmd_string(): + s = CC.APICreateMyAddress_cmd_string({"userId": 1}) + assert s == "/_address 1" + + +def test_chat_response_tag_alias_present(): + """ChatResponse_Tag union of literals exists.""" + assert hasattr(CR, "ChatResponse_Tag") + + +def test_chat_event_tag_alias_present(): + """ChatEvent_Tag exists; covers the on_event Literal annotation.""" + assert hasattr(CEvt, "ChatEvent_Tag") + args = typing.get_args(CEvt.ChatEvent_Tag) + assert "newChatItems" in args + + +def test_chat_ref_cmd_string_direct(): + """Sanity check the codegen fix for ChatRef-bearing commands.""" + assert T.ChatRef_cmd_string({"chatType": "direct", "chatId": 7}) == "@7" + assert T.ChatRef_cmd_string({"chatType": "group", "chatId": 42}) == "#42" diff --git a/packages/simplex-chat-python/tests/test_filters.py b/packages/simplex-chat-python/tests/test_filters.py new file mode 100644 index 0000000000..3c909df4df --- /dev/null +++ b/packages/simplex-chat-python/tests/test_filters.py @@ -0,0 +1,103 @@ +import re + +from simplex_chat.filters import compile_message_filter + + +def _msg(content_type="text", text=None, chat_type="direct", group_id=None, contact_id=None): + """Build a minimal mock Message-like object for filter testing.""" + + class M: + pass + + m = M() + m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type} + chat_info: dict = {"type": chat_type} + if chat_type == "group": + chat_info["groupInfo"] = {"groupId": group_id} + elif chat_type == "direct" and contact_id is not None: + chat_info["contact"] = {"contactId": contact_id} + m.chat_item = {"chatInfo": chat_info} + return m + + +def test_no_filters_matches_all(): + f = compile_message_filter({}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + + +def test_content_type_singular(): + f = compile_message_filter({"content_type": "text"}) + assert f(_msg(content_type="text")) + assert not f(_msg(content_type="image")) + + +def test_content_type_tuple_or(): + f = compile_message_filter({"content_type": ("text", "image")}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + assert not f(_msg(content_type="voice")) + + +def test_text_exact(): + f = compile_message_filter({"text": "hello"}) + assert f(_msg(text="hello")) + assert not f(_msg(text="world")) + + +def test_text_regex(): + f = compile_message_filter({"text": re.compile(r"^\d+$")}) + assert f(_msg(text="123")) + assert not f(_msg(text="abc")) + + +def test_when_callable(): + f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"}) + assert f(_msg(content_type="voice")) + assert not f(_msg(content_type="text")) + + +def test_combined_and(): + f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")}) + assert f(_msg(content_type="text", text="abc123")) + assert not f(_msg(content_type="text", text="abc")) + assert not f(_msg(content_type="image")) + + +def test_chat_type_filter(): + f = compile_message_filter({"chat_type": "group"}) + assert f(_msg(chat_type="group", group_id=1)) + assert not f(_msg(chat_type="direct")) + + +def test_group_id_filter(): + f = compile_message_filter({"group_id": 42}) + assert f(_msg(chat_type="group", group_id=42)) + assert not f(_msg(chat_type="group", group_id=99)) + assert not f(_msg(chat_type="direct")) + + +def test_group_id_tuple_or(): + f = compile_message_filter({"group_id": (1, 2, 3)}) + assert f(_msg(chat_type="group", group_id=2)) + assert not f(_msg(chat_type="group", group_id=99)) + + +def test_contact_id_filter(): + f = compile_message_filter({"contact_id": 7}) + assert f(_msg(chat_type="direct", contact_id=7)) + assert not f(_msg(chat_type="direct", contact_id=99)) + assert not f(_msg(chat_type="group", group_id=7)) + + +def test_contact_id_tuple_or(): + f = compile_message_filter({"contact_id": (1, 2, 3)}) + assert f(_msg(chat_type="direct", contact_id=2)) + assert not f(_msg(chat_type="direct", contact_id=99)) + + +def test_contact_id_combined_with_content_type(): + f = compile_message_filter({"content_type": "text", "contact_id": 5}) + assert f(_msg(content_type="text", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="image", chat_type="direct", contact_id=5)) + assert not f(_msg(content_type="text", chat_type="direct", contact_id=99)) diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py new file mode 100644 index 0000000000..30a1f43e2a --- /dev/null +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -0,0 +1,92 @@ +import zipfile +from pathlib import Path + +import pytest + +from simplex_chat._native import _cache_root, _resolve_libs_dir, _download + + +def test_cache_root_linux(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _cache_root() == tmp_path / "simplex-chat" + + +def test_cache_root_macos(tmp_path, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat" + + +def test_override_via_env(tmp_path, monkeypatch): + # _resolve_libs_dir intentionally does not validate the override directory — + # it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake. + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _resolve_libs_dir("sqlite") == tmp_path + + +def test_resolve_downloads_when_missing(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + called = {} + + def fake_download(target_root: Path, backend: str) -> None: + called["target"] = target_root + called["backend"] = backend + target_root.mkdir(parents=True, exist_ok=True) + (target_root / "libsimplex.so").touch() + + monkeypatch.setattr("simplex_chat._native._download", fake_download) + libs_dir = _resolve_libs_dir("sqlite") + assert libs_dir == tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" + assert called["backend"] == "sqlite" + assert (libs_dir / "libsimplex.so").exists() + + +def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + cached = tmp_path / "simplex-chat" / "v6.5.2" / "sqlite" + cached.mkdir(parents=True) + (cached / "libsimplex.so").touch() + # Should NOT call _download — use the cached file. + monkeypatch.setattr( + "simplex_chat._native._download", lambda *a: pytest.fail("download should not be called") + ) + assert _resolve_libs_dir("sqlite") == cached + + +def test_postgres_on_macos_rejected(monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64") + with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"): + _resolve_libs_dir("postgres") + + +def test_atomic_install(tmp_path, monkeypatch): + """Build a fake libs zip, mock _stream_to_file, verify extraction + atomic rename.""" + # Build zip: libs/libsimplex.so + libs/libHS-stub.so + src = tmp_path / "src" / "libs" + src.mkdir(parents=True) + (src / "libsimplex.so").write_text("fake-so") + (src / "libHS-stub.so").write_text("fake-hs") + zip_path = tmp_path / "fake-libs.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in src.iterdir(): + zf.write(f, f"libs/{f.name}") + + def fake_stream(url, dest, *, timeout=60.0): + import shutil + + shutil.copy(zip_path, dest) + + monkeypatch.setattr("simplex_chat._native._stream_to_file", fake_stream) + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + target = tmp_path / "out" + _download(target, "sqlite") + assert (target / "libsimplex.so").read_text() == "fake-so" + assert (target / "libHS-stub.so").read_text() == "fake-hs" diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py new file mode 100644 index 0000000000..b27c3e09cf --- /dev/null +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -0,0 +1,55 @@ +from unittest.mock import patch +import pytest +from simplex_chat._native import _platform_tag, _libs_url, _libname + + +@patch("sys.platform", "linux") +@patch("platform.machine", return_value="x86_64") +def test_platform_linux_x64(_): + assert _platform_tag() == "linux-x86_64" + + +@patch("sys.platform", "darwin") +@patch("platform.machine", return_value="arm64") +def test_platform_macos_arm64(_): + assert _platform_tag() == "macos-aarch64" + + +@patch("sys.platform", "win32") +@patch("platform.machine", return_value="AMD64") +def test_platform_windows_x64(_): + assert _platform_tag() == "windows-x86_64" + + +@patch("sys.platform", "freebsd") +@patch("platform.machine", return_value="x86_64") +def test_platform_unsupported(_): + with pytest.raises(RuntimeError, match="Unsupported"): + _platform_tag() + + +def test_libname_per_platform(): + with patch("sys.platform", "linux"): + assert _libname() == "libsimplex.so" + with patch("sys.platform", "darwin"): + assert _libname() == "libsimplex.dylib" + with patch("sys.platform", "win32"): + assert _libname() == "libsimplex.dll" + + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_sqlite(_): + assert ( + _libs_url("sqlite") + == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" + "v6.5.2/simplex-chat-libs-linux-x86_64.zip" + ) + + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_postgres(_): + assert ( + _libs_url("postgres") + == "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" + "v6.5.2/simplex-chat-libs-linux-x86_64-postgres.zip" + ) diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py new file mode 100644 index 0000000000..983b1c2a56 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_util.py @@ -0,0 +1,175 @@ +from simplex_chat import util + + +def test_chat_info_ref_direct(): + ci = {"type": "direct", "contact": {"contactId": 7}} + assert util.chat_info_ref(ci) == {"chatType": "direct", "chatId": 7} + + +def test_chat_info_ref_group(): + ci = {"type": "group", "groupInfo": {"groupId": 42}} + assert util.chat_info_ref(ci) == {"chatType": "group", "chatId": 42} + + +def test_chat_info_ref_group_with_member_support_scope(): + ci = { + "type": "group", + "groupInfo": {"groupId": 42}, + "groupChatScope": {"type": "memberSupport", "groupMember_": {"groupMemberId": 99}}, + } + ref = util.chat_info_ref(ci) + assert ref == { + "chatType": "group", + "chatId": 42, + "chatScope": {"type": "memberSupport", "groupMemberId_": 99}, + } + + +def test_chat_info_ref_group_with_member_support_scope_no_member(): + ci = { + "type": "group", + "groupInfo": {"groupId": 42}, + "groupChatScope": {"type": "memberSupport"}, + } + ref = util.chat_info_ref(ci) + # No groupMember_ → no groupMemberId_ in the wire scope. + assert ref == { + "chatType": "group", + "chatId": 42, + "chatScope": {"type": "memberSupport"}, + } + + +def test_chat_info_ref_returns_none_for_non_targets(): + assert util.chat_info_ref({"type": "contactRequest"}) is None + assert util.chat_info_ref({"type": "contactConnection"}) is None + + +def test_chat_info_name_direct(): + ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}} + assert util.chat_info_name(ci) == "@Alice" + + +def test_chat_info_name_group(): + ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}} + assert util.chat_info_name(ci) == "#MyGroup" + + +def test_chat_info_name_group_with_member_support(): + ci = { + "type": "group", + "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}, + "groupChatScope": { + "type": "memberSupport", + "groupMember_": {"memberProfile": {"displayName": "Carol"}}, + }, + } + assert util.chat_info_name(ci) == "#MyGroup(support Carol)" + + +def test_chat_info_name_local(): + assert util.chat_info_name({"type": "local"}) == "private notes" + + +def test_chat_info_name_contact_request(): + ci = {"type": "contactRequest", "contactRequest": {"profile": {"displayName": "Eve"}}} + assert util.chat_info_name(ci) == "request from @Eve" + + +def test_chat_info_name_contact_connection(): + assert util.chat_info_name({"type": "contactConnection", "contactConnection": {}}) == ( + "pending connection" + ) + assert ( + util.chat_info_name({"type": "contactConnection", "contactConnection": {"localAlias": "X"}}) + == "pending connection (X)" + ) + + +def test_sender_name_direct_uses_chat_name(): + ci = {"type": "direct", "contact": {"profile": {"displayName": "Alice"}}} + chat_dir = {"type": "directRcv"} + assert util.sender_name(ci, chat_dir) == "@Alice" + + +def test_sender_name_group_appends_member(): + ci = {"type": "group", "groupInfo": {"groupProfile": {"displayName": "MyGroup"}}} + chat_dir = {"type": "groupRcv", "groupMember": {"memberProfile": {"displayName": "Bob"}}} + assert util.sender_name(ci, chat_dir) == "#MyGroup @Bob" + + +def test_contact_address_str_prefers_short(): + assert util.contact_address_str({"connFullLink": "full", "connShortLink": "short"}) == "short" + + +def test_contact_address_str_falls_back_to_full(): + assert util.contact_address_str({"connFullLink": "full"}) == "full" + + +def test_from_local_profile_strips_extras_and_undefined(): + local = { + "displayName": "x", + "fullName": "X Y", + "shortDescr": None, + "image": "data:image/png;base64,...", + "contactLink": None, + "preferences": {}, + "peerType": "bot", + "profileId": 99, # extra LocalProfile field + "localAlias": "alias", # extra LocalProfile field + } + p = util.from_local_profile(local) + assert p == { + "displayName": "x", + "fullName": "X Y", + "image": "data:image/png;base64,...", + "preferences": {}, + "peerType": "bot", + } + + +def test_ci_content_text_rcv(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}} + assert util.ci_content_text(ci) == "hello" + + +def test_ci_content_text_snd(): + ci = {"content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": "world"}}} + assert util.ci_content_text(ci) == "world" + + +def test_ci_content_text_other(): + ci = {"content": {"type": "rcvGroupEvent"}} + assert util.ci_content_text(ci) is None + + +def test_ci_bot_command_match(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/ping"}}} + assert util.ci_bot_command(ci) == ("ping", "") + + +def test_ci_bot_command_with_args(): + ci = { + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "/echo hi "}} + } + assert util.ci_bot_command(ci) == ("echo", "hi") + + +def test_ci_bot_command_not_a_command(): + ci = {"content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": "hello"}}} + assert util.ci_bot_command(ci) is None + + +def test_ci_bot_command_no_text(): + ci = {"content": {"type": "rcvGroupEvent"}} + assert util.ci_bot_command(ci) is None + + +def test_reaction_text_emoji(): + r = {"chatReaction": {"reaction": {"type": "emoji", "emoji": "🎉"}}} + assert util.reaction_text(r) == "🎉" + + +def test_reaction_text_tag(): + r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}} + assert util.reaction_text(r) == "thumbs_up" diff --git a/plans/2026-04-29-member-profile-sending-channels.md b/plans/2026-04-29-member-profile-sending-channels.md index 2ee36b676e..be7d26ecab 100644 --- a/plans/2026-04-29-member-profile-sending-channels.md +++ b/plans/2026-04-29-member-profile-sending-channels.md @@ -1,5 +1,16 @@ # Plan: Member Profile Sending in Channels +## Implementation note (2026-05-18) + +The shipped implementation is **monotonic and reuses `member_relations_vector`**, not a new `sent_profile_vector` column: + +- The introduction bit lives in `group_members.member_relations_vector` with status `MRIntroduced`. The M20251117 backfill already populates this column for channel rows (relay role is not admin/owner), and `createNewGroupMember` writes `Binary B.empty` for new members. +- Bits flip 0 → 1 when the relay first announces the member to a recipient via prepended `XGrpMemNew` (or via `XGrpMemIntro` in `introduceInChannel`'s join-time direct path). They are **never cleared**. +- Profile updates propagate via the sender's own signed `XInfo`, forwarded unchanged by the relay. The relay updates its DB on receipt; subscribers verify with the key obtained from the earlier `XGrpMemNew`. Section 5 below ("Clear vector on profile update") is superseded by this — no clearing happens. +- The mutually-exclusive two-column delivery-jobs storage (`single_sender_group_member_id` + `sender_group_member_ids`) collapses into a single nullable `sender_group_member_ids BYTEA` column: `[s]` for single-sender jobs, `[s1, s2, ...]` for multi-sender batches, NULL for sender-less jobs (`DJRelayRemoved`). + +The plan body below is preserved for historical context. + ## Context In channels (relayed groups), subscribers don't know profiles of other subscribers. When subscriber A sends a reaction/message that gets forwarded to subscriber B, B creates an "unknown member" record with a synthesized name. This degrades UX — subscribers see "unknown member" instead of real profiles. diff --git a/plans/2026-05-07-simplex-chat-python-design.md b/plans/2026-05-07-simplex-chat-python-design.md new file mode 100644 index 0000000000..240f88714c --- /dev/null +++ b/plans/2026-05-07-simplex-chat-python-design.md @@ -0,0 +1,575 @@ +# SimpleX Chat Python library design + +## Table of contents + +- [What](#what) +- [Why](#why) +- [How](#how) +- [Architecture](#architecture) +- [Type generation](#type-generation) +- [Native lib loading](#native-lib-loading) +- [Public API](#public-api) +- [Distribution and CI](#distribution-and-ci) +- [Testing](#testing) +- [Open questions](#open-questions) + +## What + +A Python 3 client library `simplex-chat` on PyPI for SimpleX bots. Same capability as the Node.js library at `packages/simplex-chat-nodejs/`. + +The user writes a Python script with decorator-registered handlers; the library does the rest: + +```python +from simplex_chat import Bot, BotProfile, SqliteDb, TextMessage + +bot = Bot(profile=BotProfile(display_name="Squarer"), + db=SqliteDb(file_prefix="./bot"), + welcome="Send a number, I'll square it.") + +@bot.on_message(content_type="text") +async def square(msg: TextMessage) -> None: + try: + n = float(msg.text) + await msg.reply(f"{n} * {n} = {n * n}") + except ValueError: + await msg.reply("Not a number.") + +if __name__ == "__main__": + bot.run() +``` + +`pip install simplex-chat`, run the script, done. + +## Why + +SimpleX has a Node.js library (`simplex-chat`) and Haskell-built native lib (`libsimplex.{so,dylib,dll}`) but no Python equivalent. Python is the dominant language for bot scripting, automation, and data integration. Without a Python client, those users either can't use SimpleX or have to bridge through Node. + +The native `libsimplex` already exists as prebuilt artifacts (`simplex-chat/simplex-chat-libs` GitHub releases, one zip per platform/backend). The Haskell type metadata that drives the Node lib's TypeScript types is already in `bots/src/API/Docs/`. Both can be reused — adding Python bindings is mostly wiring, not a new system. + +## How + +Three pieces: + +1. **Extend the existing Haskell type generator** in `bots/src/API/Docs/Generate/` to emit a Python types module alongside the existing TypeScript one. The metadata is the same; only the rendering changes. Already includes `pySyntaxText` (used today in COMMANDS.md docs) — just needs a Python codegen module. + +2. **A new Python package** `packages/simplex-chat-python/` that wraps the prebuilt `libsimplex.*` via `ctypes`, downloading it on first use from the existing GitHub release. Async-only (`asyncio`), Python 3.11+. Single `Bot` class with decorator-registered handlers. + +3. **One small CI job** appended to `.github/workflows/build.yml`, after the existing `release-nodejs-libs` job, that publishes the Python package to PyPI on each release tag. ~15 lines of YAML. + +No new infrastructure: no separate libs build, no per-platform wheels, no PyPI size waiver, no second CI workflow. The libs zips that already exist for the Node lib are reused unchanged. + +## Architecture + +``` +┌────────────────────────────────────────────────────────────┐ +│ bots/src/API/Docs/ (Haskell, existing) │ +│ ├── Types/Commands/Events/Responses │ +│ ├── Syntax.hs (already has pySyntaxText) │ +│ ├── Generate/TypeScript.hs (existing) │ +│ └── Generate/Python.hs ← new │ +└────────────────────┬───────────────────────────────────────┘ + │ tests/APIDocs.hs runs both generators + ▼ writes auto-gen Python type files +┌────────────────────────────────────────────────────────────┐ +│ packages/simplex-chat-python/ (new) │ +│ │ +│ Bot ←── public API: decorators, lifecycle │ +│ └── ChatApi ← escape hatch: raw command access │ +│ └── core ← internal: typed FFI wrapper │ +│ └── _native ← internal: ctypes + lazy DL │ +│ ↓ │ +│ libsimplex.{so,dylib,dll} │ +│ downloaded from simplex-chat-libs releases │ +└────────────────────────────────────────────────────────────┘ +``` + +The split lets each layer be tested independently: `Bot`'s filter-routing without a real libsimplex (mock `api`), `core`'s JSON handling without ctypes (mock `_native`), `_native`'s download/ctypes work with a stub `.so`. Same shape as the Node lib (`bot.ts` → `api.ts` → `core.ts` → `simplex.cc`). + +## Type generation + +### New module: `bots/src/API/Docs/Generate/Python.hs` + +Mirrors `Generate/TypeScript.hs` line-for-line. Reuses the existing data sources (`chatCommandsDocs`, `chatResponsesDocs`, `chatEventsDocs`, `chatTypesDocs`) and `pySyntaxText` from `Syntax.hs`. Output goes to `packages/simplex-chat-python/src/simplex_chat/types/` as four files: `_types.py`, `_commands.py`, `_responses.py`, `_events.py`. + +### Type representation + +Wire types are `TypedDict` + `Literal` discriminators (matches Node lib semantics — just shapes, no runtime cost; pyright narrows tagged unions correctly). + +| Haskell | Python | +|---|---| +| `STRecord` | `class Foo(TypedDict)`; optional fields use `NotRequired[...]`. | +| `STUnion` / `STUnion1` | One `class Foo_(TypedDict)` per member with `type: Literal[""]` discriminator. Type alias `Foo = Foo_A \| Foo_B \| …`. Tag alias `Foo_Tag = Literal["", "", …]`. | +| `STEnum` / `STEnum1` / `STEnum'` | Type alias `Foo = Literal["a", "b", "c"]`. | +| `ATPrim TBool` | `bool` | +| `ATPrim TString` | `str` | +| `ATPrim TInt`/`TInt64`/`TWord32` | `int` | +| `ATPrim TDouble` | `float` | +| `ATPrim TJSONObject` | `dict[str, object]` | +| `ATPrim TUTCTime` | `str` (ISO-8601, comment-annotated) | +| `ATOptional t` | `NotRequired[]` in TypedDict fields; ` \| None` elsewhere | +| `ATArray {nonEmpty=False}` | `list[]` | +| `ATArray {nonEmpty=True}` | `list[]` with trailing `# non-empty` comment | +| `ATMap (PT k) v` | `dict[, ]` | +| `ATDef` / `ATRef` | type name as forward-string reference `""` | + +### Command serialization + +Each command becomes a `TypedDict` plus a `_cmd_string(self) -> str` function. The function body is produced by `pySyntaxText` (which already emits Python expressions for the existing Markdown docs): + +```python +class APICreateMyAddress(TypedDict): + userId: int + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError +``` + +### Field-naming convention + +| Where | Style | Why | +|---|---|---| +| Auto-generated `types/_*.py` | **camelCase** | Round-trips JSON to/from libsimplex; the keys are the wire format. | +| Hand-written user-facing types (`SqliteDb`, `BotProfile`, `Message`, …) | **snake_case** | These are Python-side configs and wrappers, never reach `chat_send_cmd` directly. | +| `CryptoArgs` (`fileKey`, `fileNonce`) | **camelCase** | Returned by `chat_write_file` as JSON; round-trips wire format. | +| Method names | **snake_case** | PEP 8. | +| Type names | **PascalCase** | PEP 8 + Haskell parity. | + +Generator must emit field names verbatim from `APIRecordField.fieldName'` — never transform. + +### Wiring + +Extend `tests/APIDocs.hs` with four `testGenerate` calls: + +```haskell +describe "Python" $ do + it "generate python commands" $ testGenerate Py.commandsCodeFile Py.commandsCodeText + it "generate python responses" $ testGenerate Py.responsesCodeFile Py.responsesCodeText + it "generate python events" $ testGenerate Py.eventsCodeFile Py.eventsCodeText + it "generate python types" $ testGenerate Py.typesCodeFile Py.typesCodeText +``` + +`cabal test` regenerates all eight files (4 TS + 4 PY) and fails on drift — same enforcement loop that already governs the TypeScript files. + +Add `API.Docs.Generate.Python` to `simplex-chat.cabal:572-580`. + +## Native lib loading + +### Approach: lazy download on first use + +Single pure-Python wheel on PyPI (`simplex-chat`, ~100 KB). On first `Bot(...)` / `ChatApi.init(...)`, the library downloads the matching `libsimplex` zip from `simplex-chat/simplex-chat-libs` releases into a user cache, then `ctypes.CDLL`s it. Subsequent runs read from cache. + +**Why not platform wheels?** Two reasons. First, simpler CI: one `python -m build` job vs a 5-platform matrix that has to download libs zips and rebuild wheels per platform. Second, the libs are already published as the source of truth (existing `release-nodejs-libs` job) — wheels would be a wrapper around those, adding nothing but build complexity. Tradeoff: first run requires internet; air-gap users set `SIMPLEX_LIBS_DIR=/path/to/libs`. + +### Cache layout + +``` +~/.cache/simplex-chat/ # XDG_CACHE_HOME on Linux +└── v6.5.1/ # = LIBS_VERSION + ├── sqlite/libsimplex.so + libHS*.so + └── postgres/libsimplex.so + libHS*.so +``` + +Platform-specific cache base: Linux `$XDG_CACHE_HOME`, macOS `~/Library/Caches/`, Windows `%LOCALAPPDATA%`. + +### Version pinning + +`src/simplex_chat/_version.py`: + +```python +__version__ = "6.5.1" # PEP 440 — bumped with each Python package release +LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix) +``` + +`__version__` is read by hatchling for wheel metadata. `LIBS_VERSION` is read by `_native.py` for the download URL. Wrapper-only patch releases use a post-suffix (`__version__ = "6.5.1.post1"`, `LIBS_VERSION` unchanged). Same pattern as Node lib's `RELEASE_TAG = 'v6.5.1'`. + +### `_native.py` responsibilities + +1. **Detect platform.** `sys.platform` × `platform.machine()` → `linux-x86_64`, `linux-aarch64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64`. Unsupported combos raise immediately. +2. **Resolve backend** from the `Db` instance (`isinstance(db, SqliteDb)` → sqlite, `PostgresDb` → postgres). Module-level `threading.Lock` guards selection — first call wins for the process; subsequent call with a different backend raises (one libsimplex variant per process — Haskell RTS constraint). +3. **Resolve libs path.** If `SIMPLEX_LIBS_DIR` env is set, use it directly. Otherwise: `~/.cache/simplex-chat/v{LIBS_VERSION}/{backend}/`, downloading if missing. +4. **Download URL** (`LIBS_VERSION` is stored without 'v' prefix; URL re-adds it): + + ``` + https://github.com/simplex-chat/simplex-chat-libs/releases/download/v{LIBS_VERSION}/simplex-chat-libs-{platform}-{arch}{-postgres?}.zip + ``` + +5. **Atomic install.** Download to sibling tempdir → `zipfile.extractall` → `os.replace` the `libs/` subdir into cache. The libs zip contains only regular files (no symlinks — `build.yml:751-781` builds it via `cp *.so` which resolves them), so plain `extractall` is sufficient. Two processes racing safely both extract; whichever rename lands first wins, contents identical. +6. **Load and init.** `ctypes.CDLL(libs_dir / libname)` once per process; declare `restype`/`argtypes` for the 8 FFI functions; call `hs_init_with_rtsopts` exactly once with `+RTS -A64m -H64m -xn --install-signal-handlers=no` (or without `-xn` on Windows — matches `cpp/simplex.cc:13-32`). +7. **Buffer ownership.** Haskell allocates result strings; caller must `free()` after copying. Declare `restype = c_void_p` (NOT `c_char_p`, which auto-converts to bytes and discards the pointer needed for free): + + ```python + ptr = lib.chat_send_cmd(ctrl, cmd_bytes) + if not ptr: raise RuntimeError("null result") + try: result = ctypes.string_at(ptr).decode("utf-8") + finally: libc.free(ptr) + ``` + + `libc` is `ctypes.CDLL(None)` on Linux/macOS, `ctypes.CDLL("msvcrt")` on Windows. Mirrors `HandleCResult` in `cpp/simplex.cc:157-165`. + +### Override / pre-fetch + +```bash +# Skip download — for Docker / air-gapped +SIMPLEX_LIBS_DIR=/opt/simplex/libs python my_bot.py + +# Pre-fetch in Dockerfile RUN step (avoids redundant download per container start) +python -m simplex_chat install --backend=sqlite +python -m simplex_chat install --backend=postgres +``` + +### Failure modes + +| Condition | Behavior | +|---|---| +| Unsupported platform/arch | Raise on first FFI call with explicit list of supported combinations. | +| Postgres on non-Linux-x86_64 | Raise — matches existing constraint in `download-libs.js:15-18`. | +| Download network/HTTP error | Propagate `urllib.error.URLError` with the URL. | +| Two processes downloading simultaneously | Both extract to sibling temp dirs; rename is atomic; identical contents → safe. | +| Two `Bot()` / `ChatApi.init()` calls in same process with different backends | Second raises — one libsimplex variant per process. | +| Two `Bot()` instances same backend, same process | Permitted — each has its own controller (`chat_ctrl`). | + +## Public API + +See the [Architecture](#architecture) section for the layering. This section specifies each layer's surface. + +### Construction + +User-facing config types are `@dataclass(slots=True)`, snake_case fields. + +```python +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + +Db = SqliteDb | PostgresDb # discriminated by isinstance() + +@dataclass(slots=True) +class BotProfile: + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + +class Bot: + def __init__( + self, *, + profile: BotProfile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, # None → [] + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + # behavioral toggles — mirror BotOptions in Node lib + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + use_bot_profile: bool = True, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: ... + + @property + def api(self) -> ChatApi: ... +``` + +### Handler registration + +Three decorators. Filters are kwargs combined with **AND**; tuples within a kwarg are **OR**; arbitrary predicates use `when=`. + +```python +class Bot: + def on_message(self, *, + content_type: T.MsgContent_Tag | tuple[T.MsgContent_Tag, ...] | None = None, + text: str | re.Pattern | None = None, # exact match or regex.search() + chat_type: T.ChatType | tuple[T.ChatType, ...] | None = None, # direct/group/local + from_role: T.GroupMemberRole | tuple[T.GroupMemberRole, ...] | None = None, + from_contact_id: int | tuple[int, ...] | None = None, + from_member_id: int | tuple[int, ...] | None = None, + group_id: int | tuple[int, ...] | None = None, + when: Callable[[Message], bool] | None = None, + ) -> Callable[[MessageHandler], MessageHandler]: ... + + def on_command(self, name: str | tuple[str, ...], *, + args: str | re.Pattern | None = None, # match command argument string + chat_type: T.ChatType | tuple[T.ChatType, ...] | None = None, + from_role: T.GroupMemberRole | tuple[T.GroupMemberRole, ...] | None = None, + from_contact_id: int | tuple[int, ...] | None = None, + group_id: int | tuple[int, ...] | None = None, + when: Callable[[Message], bool] | None = None, + ) -> Callable[[CommandHandler], CommandHandler]: ... + + # Multiple handlers per tag dispatch in registration order. + def on_event(self, event: CEvt.Tag, /, + ) -> Callable[[EventHandler], EventHandler]: ... + + def use(self, middleware: Middleware) -> None: ... + +MessageHandler = Callable[[Message], Awaitable[None] | None] +CommandHandler = Callable[[Message, ParsedCommand], Awaitable[None] | None] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None] | None] +``` + +`from_role` on direct chats: silent skip (not a runtime error). + +### Message wrapper and content-narrowed types + +`Message[C]` is generic in its content variant; concrete subclass aliases (`TextMessage`, `ImageMessage`, …) bind to the auto-generated `T.MsgContent_*` types. Decorator overloads narrow the handler parameter when `content_type` is a single `Literal`, so pyright sees the right concrete type. + +```python +C = TypeVar("C", bound=T.MsgContent) # bound covers the unparameterized case + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem # raw wire object — fields below this point are camelCase + content: C # narrowed when filter pins content_type + bot: Bot + + @property + def chat_info(self) -> T.ChatInfo: ... # shortcut for chat_item["chatInfo"] + @property + def text(self) -> str | None: ... # shortcut; non-Optional for TextMessage + + async def reply(self, text: str) -> Message: ... + async def reply_content(self, content: T.MsgContent) -> Message: ... + async def react(self, emoji: str) -> None: ... + async def delete(self) -> None: ... + async def forward(self, to: T.ChatRef) -> Message: ... + +# Concrete narrowed aliases — exported from simplex_chat/__init__.py +TextMessage = Message[T.MsgContent_Text] +ImageMessage = Message[T.MsgContent_Image] +FileMessage = Message[T.MsgContent_File] +VoiceMessage = Message[T.MsgContent_Voice] +# … one per MsgContent variant + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str +``` + +Decorator overloads (one per `T.MsgContent_*` variant — ~15 lines, hand-written in `bot.py`): + +```python +class Bot: + @overload + def on_message(self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[[Callable[[TextMessage], Awaitable[None] | None]], ...]: ... + @overload + def on_message(self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[[Callable[[ImageMessage], Awaitable[None] | None]], ...]: ... + # … one overload per MsgContent variant … + @overload + def on_message(self, *, content_type: tuple[T.MsgContent_Tag, ...] | None = None, + **rest: Any) -> Callable[[Callable[[Message], Awaitable[None] | None]], ...]: ... +``` + +`@bot.on_message(content_type="text")` → handler typed as `TextMessage`, so `msg.text: str` (non-Optional). + +**Field-naming boundary in `Message`.** Wrapper properties (`msg.chat_info`, `msg.content`, `msg.text`) are snake_case. Descending into raw wire data via `msg.chat_item[...]` reverts to camelCase — same as accessing `T.AChatItem` returned by `bot.api`. Property shortcuts cover the common paths so most handlers never touch `chat_item` directly. + +### Lifecycle + +```python +class Bot: + # Blocking convenience — runs asyncio.run(self.serve_forever()), installs SIGINT + # via loop.add_signal_handler() (POSIX) or signal.signal() (Windows). Recommended for scripts. + def run(self) -> None: ... + + # Embedding form — caller owns the loop and signal handling. + async def __aenter__(self) -> Bot: ... + async def __aexit__(self, *exc_info: object) -> None: ... + + # Concurrent calls raise RuntimeError("already serving"). Re-callable after a clean stop(). + async def serve_forever(self) -> None: ... + + # Marks bot for shutdown. Safe from signal handler, another coroutine, or another thread. + def stop(self) -> None: ... +``` + +### Middleware + +aiogram pattern. A class with `async __call__(handler, message, data)` wraps every **handler invocation** (per-message-per-matching-handler). `data: dict[str, object]` is the cross-cutting injection channel. + +```python +class Middleware: + async def __call__(self, + handler: Callable[[Message, dict[str, object]], Awaitable[None]], + message: Message, + data: dict[str, object]) -> None: + await handler(message, data) +``` + +- **Invocation count.** A `newChatItems` event with N items × M matching handlers triggers N×M middleware calls. Per-event hooks should use `on_event`. +- **Exception propagation.** Handler exceptions propagate outward through the middleware stack. The outermost middleware can catch and swallow. Uncaught exceptions are logged via `logging.getLogger("simplex_chat")` and the chain moves to the next handler — the bot does not stop on individual handler errors. The receive loop only stops on a fatal `_native`/`core` error or explicit `bot.stop()`. +- **Order.** Registered via `bot.use(...)`. Called in registration order (first registered = outermost wrap). + +### Event-loop semantics + +`Bot` runs one event-receiver coroutine looping `chat_recv_msg_wait` (in `asyncio.to_thread`): + +1. All `on_event(tag)` handlers for the event's `type` field — registration order, sequentially. +2. If event is `newChatItems`: for each chat item, run **all matching message handlers** (each through the middleware stack, in registration order). For each command-parseable text item, also run matching command handlers. + +Handlers run **sequentially within an event**. Events are processed **sequentially**. Long-running work that shouldn't block the next event must `asyncio.create_task(...)` explicitly. + +`bot.api.api_xxx(...)` calls are safe during `serve_forever` — same controller, serialized through `chat_send_cmd`. Calling them from inside a handler is the normal pattern (`msg.reply()` does exactly this). + +### `ChatApi` (escape hatch) + +Reached via `bot.api`. ~40 async methods, one per Node `apiXxx` (api.ts:344-958). Full enumeration deferred to implementation; representative examples: + +```python +class ChatApi: + @classmethod + async def init(cls, db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP) -> ChatApi: ... + async def start_chat(self) -> None: ... + async def stop_chat(self) -> None: ... + async def close(self) -> None: ... + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: ... + async def recv_chat_event(self, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: ... + + async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: ... + async def api_send_text_message(self, chat: T.ChatRef, text: str, + in_reply_to: int | None = None) -> list[T.AChatItem]: ... + async def api_get_chats(self, user_id: int, pagination: T.PaginationByTime, + query: T.ChatListQuery | None = None) -> list[T.AChat]: ... + # ... etc +``` + +TS `apiCreateUserAddress` → Python `api_create_user_address` (PEP 8). Wire-format type names (`T.AChatItem`, `T.UserContactLink`, …) keep their Haskell/TS spelling to match JSON keys. + +### Embedding example + +```python +import asyncio +from simplex_chat import Bot, BotProfile, SqliteDb + +async def main(): + async with Bot(profile=..., db=...) as bot: + @bot.on_message(content_type="text") + async def echo(msg): + await msg.reply(msg.text) + await asyncio.gather(bot.serve_forever(), other_task()) + +asyncio.run(main()) +``` + +## Distribution and CI + +### Project layout + +``` +packages/simplex-chat-python/ +├── pyproject.toml # hatchling, requires-python >= 3.11, no runtime deps +├── README.md +├── LICENSE # AGPL-3.0 +├── src/simplex_chat/ +│ ├── __init__.py # exports Bot, BotProfile, BotCommand, SqliteDb, PostgresDb, +│ │ # Message + TextMessage/ImageMessage/… aliases, ParsedCommand, +│ │ # ChatApi, MigrationConfirmation, Middleware, ChatAPIError +│ ├── _version.py # __version__ + LIBS_VERSION +│ ├── _native.py # ctypes + lazy lib download (internal) +│ ├── __main__.py # python -m simplex_chat install ... +│ ├── core.py # internal typed FFI wrapper +│ ├── api.py # ChatApi class — escape hatch +│ ├── bot.py # Bot class, decorators, Message wrapper, lifecycle +│ ├── filters.py # filter kwarg compilation; predicate combinators +│ ├── util.py # stateless helpers (chat_info_ref, ci_content_text, reaction_text, …) +│ ├── py.typed # PEP 561 marker +│ └── types/ +│ ├── __init__.py # re-exports T, CC, CR, CEvt +│ ├── _types.py # AUTOGEN +│ ├── _commands.py # AUTOGEN +│ ├── _responses.py # AUTOGEN +│ └── _events.py # AUTOGEN +├── examples/ +│ └── squaring_bot.py +└── tests/ +``` + +### `pyproject.toml` + +```toml +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +license = "AGPL-3.0" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +dynamic = ["version"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] +``` + +No runtime Python dependencies (ctypes, urllib, zipfile are stdlib). + +### CI publishing + +One job appended to `.github/workflows/build.yml`, after `release-nodejs-libs`: + +```yaml +publish-python: + needs: [release-nodejs-libs] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + permissions: { id-token: write } # OIDC, no API key + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: { python-version: "3.11" } + - run: pip install build && python -m build --wheel + working-directory: packages/simplex-chat-python + - uses: pypa/gh-action-pypi-publish@release/v1 + with: { packages-dir: packages/simplex-chat-python/dist } +``` + +Triggered by the same `vX.Y.Z` tag that already drives the desktop and libs releases. + +### One-time setup + +1. Verify PyPI package name `simplex-chat` is available; register it. +2. On PyPI project page, configure trusted publisher → repo `simplex-chat/simplex-chat`, workflow `build.yml`, job `publish-python`. + +## Testing + +Three levels: + +1. **Codegen drift** — `tests/APIDocs.hs` adds Python generators alongside TypeScript. Same `testGenerate` mechanism enforces that committed `_types.py` etc. equal the generator output. + +2. **Python unit tests** — `pytest`, no real libsimplex needed: + - `test_native.py`: mock `urllib.request.urlretrieve` + `zipfile.ZipFile`; assert correct URL, atomic rename, cache hit on second call, override-env behavior, postgres-on-mac rejection. + - `test_codegen.py`: import every type from `simplex_chat.types`, sanity-check that `T.ChatType` is `Literal[...]` of expected size, etc. Catches generator regressions. + - `test_smoke.py`: build a fake `.so` (small C file with stub `chat_send_cmd` returning canned JSON, compiled per-test), point `SIMPLEX_LIBS_DIR` at it, run `Bot.__aenter__` → handler dispatch. Verifies FFI plumbing without real Haskell. + +3. **Integration** — `examples/squaring_bot.py` runs against real libsimplex. Not in CI (needs network + persistent state). + +## Open questions + +1. **Linux ARM64.** Existing `simplex-chat-libs` releases ship `linux-x86_64`, `macos-x86_64`, `macos-aarch64`, `windows-x86_64` — no `linux-aarch64`. Python lib will fail with a clear message there. Adding it requires changes to the existing `release-nodejs-libs` job in `build.yml` (out of scope for this spec). + +2. **`asyncio.to_thread` pool sizing.** Long-blocking `chat_recv_msg_wait` calls (default 5 s) pin executor threads. The default asyncio pool is unbounded but recycled. Bots running many concurrent chats may need a custom executor — first-pass uses `asyncio.to_thread`; document recommended pool sizing in README if it becomes a problem. diff --git a/plans/2026-05-07-simplex-chat-python-implementation.md b/plans/2026-05-07-simplex-chat-python-implementation.md new file mode 100644 index 0000000000..b1ff5b951c --- /dev/null +++ b/plans/2026-05-07-simplex-chat-python-implementation.md @@ -0,0 +1,2348 @@ +# SimpleX Chat Python library — implementation plan + +> **For agentic workers:** Use superpowers-extended-cc:subagent-driven-development (if subagents available) or superpowers-extended-cc:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `simplex-chat` on PyPI — a Python 3 client library for SimpleX bots with the same capability as the existing Node.js library. + +**Architecture:** Two repositories of work in this monorepo: extend the Haskell type generator (`bots/src/API/Docs/`) to emit Python types alongside TypeScript, and add a new Python package (`packages/simplex-chat-python/`) that wraps the existing prebuilt `libsimplex.{so,dylib,dll}` via ctypes. Lazy download of libs from `simplex-chat/simplex-chat-libs` GitHub releases. Async-only public API with decorator-registered handlers. Single PyPI wheel. + +**Tech Stack:** Haskell (existing codegen), Python 3.11+ (ctypes, asyncio, hatchling), GitHub Actions (publish-python job in existing build.yml). + +**Spec:** [`plans/2026-05-07-simplex-chat-python-design.md`](./2026-05-07-simplex-chat-python-design.md) + +--- + +## Plan structure + +Eight phases, executed in order: + +1. Type generation (Haskell) — `Generate/Python.hs` + `tests/APIDocs.hs` wiring. +2. Python package scaffold — `pyproject.toml`, `_version.py`, layout, hatch config. +3. Native FFI layer — `_native.py` (lazy download, ctypes, hs_init, buffer ownership). +4. Core wrapper — `core.py` (typed async FFI). +5. ChatApi — escape-hatch class with raw + ~40 high-level methods. +6. Bot class — decorators, filters, Message wrapper, lifecycle, middleware. +7. Tests + CLI — pytest suite, `python -m simplex_chat install`. +8. CI publishing — append `publish-python` job to `.github/workflows/build.yml`. + +Each phase is a chunk. Phase boundaries are natural review points. + +--- + +## Chunk 1: Type generation (Haskell) + +Add `bots/src/API/Docs/Generate/Python.hs`, mirror `Generate/TypeScript.hs`, wire into the test suite. + +### Task 1.1: Create `Generate/Python.hs` skeleton + +**Files:** +- Create: `bots/src/API/Docs/Generate/Python.hs` + +- [ ] **Step 1: Copy `Generate/TypeScript.hs` as starting point** + +```bash +cp bots/src/API/Docs/Generate/TypeScript.hs bots/src/API/Docs/Generate/Python.hs +``` + +- [ ] **Step 2: Rename module and update output paths** + +Edit the new file: +- Module: `module API.Docs.Generate.Python where` +- File constants: + ```haskell + commandsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_commands.py" + responsesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_responses.py" + eventsCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_events.py" + typesCodeFile = "./packages/simplex-chat-python/src/simplex_chat/types/_types.py" + ``` + +- [ ] **Step 3: Register module in cabal manifest** + +In `simplex-chat.cabal`, find the `other-modules:` list of the `simplex-chat-test` test-suite stanza (`type: exitcode-stdio-1.0`, `main-is: Test.hs`). Insert `API.Docs.Generate.Python` alphabetically between `API.Docs.Generate` and `API.Docs.Responses`. + +- [ ] **Step 4: Verify cabal compiles** + +``` +cabal build simplex-chat-test +``` +Expected: builds without error (the file is still TS-shaped but Haskell-valid). + +- [ ] **Step 5: Commit scaffolding** + +```bash +git add bots/src/API/Docs/Generate/Python.hs simplex-chat.cabal +git commit -m "feat(bots): add Python codegen module skeleton" +``` + +### Task 1.2: Implement Python type rendering + +Replace TypeScript-specific output with Python equivalents per the spec's [type-mapping rules](./2026-05-07-simplex-chat-python-design.md#type-representation). + +**Files:** +- Modify: `bots/src/API/Docs/Generate/Python.hs` + +- [ ] **Step 1: Rewrite `typesCodeText`** + +Output structure: +```python +# API Types +# This file is generated automatically. +from typing import Literal, NotRequired, TypedDict + +# … one class / type alias per chatTypesDocs entry … +``` + +Translation rules (mirror `TypeScript.hs` `typeCode`): +- `ATDRecord fields` → `class (TypedDict):` with translated field types. +- `ATDEnum cs` → ` = Literal["c1", "c2", …]`. +- `ATDUnion cs` → tagged TypedDicts + union alias + tag alias (see `unionTypeCode` in TS). + +Field-type translation (table in spec; mirrors `TypeScript.hs` `fieldsCode` `typeText`): +- `ATPrim` primitives → Python primitives (`bool`, `str`, `int`, `float`, `dict[str, object]`). +- `ATOptional t` inside TypedDict → `NotRequired[]`; elsewhere ` | None`. +- `ATArray {elemType, nonEmpty}` → `list[]`, append `# non-empty` comment when `nonEmpty=True`. +- `ATMap (PT k) v` → `dict[, ]`. +- `ATDef` / `ATRef` → forward-string reference `""`. + +- [ ] **Step 2: Rewrite `responsesCodeText` and `eventsCodeText`** + +Both produce union types — same shape as TS's `unionTypeCode`. Output structure: + +```python +# API Responses +# This file is generated automatically. +from . import _types as T + +ChatResponse_ = TypedDict(...) +ChatResponse_ = TypedDict(...) +… +ChatResponse = ChatResponse_ | ChatResponse_ | … +ChatResponse_Tag = Literal["", "", …] +``` + +- [ ] **Step 3: Rewrite `commandsCodeText`** + +Each command becomes a TypedDict + a `_cmd_string(self) -> str` function + a Response alias. Function body comes from `pySyntaxText` in `Syntax.hs:160` (no changes to that function — it's already correct and used today by Markdown docs). + +```python +# API Commands +# This file is generated automatically. +import json +from typing import TypedDict +from . import _types as T +from . import _responses as CR + +class APICreateMyAddress(TypedDict): + userId: int + +def APICreateMyAddress_cmd_string(self: APICreateMyAddress) -> str: + return '/_address ' + str(self['userId']) + +APICreateMyAddress_Response = CR.UserContactLinkCreated | CR.ChatCmdError +``` + +The `cmdString` body: invoke `pySyntaxText (constrName, params) syntax` analogously to TS's `funcCode`. + +- [ ] **Step 4: Build to verify Haskell compiles** + +``` +cabal build simplex-chat-test +``` +Expected: clean build. + +- [ ] **Step 5: Commit** + +```bash +git add bots/src/API/Docs/Generate/Python.hs +git commit -m "feat(bots): implement Python type generation" +``` + +### Task 1.3: Wire generators into `tests/APIDocs.hs` + +**Files:** +- Modify: `tests/APIDocs.hs` + +- [ ] **Step 1: Add import** + +Insert after line 11 (`import qualified API.Docs.Generate.TypeScript as TS`): + +```haskell +import qualified API.Docs.Generate.Python as Py +``` + +- [ ] **Step 2: Add four `testGenerate` calls** + +Inside `apiDocsTest`, after the existing `describe "TypeScript"` block (line 40-44), add: + +```haskell +describe "Python" $ do + it "generate python commands code" $ testGenerate Py.commandsCodeFile Py.commandsCodeText + it "generate python responses code" $ testGenerate Py.responsesCodeFile Py.responsesCodeText + it "generate python events code" $ testGenerate Py.eventsCodeFile Py.eventsCodeText + it "generate python types code" $ testGenerate Py.typesCodeFile Py.typesCodeText +``` + +- [ ] **Step 3: Create empty target directory** + +```bash +mkdir -p packages/simplex-chat-python/src/simplex_chat/types +``` + +- [ ] **Step 4: Run the API docs tests — they will write the four files** + +``` +cabal test simplex-chat-test --test-options="--match \"API\"" +``` + +First run: tests fail because the on-disk files are empty / missing. The `testGenerate` mechanism overwrites the file with generated content, so the second run passes. + +``` +cabal test simplex-chat-test --test-options="--match \"Python\"" +``` + +Expected: PASS on the second run. + +- [ ] **Step 5: Sanity-check generated output** + +Eyeball each of the four generated files: + +```bash +head -50 packages/simplex-chat-python/src/simplex_chat/types/_types.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_commands.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_responses.py +head -30 packages/simplex-chat-python/src/simplex_chat/types/_events.py +``` + +Verify: starts with `# This file is generated automatically.`, contains valid-looking Python, no obvious junk like `` markers. + +- [ ] **Step 6: Run them through Python parser to verify syntax** + +``` +python -c "import ast; [ast.parse(open(f).read()) for f in ['packages/simplex-chat-python/src/simplex_chat/types/_types.py', 'packages/simplex-chat-python/src/simplex_chat/types/_commands.py', 'packages/simplex-chat-python/src/simplex_chat/types/_responses.py', 'packages/simplex-chat-python/src/simplex_chat/types/_events.py']]" +``` + +Expected: no exception. Any `SyntaxError` indicates a generator bug — fix in `Generate/Python.hs` and re-run cabal test. + +- [ ] **Step 7: Commit generated artifacts** + +```bash +git add tests/APIDocs.hs packages/simplex-chat-python/src/simplex_chat/types/ +git commit -m "feat(bots): wire Python generators into APIDocs test suite" +``` + +### Task 1.4: Add `types/__init__.py` re-exporting namespaces + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/types/__init__.py` + +- [ ] **Step 1: Write namespace re-exports** + +```python +from . import _types as T +from . import _commands as CC +from . import _responses as CR +from . import _events as CEvt + +__all__ = ["T", "CC", "CR", "CEvt"] +``` + +- [ ] **Step 2: Verify import works** + +``` +python -c "from simplex_chat.types import T, CC, CR, CEvt; print(T, CC, CR, CEvt)" +``` + +(Run from `packages/simplex-chat-python/src/`.) + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/types/__init__.py +git commit -m "feat(python): add types namespace re-exports" +``` + +--- + +## Chunk 2: Python package scaffold + +Set up the package skeleton — `pyproject.toml`, version pinning, top-level `__init__.py`, AGPL license, README placeholder. + +### Task 2.1: Create `pyproject.toml` + +**Files:** +- Create: `packages/simplex-chat-python/pyproject.toml` + +- [ ] **Step 1: Write hatchling build config** + +```toml +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-chat" +description = "SimpleX Chat Python library for chat bots" +readme = "README.md" +license = "AGPL-3.0-only" +authors = [{name = "SimpleX Chat"}] +requires-python = ">=3.11" +keywords = ["simplex", "messenger", "chat", "privacy", "security", "bots"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: GNU Affero General Public License v3", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Communications :: Chat", +] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/simplex-chat/simplex-chat/tree/stable/packages/simplex-chat-python" +Issues = "https://github.com/simplex-chat/simplex-chat/issues" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-asyncio>=0.23"] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[tool.hatch.version] +path = "src/simplex_chat/_version.py" + +[tool.hatch.build.targets.wheel] +packages = ["src/simplex_chat"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/pyproject.toml +git commit -m "feat(python): add pyproject.toml with hatchling backend" +``` + +### Task 2.2: Create `_version.py` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/_version.py` + +- [ ] **Step 1: Write version constants** + +```python +"""Single source of truth for both the Python package version and the +simplex-chat-libs release tag we depend on. + +Bump both together for normal releases. For wrapper-only fixes use a PEP 440 +post-release: __version__ = "6.5.1.post1", LIBS_VERSION unchanged. +""" + +__version__ = "6.5.1" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "6.5.1" # simplex-chat-libs release tag (no 'v' prefix) +``` + +- [ ] **Step 2: Verify hatchling can read the version** + +``` +cd packages/simplex-chat-python && python -m build --wheel +``` + +Expected: produces `dist/simplex_chat-6.5.1-py3-none-any.whl`. (Wheel will be incomplete — only the types module is in src/ at this point — but build should succeed.) + +Clean up: `rm -rf packages/simplex-chat-python/dist packages/simplex-chat-python/src/simplex_chat.egg-info` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_version.py +git commit -m "feat(python): add _version.py with package + libs version pinning" +``` + +### Task 2.3: Add `py.typed` marker, README placeholder, AGPL license + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/py.typed` +- Create: `packages/simplex-chat-python/README.md` +- Create: `packages/simplex-chat-python/LICENSE` + +- [ ] **Step 1: Touch the empty `py.typed` marker** + +```bash +touch packages/simplex-chat-python/src/simplex_chat/py.typed +``` + +- [ ] **Step 2: Copy AGPL license from a sibling package** + +```bash +cp packages/simplex-chat-nodejs/LICENSE packages/simplex-chat-python/LICENSE +``` + +- [ ] **Step 3: Write a minimal README** + +```markdown +# SimpleX Chat Python library + +Python 3 client library for [SimpleX Chat](https://simplex.chat) bots. + +Equivalent to the [Node.js library](https://www.npmjs.com/package/simplex-chat). + +## Installation + + pip install simplex-chat + +Requires Python 3.11+. + +## Quick start + +[example to be added] + +## License + +[AGPL-3.0](./LICENSE) +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/py.typed \ + packages/simplex-chat-python/README.md \ + packages/simplex-chat-python/LICENSE +git commit -m "feat(python): add py.typed marker, README, AGPL license" +``` + +### Task 2.4: Top-level `__init__.py` with empty exports + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/__init__.py` + +- [ ] **Step 1: Write a placeholder that re-exports from submodules as they appear** + +```python +"""SimpleX Chat — Python client library for chat bots.""" + +from ._version import __version__ + +__all__ = ["__version__"] +``` + +(Will be expanded as `Bot`, `ChatApi`, etc. land in later phases.) + +- [ ] **Step 2: Verify import** + +``` +python -c "import simplex_chat; print(simplex_chat.__version__)" +``` + +Run from `packages/simplex-chat-python/src/`. + +Expected: prints `6.5.1`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__init__.py +git commit -m "feat(python): add top-level package __init__.py" +``` + +--- + +## Chunk 3: Native FFI layer (`_native.py`) + +Lazy lib download, platform detection, ctypes signatures, `hs_init_with_rtsopts`, atomic install, buffer ownership. Single most-error-prone piece of the package — give it tests. + +### Task 3.1: `_native.py` — platform detection + URL building + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/_native.py` +- Create: `packages/simplex-chat-python/tests/test_native_url.py` + +- [ ] **Step 1: Write platform-detection tests** + +```python +# tests/test_native_url.py +from unittest.mock import patch +import pytest +from simplex_chat._native import _platform_tag, _libs_url, _libname + +@patch("sys.platform", "linux") +@patch("platform.machine", return_value="x86_64") +def test_platform_linux_x64(_): + assert _platform_tag() == "linux-x86_64" + +@patch("sys.platform", "darwin") +@patch("platform.machine", return_value="arm64") +def test_platform_macos_arm64(_): + assert _platform_tag() == "macos-aarch64" + +@patch("sys.platform", "win32") +@patch("platform.machine", return_value="AMD64") +def test_platform_windows_x64(_): + assert _platform_tag() == "windows-x86_64" + +@patch("sys.platform", "freebsd") +@patch("platform.machine", return_value="x86_64") +def test_platform_unsupported(_): + with pytest.raises(RuntimeError, match="Unsupported"): + _platform_tag() + +def test_libname_per_platform(): + with patch("sys.platform", "linux"): + assert _libname() == "libsimplex.so" + with patch("sys.platform", "darwin"): + assert _libname() == "libsimplex.dylib" + with patch("sys.platform", "win32"): + assert _libname() == "libsimplex.dll" + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_sqlite(_): + assert _libs_url("sqlite") == \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \ + "v6.5.1/simplex-chat-libs-linux-x86_64.zip" + +@patch("simplex_chat._native._platform_tag", return_value="linux-x86_64") +def test_url_postgres(_): + assert _libs_url("postgres") == \ + "https://github.com/simplex-chat/simplex-chat-libs/releases/download/" \ + "v6.5.1/simplex-chat-libs-linux-x86_64-postgres.zip" +``` + +- [ ] **Step 2: Write `_native.py` skeleton with platform + URL helpers** + +```python +"""Native libsimplex loader: platform detection, lazy download, ctypes setup. + +Internal — users interact with `Bot` / `ChatApi`, never with this module. +""" +from __future__ import annotations + +import ctypes +import errno +import os +import platform +import sys +import tempfile +import threading +import urllib.request +import zipfile +from ctypes import POINTER, c_char_p, c_int, c_uint8, c_void_p +from pathlib import Path +from typing import Literal + +from ._version import LIBS_VERSION + +Backend = Literal["sqlite", "postgres"] + +_GITHUB_REPO = "simplex-chat/simplex-chat-libs" + +_PLATFORM_MAP = { + "linux": ("linux", {"x86_64": "x86_64", "aarch64": "aarch64"}), + "darwin": ("macos", {"x86_64": "x86_64", "arm64": "aarch64"}), + "win32": ("windows", {"AMD64": "x86_64", "x86_64": "x86_64"}), +} + +_LIBNAME = {"linux": "libsimplex.so", "darwin": "libsimplex.dylib", "win32": "libsimplex.dll"} + +SUPPORTED = ( + "linux-x86_64", "linux-aarch64", + "macos-x86_64", "macos-aarch64", + "windows-x86_64", +) + + +def _platform_tag() -> str: + info = _PLATFORM_MAP.get(sys.platform) + if not info: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + sysname, archs = info + arch = archs.get(platform.machine()) + if not arch: + raise RuntimeError(f"Unsupported architecture: {sys.platform}/{platform.machine()}") + tag = f"{sysname}-{arch}" + if tag not in SUPPORTED: + raise RuntimeError(f"Unsupported combination: {tag}; supported: {SUPPORTED}") + return tag + + +def _libname() -> str: + return _LIBNAME[sys.platform] + + +def _libs_url(backend: Backend) -> str: + suffix = "-postgres" if backend == "postgres" else "" + return ( + f"https://github.com/{_GITHUB_REPO}/releases/download/" + f"v{LIBS_VERSION}/simplex-chat-libs-{_platform_tag()}{suffix}.zip" + ) +``` + +- [ ] **Step 3: Run tests** + +``` +cd packages/simplex-chat-python && pip install -e . && pip install pytest +PYTHONPATH=src pytest tests/test_native_url.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py \ + packages/simplex-chat-python/tests/test_native_url.py +git commit -m "feat(python): _native platform detection + URL building" +``` + +### Task 3.2: `_native.py` — cache resolution + lazy download + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/_native.py` +- Create: `packages/simplex-chat-python/tests/test_native_cache.py` + +- [ ] **Step 1: Write cache-resolution tests** + +```python +# tests/test_native_cache.py +import zipfile +from pathlib import Path + +import pytest + +from simplex_chat._native import _cache_root, _resolve_libs_dir, _download + + +def test_cache_root_linux(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _cache_root() == tmp_path / "simplex-chat" + +def test_cache_root_macos(tmp_path, monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path) + assert _cache_root() == tmp_path / "Library" / "Caches" / "simplex-chat" + +def test_override_via_env(tmp_path, monkeypatch): + # _resolve_libs_dir intentionally does not validate the override directory — + # it returns it verbatim; the eventual ctypes.CDLL call surfaces any mistake. + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + assert _resolve_libs_dir("sqlite") == tmp_path + +def test_resolve_downloads_when_missing(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + called = {} + def fake_download(target_root: Path, backend: str) -> None: + called["target"] = target_root + called["backend"] = backend + target_root.mkdir(parents=True, exist_ok=True) + (target_root / "libsimplex.so").touch() + + monkeypatch.setattr("simplex_chat._native._download", fake_download) + libs_dir = _resolve_libs_dir("sqlite") + assert libs_dir == tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + assert called["backend"] == "sqlite" + assert (libs_dir / "libsimplex.so").exists() + +def test_resolve_uses_cache_on_second_call(tmp_path, monkeypatch): + monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path)) + monkeypatch.setattr("sys.platform", "linux") + cached = tmp_path / "simplex-chat" / "v6.5.1" / "sqlite" + cached.mkdir(parents=True) + (cached / "libsimplex.so").touch() + # Should NOT call _download — use the cached file. + monkeypatch.setattr("simplex_chat._native._download", + lambda *a: pytest.fail("download should not be called")) + assert _resolve_libs_dir("sqlite") == cached + +def test_postgres_on_macos_rejected(monkeypatch): + monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "macos-aarch64") + with pytest.raises(RuntimeError, match="postgres.*linux-x86_64"): + _resolve_libs_dir("postgres") + +def test_atomic_install(tmp_path, monkeypatch): + """Build a fake libs zip, mock urlretrieve, verify extraction + atomic rename.""" + # Build zip: libs/libsimplex.so + libs/libHS-stub.so + src = tmp_path / "src" / "libs" + src.mkdir(parents=True) + (src / "libsimplex.so").write_text("fake-so") + (src / "libHS-stub.so").write_text("fake-hs") + zip_path = tmp_path / "fake-libs.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in src.iterdir(): + zf.write(f, f"libs/{f.name}") + + def fake_urlretrieve(url: str, dest: str) -> None: + import shutil + shutil.copy(zip_path, dest) + + monkeypatch.setattr("urllib.request.urlretrieve", fake_urlretrieve) + monkeypatch.setattr("simplex_chat._native._platform_tag", lambda: "linux-x86_64") + + target = tmp_path / "out" + _download(target, "sqlite") + assert (target / "libsimplex.so").read_text() == "fake-so" + assert (target / "libHS-stub.so").read_text() == "fake-hs" +``` + +- [ ] **Step 2: Implement `_cache_root`, `_resolve_libs_dir`, `_download`** + +Append to `_native.py`: + +```python +def _cache_root() -> Path: + if sys.platform == "darwin": + return Path.home() / "Library" / "Caches" / "simplex-chat" + if sys.platform == "win32": + return Path(os.environ["LOCALAPPDATA"]) / "simplex-chat" + base = os.environ.get("XDG_CACHE_HOME") or str(Path.home() / ".cache") + return Path(base) / "simplex-chat" + + +def _resolve_libs_dir(backend: Backend) -> Path: + if override := os.environ.get("SIMPLEX_LIBS_DIR"): + return Path(override) + if backend == "postgres" and _platform_tag() != "linux-x86_64": + raise RuntimeError( + "postgres backend is only supported on linux-x86_64; " + f"current platform is {_platform_tag()}" + ) + target = _cache_root() / f"v{LIBS_VERSION}" / backend + if not (target / _libname()).exists(): + _download(target, backend) + return target + + +def _download(target: Path, backend: Backend) -> None: + """Download libs zip → atomic rename into `target`. Concurrent processes safe. + + Atomicity strategy: each process extracts to its own sibling tempdir on the same + filesystem, then `os.rename` the `libs/` subdir to `target`. POSIX `os.rename` + onto a NON-EXISTENT path is atomic; if the target exists (another process won + the race), `os.rename` fails on most platforms — we then verify the winner has + what we need and proceed. NEVER rmtree the target: that creates a TOCTOU + window where another process is reading/loading the file we're deleting. + """ + target.parent.mkdir(parents=True, exist_ok=True) + print( + f"Downloading libsimplex ({_platform_tag()}, {backend}) " + f"v{LIBS_VERSION} ...", + file=sys.stderr, + flush=True, + ) + with tempfile.TemporaryDirectory(dir=target.parent) as tmp: + zip_path = Path(tmp) / "libs.zip" + urllib.request.urlretrieve(_libs_url(backend), zip_path) + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + # zip layout: /libs/libsimplex.* + libHS*.* + extracted_libs = Path(tmp) / "libs" + if not extracted_libs.is_dir(): + raise RuntimeError(f"libs/ missing from {_libs_url(backend)}") + try: + os.rename(extracted_libs, target) + except OSError as e: + # EEXIST / ENOTEMPTY mean another process won the race — fall through + # and check that the winner left a usable libsimplex behind. Anything + # else (ENOSPC, EACCES, EROFS, Windows codes mapped to None) is a real + # failure and must propagate. Same VERSION cached → same content → + # safe to proceed once we've confirmed the file is there. + if e.errno not in (errno.EEXIST, errno.ENOTEMPTY): + raise + if not (target / _libname()).exists(): + raise RuntimeError( + f"another process partially populated {target} but libsimplex " + f"is missing; remove the directory manually and retry" + ) from e +``` + +- [ ] **Step 3: Run tests** + +``` +PYTHONPATH=src pytest tests/test_native_cache.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py \ + packages/simplex-chat-python/tests/test_native_cache.py +git commit -m "feat(python): _native cache resolution and lazy download" +``` + +### Task 3.3: `_native.py` — ctypes signatures, `hs_init`, lib loader + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/_native.py` + +- [ ] **Step 1: Append the loader** + +(Imports for `ctypes`, `threading`, and the `from ctypes import …` line were already hoisted to the top of `_native.py` in Task 3.1 — do not re-add them here.) + +```python +_lock = threading.Lock() +_lib: ctypes.CDLL | None = None +_libc: ctypes.CDLL | None = None +_backend: Backend | None = None + + +def _load_libc() -> ctypes.CDLL: + if sys.platform == "win32": + return ctypes.CDLL("msvcrt") + return ctypes.CDLL(None) # libc on POSIX is the process's own symbol table + + +def _setup_signatures(lib: ctypes.CDLL) -> None: + """Declare argtypes/restype for the 8 chat_* functions exported by libsimplex. + + All result strings come back as raw c_void_p so the caller can free them + after copying — matches HandleCResult in cpp/simplex.cc:157-165. + """ + lib.chat_migrate_init.argtypes = [c_char_p, c_char_p, c_char_p, POINTER(c_void_p)] + lib.chat_migrate_init.restype = c_void_p + lib.chat_close_store.argtypes = [c_void_p] + lib.chat_close_store.restype = c_void_p + lib.chat_send_cmd.argtypes = [c_void_p, c_char_p] + lib.chat_send_cmd.restype = c_void_p + lib.chat_recv_msg_wait.argtypes = [c_void_p, c_int] + lib.chat_recv_msg_wait.restype = c_void_p + lib.chat_write_file.argtypes = [c_void_p, c_char_p, POINTER(c_uint8), c_int] + lib.chat_write_file.restype = c_void_p + lib.chat_read_file.argtypes = [c_char_p, c_char_p, c_char_p] + lib.chat_read_file.restype = POINTER(c_uint8) + lib.chat_encrypt_file.argtypes = [c_void_p, c_char_p, c_char_p] + lib.chat_encrypt_file.restype = c_void_p + lib.chat_decrypt_file.argtypes = [c_char_p, c_char_p, c_char_p, c_char_p] + lib.chat_decrypt_file.restype = c_void_p + + +def _hs_init(lib: ctypes.CDLL) -> None: + """Initialize the Haskell runtime exactly once. Mirrors cpp/simplex.cc:13-32.""" + if sys.platform == "win32": + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"--install-signal-handlers=no"] + else: + argv_strs = [b"simplex", b"+RTS", b"-A64m", b"-H64m", b"-xn", b"--install-signal-handlers=no"] + argc = c_int(len(argv_strs)) + arr = (c_char_p * (len(argv_strs) + 1))(*argv_strs, None) + arr_ptr = ctypes.byref(ctypes.cast(arr, POINTER(c_char_p))) + lib.hs_init_with_rtsopts.argtypes = [POINTER(c_int), POINTER(POINTER(c_char_p))] + lib.hs_init_with_rtsopts.restype = None + lib.hs_init_with_rtsopts(ctypes.byref(argc), arr_ptr) + + +def lib_for(backend: Backend) -> ctypes.CDLL: + """Resolve, load, and initialize libsimplex for the given backend. + + Idempotent for the same backend; raises if called with a different backend. + Concurrent calls serialize on the module-level lock. + """ + global _lib, _libc, _backend + with _lock: + if _lib is not None: + if _backend != backend: + raise RuntimeError( + f"libsimplex already loaded with backend={_backend!r}; " + f"cannot switch to {backend!r} in the same process" + ) + return _lib + libs_dir = _resolve_libs_dir(backend) + lib = ctypes.CDLL(str(libs_dir / _libname())) + _setup_signatures(lib) + _hs_init(lib) + _libc = _load_libc() + _lib = lib + _backend = backend + return lib + + +def libc() -> ctypes.CDLL: + """libc — needed by `core` to free Haskell-allocated result strings.""" + if _libc is None: + raise RuntimeError("lib_for() must be called before libc()") + return _libc +``` + +- [ ] **Step 2: Sanity-check imports** + +``` +PYTHONPATH=src python -c "import simplex_chat._native; print('ok')" +``` + +Expected: prints `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/_native.py +git commit -m "feat(python): _native ctypes signatures, hs_init, lib loader" +``` + +--- + +## Chunk 4: Core wrapper (`core.py`) + +Typed async wrappers around the 8 FFI functions. Handles JSON parse, buffer free, error translation. No public API yet — that lands in `ChatApi`. + +### Task 4.1: `core.py` — exceptions, enums, `chat_send_cmd`, `chat_recv_msg_wait` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/core.py` + +- [ ] **Step 1: Write the core module** + +```python +"""Internal typed async wrapper around libsimplex's 8 C ABI functions. + +Users interact with `Bot` / `ChatApi`. This module is exposed as +`simplex_chat.core` for tests and the api.ChatApi class only. +""" +from __future__ import annotations + +import asyncio +import ctypes +import json +from enum import StrEnum +from typing import TypedDict + +from . import _native +from .types import T, CR, CEvt + + +class ChatAPIError(Exception): + """Raised when chat_send_cmd / chat_recv_msg_wait returns a chat error.""" + def __init__(self, message: str, chat_error: T.ChatError | None = None): + super().__init__(message) + self.chat_error = chat_error + + +class ChatInitError(Exception): + """Raised when chat_migrate_init returns a DBMigrationResult error.""" + def __init__(self, message: str, db_migration_error): + super().__init__(message) + self.db_migration_error = db_migration_error + + +class MigrationConfirmation(StrEnum): + YES_UP = "yesUp" + YES_UP_DOWN = "yesUpDown" + CONSOLE = "console" + ERROR = "error" + + +class CryptoArgs(TypedDict): # wire-format JSON; camelCase fields + fileKey: str + fileNonce: str + + +def _read_and_free(ptr: int | None) -> str: + """Copy a Haskell-allocated null-terminated UTF-8 string and free its buffer. + + Mirrors HandleCResult in packages/simplex-chat-nodejs/cpp/simplex.cc:157-165. + """ + if not ptr: + raise RuntimeError("null pointer returned from libsimplex") + try: + return ctypes.string_at(ptr).decode("utf-8") + finally: + _native.libc().free(ctypes.c_void_p(ptr)) + + +async def chat_send_cmd(ctrl: int, cmd: str) -> CR.ChatResponse: + def _call() -> str: + ptr = _native._lib.chat_send_cmd(ctrl, cmd.encode("utf-8")) + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat command error: {err.get('type')}", err) + raise ChatAPIError(f"invalid chat command result: {raw[:200]}") + + +async def chat_recv_msg_wait(ctrl: int, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: + def _call() -> str: + ptr = _native._lib.chat_recv_msg_wait(ctrl, wait_us) + if not ptr: + return "" + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + if not raw: + return None + parsed = json.loads(raw) + if "result" in parsed and isinstance(parsed["result"], dict): + return parsed["result"] # type: ignore[return-value] + err = parsed.get("error") + if isinstance(err, dict): + raise ChatAPIError(f"chat event error: {err.get('type')}", err) + raise ChatAPIError(f"invalid chat event: {raw[:200]}") +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.core import chat_send_cmd, ChatAPIError, MigrationConfirmation; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/core.py +git commit -m "feat(python): core typed wrappers for chat_send_cmd + chat_recv_msg_wait" +``` + +### Task 4.2: `core.py` — remaining FFI functions (`chat_migrate_init`, `chat_close_store`, file ops) + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/core.py` + +- [ ] **Step 1: Append the remaining functions** + +```python +async def chat_migrate_init( + db_path: str, db_key: str, confirm: MigrationConfirmation +) -> int: + """Initialize chat controller. Returns opaque ctrl pointer as Python int.""" + def _call() -> tuple[int, str]: + ctrl = ctypes.c_void_p() + ptr = _native._lib.chat_migrate_init( + db_path.encode("utf-8"), + db_key.encode("utf-8"), + confirm.encode("utf-8"), + ctypes.byref(ctrl), + ) + return (ctrl.value or 0, _read_and_free(ptr)) + ctrl_val, raw = await asyncio.to_thread(_call) + parsed = json.loads(raw) + if parsed.get("type") == "ok": + return ctrl_val + raise ChatInitError( + "Database or migration error (see db_migration_error)", + parsed, + ) + + +async def chat_close_store(ctrl: int) -> None: + def _call() -> str: + ptr = _native._lib.chat_close_store(ctrl) + return _read_and_free(ptr) + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +async def chat_write_file(ctrl: int, path: str, data: bytes) -> CryptoArgs: + def _call() -> str: + buf = (ctypes.c_uint8 * len(data)).from_buffer_copy(data) + ptr = _native._lib.chat_write_file(ctrl, path.encode("utf-8"), buf, len(data)) + return _read_and_free(ptr) + raw = await asyncio.to_thread(_call) + return _crypto_args_result(raw) + + +async def chat_read_file(path: str, args: CryptoArgs) -> bytes: + def _call() -> bytes: + ptr = _native._lib.chat_read_file( + path.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + ) + if not ptr: + raise RuntimeError("chat_read_file returned null") + addr = ctypes.cast(ptr, ctypes.c_void_p).value or 0 + try: + status = ctypes.cast(addr, ctypes.POINTER(ctypes.c_uint8))[0] + if status == 1: + msg = ctypes.string_at(addr + 1).decode("utf-8") + raise RuntimeError(msg) + if status != 0: + raise RuntimeError(f"unexpected status {status} from chat_read_file") + length = ctypes.cast(addr + 1, ctypes.POINTER(ctypes.c_uint32))[0] + return ctypes.string_at(addr + 5, length) + finally: + _native.libc().free(ctypes.c_void_p(addr)) + return await asyncio.to_thread(_call) + + +async def chat_encrypt_file(ctrl: int, src: str, dst: str) -> CryptoArgs: + def _call() -> str: + ptr = _native._lib.chat_encrypt_file( + ctrl, src.encode("utf-8"), dst.encode("utf-8") + ) + return _read_and_free(ptr) + return _crypto_args_result(await asyncio.to_thread(_call)) + + +async def chat_decrypt_file(src: str, args: CryptoArgs, dst: str) -> None: + def _call() -> str: + ptr = _native._lib.chat_decrypt_file( + src.encode("utf-8"), + args["fileKey"].encode("utf-8"), + args["fileNonce"].encode("utf-8"), + dst.encode("utf-8"), + ) + return _read_and_free(ptr) + res = await asyncio.to_thread(_call) + if res: + raise RuntimeError(res) + + +def _crypto_args_result(raw: str) -> CryptoArgs: + parsed = json.loads(raw) + if parsed.get("type") == "result": + return parsed["cryptoArgs"] + if parsed.get("type") == "error": + raise RuntimeError(parsed.get("writeError", "unknown write error")) + raise RuntimeError(f"unexpected result: {raw[:200]}") +``` + +- [ ] **Step 2: Re-verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat import core; print(dir(core))" +``` + +Expected: prints a list including `chat_migrate_init`, `chat_close_store`, all eight functions. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/core.py +git commit -m "feat(python): core wrappers for migrate, close, file ops" +``` + +--- + +## Chunk 5: ChatApi class + +Escape-hatch class with the 6 control methods plus ~40 `api_xxx` methods, one per Node `apiXxx`. Repetitive — done in batches grouped by domain. + +### Task 5.1: `api.py` — `ChatApi` class with control methods + `Db` config + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/api.py` + +- [ ] **Step 1: Write Db config dataclasses + ChatApi base** + +```python +"""Low-level escape-hatch API. Most users go through `Bot` instead.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from . import core +from .core import ChatAPIError, ChatInitError, MigrationConfirmation +from .types import CC, CEvt, CR, T + + +@dataclass(slots=True) +class SqliteDb: + file_prefix: str + encryption_key: str | None = None + + +@dataclass(slots=True) +class PostgresDb: + connection_string: str + schema_prefix: str | None = None + + +Db = SqliteDb | PostgresDb + + +def _db_to_migrate_args(db: Db) -> tuple[str, str, str]: + """Returns (path-or-prefix, key-or-conn, backend).""" + if isinstance(db, SqliteDb): + return (db.file_prefix, db.encryption_key or "", "sqlite") + if isinstance(db, PostgresDb): + return (db.schema_prefix or "", db.connection_string, "postgres") + raise TypeError(f"Unknown db: {db!r}") + + +class ChatCommandError(Exception): + def __init__(self, message: str, response: CR.ChatResponse): + super().__init__(message) + self.response = response + + +class ChatApi: + def __init__(self, ctrl: int, backend: str): + self._ctrl = ctrl + self._backend = backend + + @classmethod + async def init( + cls, + db: Db, + confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, + ) -> "ChatApi": + from . import _native + path_or_prefix, key_or_conn, backend = _db_to_migrate_args(db) + # Trigger lazy lib load with the right backend BEFORE chat_migrate_init. + _native.lib_for(backend) # type: ignore[arg-type] + ctrl = await core.chat_migrate_init(path_or_prefix, key_or_conn, confirm) + return cls(ctrl, backend) + + @property + def ctrl(self) -> int: + return self._ctrl + + async def start_chat(self) -> None: + r = await self.send_chat_cmd( + CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + ) + if r.get("type") not in ("chatStarted", "chatRunning"): + raise ChatCommandError("error starting chat", r) + + async def stop_chat(self) -> None: + r = await self.send_chat_cmd("/_stop") + if r.get("type") != "chatStopped": + raise ChatCommandError("error stopping chat", r) + + async def close(self) -> None: + await core.chat_close_store(self._ctrl) + + async def send_chat_cmd(self, cmd: str) -> CR.ChatResponse: + return await core.chat_send_cmd(self._ctrl, cmd) + + async def recv_chat_event(self, wait_us: int = 5_000_000) -> CEvt.ChatEvent | None: + return await core.chat_recv_msg_wait(self._ctrl, wait_us) +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.api import ChatApi, SqliteDb, PostgresDb; print('ok')" +``` + +Expected: `ok`. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/api.py +git commit -m "feat(python): ChatApi base class with control methods + Db config" +``` + +### Task 5.2: `api.py` — implement ~40 `api_xxx` methods + +Mirror methods from `packages/simplex-chat-nodejs/src/api.ts:344-958`. Each method is the same shape: + +```python +async def api_(self, ...args) -> : + r = await self.send_chat_cmd(CC._cmd_string({"...": args, ...})) + if r["type"] == "": + return r[""] + raise ChatCommandError("error ", r) +``` + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/api.py` + +- [ ] **Step 1: Reference the Node lib while implementing** + +```bash +# Open the Node source side-by-side +sed -n '344,958p' packages/simplex-chat-nodejs/src/api.ts | less +``` + +- [ ] **Step 2: Implement methods in groups, one commit per group** + +The Node file groups by domain — port them in the same order: + +| Group | Methods (Node names → Python snake_case) | Lines in api.ts | +|---|---|---| +| Address | apiCreateUserAddress, apiDeleteUserAddress, apiGetUserAddress, apiSetProfileAddress, apiSetAddressSettings | 344-409 | +| Messages | apiSendMessages, apiSendTextMessage, apiSendTextReply, apiUpdateChatItem, apiDeleteChatItems, apiDeleteMemberChatItem, apiChatItemReaction | 411-505 | +| Files | apiReceiveFile, apiCancelFile | 507-525 | +| Groups | apiAddMember, apiJoinGroup, apiAcceptMember, apiSetMembersRole, apiBlockMembersForAll, apiRemoveMembers, apiLeaveGroup, apiListMembers, apiNewGroup, apiUpdateGroupProfile | 527-625 | +| Group links | apiCreateGroupLink, apiSetGroupLinkMemberRole, apiDeleteGroupLink, apiGetGroupLink, apiGetGroupLinkStr | 627-672 | +| Connections | apiCreateLink, apiConnectPlan, apiConnect, apiConnectActiveUser, apiAcceptContactRequest, apiRejectContactRequest | 674-746 | +| Chats | apiListContacts, apiListGroups, apiGetChats, apiDeleteChat, apiSetGroupCustomData, apiSetContactCustomData, apiSetAutoAcceptMemberContacts, apiGetChat | 748-841 | +| Users | apiGetActiveUser, apiCreateActiveUser, apiListUsers, apiSetActiveUser, apiDeleteUser, apiUpdateProfile, apiSetContactPrefs | 843-928 | +| Member contacts | apiCreateMemberContact, apiSendMemberContactInvitation | 930-957 | + +For each method: +- TS `apiCreateUserAddress(userId)` → Python `api_create_user_address(self, user_id: int) -> T.CreatedConnLink` +- Use the autogenerated `CC._cmd_string({...})` to build the command string. Field names inside the dict are camelCase wire format. +- Use type narrowing on `r["type"]` to extract the expected response field. + +Example port: + +```python +async def api_create_user_address(self, user_id: int) -> T.CreatedConnLink: + r = await self.send_chat_cmd(CC.APICreateMyAddress_cmd_string({"userId": user_id})) + if r["type"] == "userContactLinkCreated": + return r["connLinkContact"] + raise ChatCommandError("error creating user address", r) +``` + +Commit pattern: one commit per group from the table above. Commit messages: + +```bash +git commit -m "feat(python): ChatApi address methods" +git commit -m "feat(python): ChatApi message methods" +git commit -m "feat(python): ChatApi file methods" +# … etc, one per group +``` + +- [ ] **Step 3: After all groups land, verify all methods are present** + +```bash +grep -c "async def api_" packages/simplex-chat-python/src/simplex_chat/api.py +``` + +Expected: ≥40 (matches the count of `apiXxx` methods in api.ts). + +- [ ] **Step 4: Verify import after all groups** + +``` +PYTHONPATH=src python -c "from simplex_chat.api import ChatApi; api = ChatApi.__init__; print('ok')" +``` + +Expected: `ok`. + +--- + +## Chunk 6: Bot class + +User-facing `Bot`: decorator-registered handlers, kwarg filters, `Message` wrapper with content-narrowed subclasses, dual lifecycle, middleware. + +### Task 6.1: `Message` wrapper class + content-narrowed aliases + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Write `Message` and aliases** + +```python +"""User-facing `Bot` API: decorators, filters, Message wrapper, lifecycle.""" +from __future__ import annotations + +import asyncio +import logging +import re +import signal as _signal +from dataclasses import dataclass +from typing import ( + Any, Awaitable, Callable, Generic, Literal, TYPE_CHECKING, TypeVar, overload +) + +from . import _native +from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb +from .core import ChatAPIError, MigrationConfirmation +from .types import CC, CEvt, CR, T + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +log = logging.getLogger("simplex_chat") + +C = TypeVar("C", bound=T.MsgContent) + + +@dataclass(slots=True) +class BotProfile: + display_name: str + full_name: str = "" + short_descr: str | None = None + image: str | None = None + + +@dataclass(slots=True) +class BotCommand: + keyword: str + label: str + + +@dataclass(slots=True, frozen=True) +class ParsedCommand: + keyword: str + args: str + + +@dataclass(slots=True, frozen=True) +class Message(Generic[C]): + chat_item: T.AChatItem + content: C + bot: "Bot" + + @property + def chat_info(self) -> T.ChatInfo: + return self.chat_item["chatInfo"] + + @property + def text(self) -> str | None: + c = self.content + if isinstance(c, dict): + return c.get("text") # type: ignore[return-value] + return None + + async def reply(self, text: str) -> "Message": + items = await self.bot.api.api_send_text_reply(self.chat_item, text) + return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot) + + async def reply_content(self, content: T.MsgContent) -> "Message": + items = await self.bot.api.api_send_messages( + self.chat_info, [{"msgContent": content, "mentions": {}}] + ) + return Message(chat_item=items[0], content=items[0]["chatItem"]["content"], bot=self.bot) + + async def react(self, emoji: str) -> None: + # Implementation defers to ChatApi.api_chat_item_reaction + ... + + async def delete(self) -> None: ... + async def forward(self, to: T.ChatRef) -> "Message": ... + + +# Concrete narrowed aliases — exported from package __init__.py +TextMessage = Message[T.MsgContent_Text] +ImageMessage = Message[T.MsgContent_Image] +FileMessage = Message[T.MsgContent_File] +VoiceMessage = Message[T.MsgContent_Voice] +VideoMessage = Message[T.MsgContent_Video] +LinkMessage = Message[T.MsgContent_Link] +# … one per T.MsgContent_* variant; full list mirrors what's emitted in _types.py +``` + +- [ ] **Step 2: Verify import** + +``` +PYTHONPATH=src python -c "from simplex_chat.bot import Message, TextMessage, BotProfile; print('ok')" +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot module skeleton — Message wrapper + aliases" +``` + +### Task 6.2: Filter compilation (`filters.py`) + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/filters.py` +- Create: `packages/simplex-chat-python/tests/test_filters.py` + +- [ ] **Step 1: Write filter tests** + +```python +# tests/test_filters.py +import re +import pytest +from simplex_chat.filters import compile_message_filter + +def _msg(content_type="text", text=None, chat_type="direct", + from_role=None, from_contact_id=None, group_id=None): + """Build a minimal mock Message-like object for filter testing.""" + class M: + pass + m = M() + m.content = {"type": content_type, "text": text} if text is not None else {"type": content_type} + m.chat_item = {"chatInfo": { + "type": chat_type, + **({"groupInfo": {"groupId": group_id}} if chat_type == "group" else {}), + }} + # Sender extraction is implementation-detail of the filter — keep test fixture pragmatic. + m._from_role = from_role + m._from_contact_id = from_contact_id + return m + +def test_no_filters_matches_all(): + f = compile_message_filter({}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + +def test_content_type_singular(): + f = compile_message_filter({"content_type": "text"}) + assert f(_msg(content_type="text")) + assert not f(_msg(content_type="image")) + +def test_content_type_tuple_or(): + f = compile_message_filter({"content_type": ("text", "image")}) + assert f(_msg(content_type="text")) + assert f(_msg(content_type="image")) + assert not f(_msg(content_type="voice")) + +def test_text_exact(): + f = compile_message_filter({"text": "hello"}) + assert f(_msg(text="hello")) + assert not f(_msg(text="world")) + +def test_text_regex(): + f = compile_message_filter({"text": re.compile(r"^\d+$")}) + assert f(_msg(text="123")) + assert not f(_msg(text="abc")) + +def test_when_callable(): + f = compile_message_filter({"when": lambda m: m.content["type"] == "voice"}) + assert f(_msg(content_type="voice")) + assert not f(_msg(content_type="text")) + +def test_combined_and(): + f = compile_message_filter({"content_type": "text", "text": re.compile(r"\d")}) + assert f(_msg(content_type="text", text="abc123")) + assert not f(_msg(content_type="text", text="abc")) + assert not f(_msg(content_type="image")) +``` + +- [ ] **Step 2: Implement `compile_message_filter`** + +```python +# filters.py +from __future__ import annotations +import re +from typing import Any, Callable + +def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: + """Compile filter kwargs into a single predicate function. + + Multiple kwargs combine with AND; tuples within a kwarg combine with OR. + `when` is the last predicate evaluated. + """ + predicates: list[Callable[[Any], bool]] = [] + + if (ct := kw.get("content_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.content.get("type") in ct_set) + + if (t := kw.get("text")) is not None: + if isinstance(t, re.Pattern): + predicates.append(lambda m: bool(t.search(m.content.get("text", "") or ""))) + else: + predicates.append(lambda m: m.content.get("text") == t) + + if (ct := kw.get("chat_type")) is not None: + ct_set = (ct,) if isinstance(ct, str) else tuple(ct) + predicates.append(lambda m: m.chat_item["chatInfo"]["type"] in ct_set) + + if (gid := kw.get("group_id")) is not None: + gid_set = (gid,) if isinstance(gid, int) else tuple(gid) + def gid_match(m: Any) -> bool: + ci = m.chat_item["chatInfo"] + return ci["type"] == "group" and ci["groupInfo"]["groupId"] in gid_set + predicates.append(gid_match) + + # from_role, from_contact_id, from_member_id are looked up via helpers in filters.py + # — defer to integration with ChatInfo / GroupMember accessors implemented later. + + if (when := kw.get("when")) is not None: + predicates.append(when) + + if not predicates: + return lambda _m: True + return lambda m: all(p(m) for p in predicates) +``` + +- [ ] **Step 3: Run filter tests** + +``` +PYTHONPATH=src pytest tests/test_filters.py -v +``` + +Expected: all PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/filters.py \ + packages/simplex-chat-python/tests/test_filters.py +git commit -m "feat(python): filter kwarg compilation + tests" +``` + +### Task 6.3: `Bot` class — construction, decorators, registration + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Add `Bot` class with `__init__`, decorator methods, registration storage** + +```python +# Append to bot.py + +MessageHandler = Callable[[Message], Awaitable[None]] +CommandHandler = Callable[[Message, ParsedCommand], Awaitable[None]] +EventHandler = Callable[[CEvt.ChatEvent], Awaitable[None]] +# Note: handlers are async-only (matches spec). Users must `async def handler(...)`. + + +class Middleware: + async def __call__( + self, + handler: Callable[[Message, dict[str, object]], Awaitable[None]], + message: Message, + data: dict[str, object], + ) -> None: + await handler(message, data) + + +class Bot: + def __init__( + self, + *, + profile: BotProfile, + db: Db, + welcome: str | T.MsgContent | None = None, + commands: list[BotCommand] | None = None, + confirm_migrations: MigrationConfirmation = MigrationConfirmation.YES_UP, + create_address: bool = True, + update_address: bool = True, + update_profile: bool = True, + auto_accept: bool = True, + business_address: bool = False, + allow_files: bool = False, + use_bot_profile: bool = True, + log_contacts: bool = True, + log_network: bool = False, + ) -> None: + self._profile = profile + self._db = db + self._welcome = welcome + self._commands = commands or [] + self._confirm_migrations = confirm_migrations + self._opts = { + "create_address": create_address, + "update_address": update_address, + "update_profile": update_profile, + "auto_accept": auto_accept, + "business_address": business_address, + "allow_files": allow_files, + "use_bot_profile": use_bot_profile, + "log_contacts": log_contacts, + "log_network": log_network, + } + self._api: ChatApi | None = None + self._serving = False + self._stop_event = asyncio.Event() + self._message_handlers: list[tuple[Callable[[Message], bool], MessageHandler]] = [] + self._command_handlers: list[tuple[tuple[str, ...], Callable[[Message], bool], CommandHandler]] = [] + self._event_handlers: dict[str, list[EventHandler]] = {} + self._middleware: list[Middleware] = [] + + @property + def api(self) -> ChatApi: + if self._api is None: + raise RuntimeError("Bot not initialized — call bot.run() or use `async with bot:`") + return self._api + + # --- decorator registration (overloads omitted for brevity; see spec for full list) --- + + def on_message(self, **filter_kw: Any) -> Callable[[MessageHandler], MessageHandler]: + from .filters import compile_message_filter + predicate = compile_message_filter(filter_kw) + def deco(fn: MessageHandler) -> MessageHandler: + self._message_handlers.append((predicate, fn)) + return fn + return deco + + def on_command( + self, name: str | tuple[str, ...], **filter_kw: Any + ) -> Callable[[CommandHandler], CommandHandler]: + names = (name,) if isinstance(name, str) else tuple(name) + from .filters import compile_message_filter + predicate = compile_message_filter(filter_kw) + def deco(fn: CommandHandler) -> CommandHandler: + self._command_handlers.append((names, predicate, fn)) + return fn + return deco + + def on_event(self, event: CEvt.Tag, /) -> Callable[[EventHandler], EventHandler]: + def deco(fn: EventHandler) -> EventHandler: + self._event_handlers.setdefault(event, []).append(fn) + return fn + return deco + + def use(self, middleware: Middleware) -> None: + self._middleware.append(middleware) +``` + +- [ ] **Step 2: Discover the full `MsgContent` variant list from generated types** + +After Chunk 1 lands, the generated file lists every variant. Get the canonical list: + +```bash +grep -E '^class MsgContent_' packages/simplex-chat-python/src/simplex_chat/types/_types.py | sed 's/class \([^(]*\).*/\1/' +``` + +Expected output (approximately — exact list comes from Haskell `MsgContent` defined at `bots/src/API/Docs/Types.hs:309` with prefix `"MC"`): + +``` +MsgContent_Text +MsgContent_Link +MsgContent_Image +MsgContent_Video +MsgContent_Voice +MsgContent_File +MsgContent_Report +MsgContent_Chat +MsgContent_Unknown +``` + +For each variant in that list, define both a `Message` alias (in Step 1 above — extend the alias block to cover every variant) and one decorator overload (this step). + +- [ ] **Step 3: Add the typed overloads — one per variant from Step 2** + +After the plain `on_message` definition, add a typed overload for each variant. The pattern below shows two; repeat for every variant the previous step found: + +```python + # Type-only overloads — compiler-visible, no runtime effect. + # MUST cover every variant from `grep '^class MsgContent_' _types.py`. + @overload + def on_message(self, *, content_type: Literal["text"], **rest: Any + ) -> Callable[[Callable[[TextMessage], Awaitable[None]]], + Callable[[TextMessage], Awaitable[None]]]: ... + @overload + def on_message(self, *, content_type: Literal["image"], **rest: Any + ) -> Callable[[Callable[[ImageMessage], Awaitable[None]]], + Callable[[ImageMessage], Awaitable[None]]]: ... + # … one per MsgContent variant; verify count matches Step 2's grep output … + @overload + def on_message(self, **rest: Any + ) -> Callable[[MessageHandler], MessageHandler]: ... +``` + +The per-variant tag string (`"text"`, `"image"`, …) is the lowercased suffix after `MsgContent_` — see the `Literal[...]` member on each generated TypedDict's `type` field for the canonical spelling. + +- [ ] **Step 3: Verify import + decorator binding** + +```python +# tests/test_bot_registration.py +from simplex_chat.bot import Bot, BotProfile +from simplex_chat.api import SqliteDb + +def test_decorator_registers_handler(): + bot = Bot(profile=BotProfile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test")) + @bot.on_message(content_type="text") + async def h(msg): pass + assert len(bot._message_handlers) == 1 +``` + +``` +PYTHONPATH=src pytest tests/test_bot_registration.py -v +``` + +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py \ + packages/simplex-chat-python/tests/test_bot_registration.py +git commit -m "feat(python): Bot class — construction + decorator registration" +``` + +### Task 6.4: Lifecycle: `run()`, `serve_forever()`, `__aenter__/__aexit__`, `stop()` + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Append lifecycle methods** + +```python +class Bot: + # … existing methods … + + async def __aenter__(self) -> "Bot": + self._api = await ChatApi.init(self._db, self._confirm_migrations) + await self._api.start_chat() + # TODO Task 6.5: profile + address sync via mkBotProfile/createOrUpdateAddress + return self + + async def __aexit__(self, *exc_info: object) -> None: + self.stop() + if self._api is not None: + try: + await self._api.stop_chat() + finally: + await self._api.close() + self._api = None + + def run(self) -> None: + """Blocking entry: runs serve_forever() with a SIGINT handler installed.""" + async def _main() -> None: + async with self: + loop = asyncio.get_running_loop() + if hasattr(_signal, "SIGINT"): + try: + loop.add_signal_handler(_signal.SIGINT, self.stop) + loop.add_signal_handler(_signal.SIGTERM, self.stop) + except NotImplementedError: # Windows + _signal.signal(_signal.SIGINT, lambda *_: self.stop()) + await self.serve_forever() + asyncio.run(_main()) + + async def serve_forever(self) -> None: + if self._serving: + raise RuntimeError("already serving") + self._serving = True + self._stop_event.clear() + try: + await self._receive_loop() + finally: + self._serving = False + + def stop(self) -> None: + self._stop_event.set() + + async def _receive_loop(self) -> None: + while not self._stop_event.is_set(): + try: + event = await self.api.recv_chat_event(wait_us=5_000_000) + except ChatAPIError as e: + log.error("chat event error: %s", e) + continue + if event is None: + continue + await self._dispatch_event(event) +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot lifecycle (run, serve_forever, async-context, stop)" +``` + +### Task 6.5: Event dispatch + handler invocation through middleware + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Append `_dispatch_event` and helpers** + +```python +class Bot: + async def _dispatch_event(self, event: CEvt.ChatEvent) -> None: + # 1. Tag-targeted on_event handlers (registration order) + tag = event["type"] + for h in self._event_handlers.get(tag, []): + try: + await h(event) + except Exception: + log.exception("on_event handler failed") + # 2. If newChatItems → message + command dispatch. + # Tag check narrows the union; pyright sees event as CEvt.ChatEvent_NewChatItems below. + if tag == "newChatItems": + evt: CEvt.ChatEvent_NewChatItems = event # type: ignore[assignment] + for ci in evt["chatItems"]: + content = ci["chatItem"]["content"] + if content["type"] != "rcvMsgContent": + continue + msg_content = content["msgContent"] + msg = Message(chat_item=ci, content=msg_content, bot=self) + await self._dispatch_message(msg) + + async def _dispatch_message(self, msg: Message) -> None: + # Run all matching message handlers + for predicate, handler in self._message_handlers: + if predicate(msg): + await self._invoke_with_middleware(handler, msg) + # Then any matching command handlers + cmd = self._parse_command(msg) + if cmd is not None: + for names, predicate, handler in self._command_handlers: + if cmd.keyword in names and predicate(msg): + await self._invoke_command_with_middleware(handler, msg, cmd) + + async def _invoke_with_middleware( + self, handler: MessageHandler, message: Message + ) -> None: + async def call(m: Message, _data: dict[str, object]) -> None: + await handler(m) + + chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call + # mw=mw, inner=inner bind the loop variable (late-binding fix) + for mw in reversed(self._middleware): + inner = chain + async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None: + await mw(inner, m, d) + chain = _wrapped + + try: + await chain(message, {}) + except Exception: + log.exception("message handler failed") + + async def _invoke_command_with_middleware( + self, handler: CommandHandler, message: Message, cmd: ParsedCommand + ) -> None: + # Same shape as _invoke_with_middleware but the inner call gets cmd too. + async def call(m: Message, _data: dict[str, object]) -> None: + await handler(m, cmd) + chain: Callable[[Message, dict[str, object]], Awaitable[None]] = call + for mw in reversed(self._middleware): + inner = chain + async def _wrapped(m: Message, d: dict[str, object], mw=mw, inner=inner) -> None: + await mw(inner, m, d) + chain = _wrapped + try: + await chain(message, {}) + except Exception: + log.exception("command handler failed") + + @staticmethod + def _parse_command(msg: Message) -> ParsedCommand | None: + text = msg.text + if not text or not text.startswith("/"): + return None + body = text[1:].lstrip() + if not body: + return None + if " " in body: + kw, args = body.split(" ", 1) + return ParsedCommand(keyword=kw, args=args.strip()) + return ParsedCommand(keyword=body, args="") +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot event dispatch + middleware chaining" +``` + +### Task 6.6: Profile + address sync (mirror Node `bot.ts:158-214`) + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/bot.py` + +- [ ] **Step 1: Implement initial profile + address sync inside `__aenter__`** + +Mirror `bot.ts` `createBotUser`, `createOrUpdateAddress`, `updateBotUserProfile`, `mkBotProfile`. Each is straightforward — fetch via `self._api.api_get_active_user()`, compare with `self._profile`, update if `update_profile=True`, etc. Reference: `packages/simplex-chat-nodejs/src/bot.ts:158-214`. + +- [ ] **Step 2: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/bot.py +git commit -m "feat(python): Bot profile + address sync on init" +``` + +### Task 6.7: Update package `__init__.py` to export public API + +**Files:** +- Modify: `packages/simplex-chat-python/src/simplex_chat/__init__.py` + +- [ ] **Step 1: Add exports** + +```python +"""SimpleX Chat — Python client library for chat bots.""" +from ._version import __version__ +from .api import ChatApi, ChatCommandError, Db, PostgresDb, SqliteDb +from .bot import ( + Bot, + BotCommand, + BotProfile, + FileMessage, + ImageMessage, + Message, + Middleware, + ParsedCommand, + TextMessage, + VoiceMessage, + # … all aliases … +) +from .core import ChatAPIError, ChatInitError, MigrationConfirmation, CryptoArgs + +__all__ = [ + "__version__", + # … all the names above … +] +``` + +- [ ] **Step 2: Verify clean import** + +``` +PYTHONPATH=src python -c "from simplex_chat import Bot, TextMessage, SqliteDb, BotProfile; print('ok')" +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__init__.py +git commit -m "feat(python): export public API from package __init__" +``` + +--- + +## Chunk 7: Tests + CLI + +Pytest suite covering native, codegen, smoke; pre-fetch CLI; example bot. + +### Task 7.1: `__main__.py` — `python -m simplex_chat install` + +**Files:** +- Create: `packages/simplex-chat-python/src/simplex_chat/__main__.py` + +- [ ] **Step 1: Write the CLI** + +```python +"""CLI: python -m simplex_chat install [--backend=sqlite|postgres]""" +from __future__ import annotations +import argparse +import sys +from . import _native + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(prog="simplex_chat") + sub = p.add_subparsers(dest="command", required=True) + install = sub.add_parser("install", help="Pre-fetch libsimplex into the user cache") + install.add_argument( + "--backend", choices=["sqlite", "postgres"], default="sqlite", + help="which libsimplex variant to download (default: sqlite)", + ) + args = p.parse_args(argv) + if args.command == "install": + try: + path = _native._resolve_libs_dir(args.backend) + print(f"libsimplex installed at: {path}") + return 0 + except Exception as e: + print(f"install failed: {e}", file=sys.stderr) + return 1 + return 1 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +- [ ] **Step 2: Smoke-check the CLI exists** + +``` +PYTHONPATH=src python -m simplex_chat install --help +``` + +Expected: prints argparse help. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/src/simplex_chat/__main__.py +git commit -m "feat(python): python -m simplex_chat install CLI" +``` + +### Task 7.2: Codegen smoke test + +**Files:** +- Create: `packages/simplex-chat-python/tests/test_codegen.py` + +- [ ] **Step 1: Write the test** + +```python +"""Sanity checks on auto-generated wire types — catches generator regressions.""" +import typing +from simplex_chat.types import T, CC, CR, CEvt + + +def test_types_module_imports(): + """Every generated module imports cleanly with no SyntaxError.""" + assert T is not None and CC is not None and CR is not None and CEvt is not None + + +def test_chat_type_is_literal_enum(): + """ChatType should be a Literal of expected member set.""" + origin = typing.get_origin(T.ChatType) + args = typing.get_args(T.ChatType) + # Python ≥3.11 typing.Literal: origin is Literal, args is the tuple of values + assert "direct" in args + assert "group" in args + assert "local" in args + + +def test_known_command_has_cmd_string(): + s = CC.APICreateMyAddress_cmd_string({"userId": 1}) + assert s == "/_address 1" + + +def test_chat_response_tag_alias_present(): + """ChatResponse_Tag union of literals exists.""" + assert hasattr(CR, "ChatResponse_Tag") +``` + +- [ ] **Step 2: Run** + +``` +PYTHONPATH=src pytest tests/test_codegen.py -v +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/tests/test_codegen.py +git commit -m "test(python): codegen sanity checks" +``` + +### Task 7.3: Smoke test with stub libsimplex + +**Files:** +- Create: `packages/simplex-chat-python/tests/test_smoke.py` +- Create: `packages/simplex-chat-python/tests/_stub_libsimplex.c` + +- [ ] **Step 1: Write a tiny C stub that returns canned JSON** + +```c +// _stub_libsimplex.c — compile to libsimplex.so for smoke testing +#include +#include +#include + +void hs_init_with_rtsopts(int *argc, char ***argv) { (void)argc; (void)argv; } + +static char *dup_str(const char *s) { return strdup(s); } + +char *chat_migrate_init(const char *path, const char *key, const char *confirm, void **ctrl) { + (void)path; (void)key; (void)confirm; + *ctrl = (void*)0x1; + return dup_str("{\"type\":\"ok\"}"); +} + +char *chat_close_store(void *ctrl) { (void)ctrl; return dup_str(""); } + +char *chat_send_cmd(void *ctrl, const char *cmd) { + (void)ctrl; (void)cmd; + return dup_str("{\"result\":{\"type\":\"chatStarted\"}}"); +} + +char *chat_recv_msg_wait(void *ctrl, int wait) { + (void)ctrl; (void)wait; + return NULL; +} +// stubs for the file functions: +char *chat_write_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); } +char *chat_read_file() { return NULL; } +char *chat_encrypt_file() { return dup_str("{\"type\":\"result\",\"cryptoArgs\":{\"fileKey\":\"k\",\"fileNonce\":\"n\"}}"); } +char *chat_decrypt_file() { return dup_str(""); } +``` + +- [ ] **Step 2: Write the smoke test that compiles and uses the stub** + +```python +import asyncio +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path +import pytest + + +@pytest.fixture +def stub_libs_dir(tmp_path): + """Compile _stub_libsimplex.c into a libsimplex.so in tmp_path.""" + src = Path(__file__).parent / "_stub_libsimplex.c" + if sys.platform == "win32": + pytest.skip("stub compilation not implemented for Windows") + libname = "libsimplex.dylib" if sys.platform == "darwin" else "libsimplex.so" + out = tmp_path / libname + cc = shutil.which("cc") or shutil.which("gcc") or pytest.skip("no C compiler") + subprocess.run([cc, "-shared", "-fPIC", str(src), "-o", str(out)], check=True) + return tmp_path + + +@pytest.mark.asyncio +async def test_chat_api_init_and_start(stub_libs_dir, monkeypatch): + monkeypatch.setenv("SIMPLEX_LIBS_DIR", str(stub_libs_dir)) + from simplex_chat.api import ChatApi, SqliteDb + api = await ChatApi.init(SqliteDb(file_prefix=str(stub_libs_dir / "db"))) + await api.start_chat() + await api.close() +``` + +- [ ] **Step 3: Run** + +`test` extras are already declared in `pyproject.toml` (Task 2.1). + +``` +pip install -e ".[test]" +PYTHONPATH=src pytest tests/test_smoke.py -v +``` + +Expected: PASS on Linux/macOS; SKIPPED on Windows. + +- [ ] **Step 4: Commit** + +```bash +git add packages/simplex-chat-python/tests/_stub_libsimplex.c \ + packages/simplex-chat-python/tests/test_smoke.py +git commit -m "test(python): smoke test against stub libsimplex" +``` + +### Task 7.4: Squaring-bot example + +**Files:** +- Create: `packages/simplex-chat-python/examples/squaring_bot.py` + +- [ ] **Step 1: Write the example from the spec** + +```python +"""Squaring bot — receives numbers, replies with their squares. + +Run: python examples/squaring_bot.py +""" +import re +from simplex_chat import ( + Bot, BotProfile, BotCommand, SqliteDb, TextMessage, Message, ParsedCommand +) + +bot = Bot( + profile=BotProfile(display_name="Squaring bot"), + db=SqliteDb(file_prefix="./squaring_bot"), + welcome="Send me a number, I'll square it.", + commands=[BotCommand(keyword="help", label="Show help")], +) + +NUMBER_RE = re.compile(r"^-?\d+(\.\d+)?$") + +@bot.on_message(content_type="text", text=NUMBER_RE) +async def square(msg: TextMessage) -> None: + n = float(msg.text) + await msg.reply(f"{n} * {n} = {n * n}") + +@bot.on_message(content_type="text") # fallback +async def fallback(msg: Message) -> None: + await msg.reply("Send me a number, like 7 or 3.14.") + +@bot.on_command("help") +async def help_cmd(msg: Message, _cmd: ParsedCommand) -> None: + await msg.reply("Send a number, I'll square it.") + +if __name__ == "__main__": + bot.run() +``` + +- [ ] **Step 2: Document in README** + +Replace the placeholder in `packages/simplex-chat-python/README.md` with the example and `pip install simplex-chat` quick-start. + +- [ ] **Step 3: Commit** + +```bash +git add packages/simplex-chat-python/examples/squaring_bot.py \ + packages/simplex-chat-python/README.md +git commit -m "docs(python): squaring bot example + README quick-start" +``` + +--- + +## Chunk 8: CI publishing + +Append `publish-python` job to existing `.github/workflows/build.yml` after `release-nodejs-libs`. Configure PyPI trusted publisher. + +### Task 8.1: Add `publish-python` job to `build.yml` + +**Files:** +- Modify: `.github/workflows/build.yml` (append new job after `release-nodejs-libs:`) + +- [ ] **Step 1: Append the job** + +After line ~803 (end of `release-nodejs-libs`), add: + +```yaml +# ========================= +# Python package release +# ========================= + +# Publishes simplex-chat to PyPI on release tags. +# Depends on release-nodejs-libs because the Python package downloads +# its libsimplex from the simplex-chat-libs release at runtime, so the +# libs release must exist before the Python package is published. +# +# Trusted publishing is configured on PyPI: no API token, OIDC only. + + publish-python: + runs-on: ubuntu-latest + needs: [release-nodejs-libs] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + id-token: write # OIDC for trusted publishing + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install build + - run: python -m build --wheel + working-directory: packages/simplex-chat-python + - uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: packages/simplex-chat-python/dist +``` + +- [ ] **Step 2: Validate YAML locally** + +``` +python -c "import yaml; yaml.safe_load(open('.github/workflows/build.yml'))" +``` + +Expected: no exception. + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/build.yml +git commit -m "ci: add publish-python job for PyPI release on tag" +``` + +### Task 8.2: One-time PyPI setup (manual, document in README) + +- [ ] **Step 1: Verify package name `simplex-chat` is available on PyPI** + +```bash +curl -s https://pypi.org/pypi/simplex-chat/json | head -c 200 +``` + +If it 404s, the name is free. If it returns metadata, the name is taken — coordinate with the team. + +- [ ] **Step 2: On PyPI, create a pending publisher** + +Navigate to https://pypi.org/manage/account/publishing/ and add: + +| Field | Value | +|---|---| +| PyPI Project Name | simplex-chat | +| Owner | simplex-chat | +| Repository name | simplex-chat | +| Workflow name | build.yml | +| Environment name | (leave blank) | + +- [ ] **Step 3: Add a section to `packages/simplex-chat-python/README.md` documenting the release process** + +Brief checklist for the maintainer: +1. Bump `_version.py` `__version__` (and `LIBS_VERSION` if libs changed). +2. Tag with `vX.Y.Z` matching `__version__`. +3. Push the tag → CI runs the existing build matrix, then `release-nodejs-libs`, then `publish-python`. +4. Verify the wheel appears at https://pypi.org/project/simplex-chat/. + +- [ ] **Step 4: Commit doc update** + +```bash +git add packages/simplex-chat-python/README.md +git commit -m "docs(python): release process + PyPI trusted publisher setup" +``` + +--- + +## Final acceptance + +After all phases: + +- [ ] **Type generation parity.** `cabal test simplex-chat-test` passes for all four `Python` test cases. +- [ ] **Python package builds.** `cd packages/simplex-chat-python && python -m build --wheel` produces a single `.whl` ≤ 200 KB. +- [ ] **All Python tests pass.** `pytest packages/simplex-chat-python/tests` — green on Linux + macOS. +- [ ] **Pyright clean.** `pyright packages/simplex-chat-python/src` — zero errors. +- [ ] **Squaring bot smoke.** Run `python examples/squaring_bot.py` against a fresh database; verify (a) lazy lib download succeeds, (b) `bot.run()` blocks, (c) Ctrl-C exits cleanly. +- [ ] **CI dry run.** Push a `v0.0.0-test` tag to a fork; verify the `publish-python` job runs after `release-nodejs-libs` and the wheel uploads to TestPyPI (configure a separate test publisher if doing this). diff --git a/plans/2026-05-08-public-groups-via-relays-overview.md b/plans/2026-05-08-public-groups-via-relays-overview.md new file mode 100644 index 0000000000..c0114e237d --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays-overview.md @@ -0,0 +1,45 @@ +# Public groups via relays — plan summary + +A third kind of group: relay-mediated like channels, but every member can post like a +secret group. Resolves the scale ceiling of full-mesh groups without the broadcast-only +governance of channels. Two orthogonal axes, already in the model: + +| `useRelays` | `groupType` | Name | +|-------------|--------------|--------------| +| false | (none) | Secret group | +| true | `GTChannel` | Channel | +| true | `GTGroup` | Public group | ← new +| true | `GTUnknown` | refuse | ← older client sees this for `"group"` + +`useRelays` is transport; `groupType` is the governance model (broadcast vs +participatory). The joiner role is a per-group value the owner sets at creation, +carried on the (owner-signed) channel profile, so every relay derives the same role for +the same group — not from a relay-side global config and not from `groupType`. The +blocker is narrow: no path produces `GTGroup` today, and the channel profile carries +no joiner-role field yet. + +## Shape of the work + +Backend: wire/version bump, type helpers, create command, owner-configured joiner-role +field on the channel profile, relay role derivation from that field. Clients (iOS + +Kotlin mirror): model, audit splitting transport-vs-governance call sites, unified +create flow with a Channel/Public-group toggle that picks the joiner-role default, +views, connect-plan messaging. + +## Threat model deltas vs. channels + +**Relay can fabricate content as any member** (broader than channels, where it could +only forge as owners). Same deniability property as channels by design; future fix is +opt-in content signing. + +Everything else in the channel threat model carries over unchanged. Out of scope for +now: member-to-member DMs in relay-mediated groups — deferred, not killed. + +## Sequencing & boundary + +Hard prerequisite: the member-profile dissemination plan +(`2026-04-29-member-profile-sending-channels.md`) lands first. Then backend → iOS → +Kotlin; platforms ship independently; older clients refuse to join. Owner→relay +role/rejection-rule communication and owner-signature verification on the channel +profile by relays are not planned here — both apply to channels equally; neither blocks +Public groups. diff --git a/plans/2026-05-08-public-groups-via-relays.md b/plans/2026-05-08-public-groups-via-relays.md new file mode 100644 index 0000000000..702f06fe7f --- /dev/null +++ b/plans/2026-05-08-public-groups-via-relays.md @@ -0,0 +1,378 @@ +# Plan: Public groups via relays + +Date: 2026-05-08 + +## 1. Overview + +Channels (shipped) are relay-mediated groups in which the relay forwards +content from any sender, but subscribers are pinned to `GRObserver` and +cannot post. Public groups are the second value of the same two-axis design: +same wire, same transport, members can post. The blocker is narrow — no +path produces `groupType = GTGroup`, and the relay's joiner-role default +comes from a global config instead of the owner-signed channel profile. +Add a `memberRole` field to the profile, plumb it (with `groupType`) +through the create command, derive the relay's joiner role from it, audit +clients for sites that conflate transport with governance, ride on the +approved member-profile dissemination plan. Member-to-member DMs in +relay-mediated groups are deferred (§10). + +## 2. Concept summary: the `useRelays × groupType` matrix + +| `useRelays` | `groupType` | Name | Wire shape | UX | +|-------------|-------------------------|-------------------|-------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| +| `false` | (no `publicGroup`) | **Secret group** | P2P `x.grp.inv` invitations; full mesh between members; JSON array batch. | Today's group: all members can post; profiles known eagerly; admins moderate. | +| `true` | `GTChannel` | **Channel** | Relay-mediated; subscribers join via channel link; binary signed-batch format; profile carries `memberRole` (default `GRObserver` at creation). | Today's channel: only owners post; subscribers anonymous to each other. | +| `true` | `GTGroup` | **Public group** | Same wire as channel; profile carries `memberRole` (default `GRMember` at creation); profile dissemination on demand. | New: every member can post; member-to-member DMs prohibited (deferred); member roster grown lazily via on-demand profile send. | +| `true` | `GTUnknown _` (decode) | (refuse to join) | Channel link from a newer client; older client sees unknown discriminator. | New clients reject with a clear "needs newer version" message; pre-existing channels unaffected. | + +Three axes: **transport** = `useRelays` (topology, batch, signatures, +delivery); **governance model** = `groupType` (profile dissemination, +member affordances; member DMs prohibited in any relay-mediated group); +**joiner role** = `memberRole` on the owner-signed profile, set at +creation, type-keyed default. Today's client sites branch on `useRelays` +as a proxy for `isChannel` — that's the audit work (§4.2, §5.2). + +## 3. Backend changes + +### 3.1 Wire format / protocol + +New optional `memberRole :: Maybe GroupMemberRole` on `PublicGroupProfile` +(owner-signed; relays read from cache). New chat-protocol version +`publicGroupsVersion` signals understanding of `groupType = "group"` and +`memberRole`. Older peers decode unknown `groupType` as `GTUnknown` +(lossless tag preservation already exists) and ignore unknown JSON fields +— §7 covers behavior. Channel-protocol docs gain a paragraph naming +`groupType` the discriminator and `memberRole` the owner-set joiner role. + +### 3.2 Type changes + +`PublicGroupProfile` gains `memberRole :: Maybe GroupMemberRole`. New +single-line helpers in the same module as `useRelays'`: `groupType'` / +`memberRole'` (accessors); `isPublicGroup'` (`useRelays' && groupType' +== Just GTGroup`); `defaultMemberRoleFor` (`GTChannel → GRObserver`, +`GTGroup → GRMember`, `GTUnknown _ → GRObserver` defensive); +`joinerRoleFor` (canonical resolver — `memberRole'` if present, else +`defaultMemberRoleFor groupType'`). `requiresSignature` unchanged for +MVP; opt-in content signing is future-work mitigation per §6. + +### 3.3 API / command changes + +`APINewPublicGroup` / `/public group` gain `groupType` (default channel) +and optional `memberRole` (default `defaultMemberRoleFor groupType`); both +are written onto the constructed profile. The subscriber-side prepare-group +flow reads `memberRole` from the resolved link with the same fallback. The +`channelSubscriberRole` config is removed (no callers after §3.4); tests +that flipped it migrate to Public groups or explicit `memberRole`. + +#### 3.3.1 Default group preferences + +Public-group defaults equal secret-group defaults — the channel override +(`support = OFF`) does not apply, since member-to-moderator escalation is +expected. Parameterize the existing channel-prefs parser by `GroupType` +(Channel keeps its path; Public group and `GTUnknown` use secret-group). +`directMessages` stays ON by inheritance but is **dormant** in any relay- +mediated group (relay doesn't forward `XGrpDirectInv`; clients hide the +toggle); keeping the wire ON lets a future plan re-enable DMs without a +profile-shape change. + +### 3.4 Message processing + +- **Relay joiner-role derivation** (today reads `channelSubscriberRole`): + switch to `joinerRoleFor gInfo`. Eliminates cross-relay disparity. +- **Member-DM defensive refusal** (`xGrpDirectInv`): when `useRelays'`, + emit `messageError` and create no contact. Belt-and-suspenders with the + §4/§5 client suppression; unreachable today (no forwarding, no P2P). +- **Legacy `x.grp.inv`**: existing channel rejection covers Public groups. +- **`unverifiedAllowed`**: unchanged. Tightening becomes possible once + the dissemination plan distributes member keys; existing TODO is + updated to name that precondition. +- **Inherited unchanged** (add a test each): `checkSendAsGroup` + (role-based), receipts cutoff (count-based), introduce-in-channel + + history (`useRelays`-keyed). +- **`memberAdmission` on relay-mediated join**: hardcoded `GAAccepted` + bypasses review/captcha. Generic relay-mediated-groups gap; §8. + +### 3.5 Database migrations + +No schema migration: `groupType` and `memberRole` ride the existing +JSON-serialized profile; absent fields resolve via `defaultMemberRoleFor`. +The dissemination plan's `sent_profile_vector BLOB` migration is a hard +prerequisite owned by that plan. + +### 3.6 Test scenarios + +Add Public-group helpers paralleling the channel helpers, plus: + +1. Member sends content; all members receive it (no "unknown member" lines). +2. Multi-author session: no "unknown member" lines anywhere. +3. Member edit / delete / react forwarded by relay to all members. +4. Member-DM refused on receive: inject `XGrpDirectInv`, expect `messageError`, no contact created; repeat for Channel. +5. Role changes propagate through signed forwarding. +6. Blocked member's subsequent messages not forwarded. +7. Multi-relay delivery with cross-relay deduplication. +8. History on join. +9. `asGroup=true` from a non-owner member rejected with existing error. +10. Receipts disabled above the 20-member limit. +11. Older-client refusal on `groupType = "group"` shows needs-newer-version. +12. Incognito member posting attributes the incognito profile to others. +13. `memberRole` propagates: explicit `GRAuthor` at creation → joiners get `GRAuthor`; resolved link data carries the value. +14. `memberRole` defaults: Channel → `GRObserver`; Public group → `GRMember`. +15. Old-profile fallback: `memberRole = Nothing` → `defaultMemberRoleFor groupType` (`GRObserver` for Channel). + +## 4. iOS changes + +### 4.1 Model + +Add `case group` to `GroupType` (with serializer arms); +`memberRole: GroupMemberRole?` on `PublicGroupProfile`; `isPublicGroup`, +`groupType`, `memberRole` accessors on `GroupProfile`/`GroupInfo`. Client +uses `memberRole` for display only; authoritative resolution stays on +Haskell. + +### 4.2 Audit `useRelays` vs `isChannel` (≈73 sites) + +Per-site rule: **transport** (link/relay management, owner-can't-leave-own- +relay-group, relay-status indicator, incognito flag display, typing-state +gating, member-DM-affordance suppression) → keep `useRelays`. **Governance** +(titles, "subscribers" vs "members" framing, "Channel preferences" labels, +channel-style vs group-style member display) → switch to `isChannel`. +Roughly 70% flip to `isChannel`. Visually compare Public / Channel / Secret +after. + +### 4.3 Create flow + +Unified view with a "Channel / Public group" segmented control above the +display-name field, defaulting to Channel. The toggle drives the screen +title, link-step label, success screen, and two API parameters: `groupType` +and `memberRole` (`.observer` for Channel, `.member` for Public group — +no separate role picker in MVP). Default `groupPreferences` builder is +`groupType`-keyed per §3.3.1. The `directMessages` toggle is hidden in +the create-flow prefs section when `useRelays`. When `groupType = .group`, +render below the title: + +> "In a Public group, every member can post. Messages are delivered through +> relays you choose, which means a malicious relay could change or +> fabricate messages from any member. Pick relays you trust." + +### 4.4 Strings, views, icons, connect-plan + +- **Strings:** ~5–10 keys mirroring channel forms with `_public_group` + suffixes (create/add/leave/delete/link/temporarily-unavailable/no- + relays), plus `create_public_group_threat_model_note`. Reuse + `group_members_*` for "members" framing; channels keep `_subscriber*`. +- **Compose:** existing role-based gates allow members to post; **suppress + the member-tap "send direct message" affordance in any relay-mediated + group** (client side of the DM prohibition; receive gate at §3.4). +- **Views:** `GroupChatInfoView` and the link view branch three ways at + §4.2 sites; the link view takes `groupInfo` and derives variant inside. + `GroupPreferencesView` hides `directMessages` when `useRelays`. +- **Icon:** `chatIconName` gains a Public-group arm with a distinct icon + (different from channel-antenna and secret-group-people — §8). +- **Members view:** show the relay-known roster; header "subscribers" for + channels, "members" for Public groups. No filtered view in MVP. +- **Connect-plan:** wording keyed on resolved `groupType` — "ok to + subscribe via relays" (channel) vs "ok to join via relays" (Public + group). CLI string changes alongside; tests follow. + +## 5. Kotlin changes + +Mirror of §4 across the Compose surface. Subsections parallel §4 and +note divergences only. + +### 5.1 Model + +`GroupType` gains `Group`; `memberRole` / `isPublicGroup` accessors on +`GroupInfo` / `GroupProfile`. + +### 5.2 Audit `useRelays` vs `isChannel` (≈74 sites) + +Same transport-vs-governance rule as §4.2; ~70% flip to `isChannel`. + +### 5.3 Create flow + +Single-view create with Channel / Public-group toggle driving +`groupType`+`memberRole`; threat-model note below the title; +`directMessages` toggle hidden under `useRelays`. + +### 5.4 Strings, views, icons, ConnectPlan + +Strings, views, icons, and ConnectPlan mirror §4.4. **Kotlin-only:** +chat-list filter chips place Public groups in the "groups" bucket +(mental model: "things I can post in"), not "channels". + +## 6. Threat model: changes from channels + +This section assumes the channel threat model +(`docs/protocol/channels-overview.md` §"Threat model"). Public groups +inherit every property listed there. One threat is *broader* (channels +have a narrower form of the same threat). The relay's "can / cannot" +framing matches the existing doc style; the items below are written so +they can be folded directly into a future revision of +`channels-overview.md` once Public groups ship. + +### 6.A.1 A relay can fabricate content as any member + +Content messages (`XMsgNew`, `XMsgUpdate`, `XMsgDel`, `XMsgReact`, +`XFileCancel`) are unsigned in both channels and Public groups +(`Protocol.hs:1221`, `requiresSignature` lists only roster / +administrative events). In channels this gives a compromised relay +the ability to fabricate content attributed to owners — already +documented in `channels-overview.md` §"Threat model" ("Substitute +unsigned content or selectively drop messages for its subscribers"). +In Public groups, the same property has a **broader blast radius**: +the relay can fabricate content attributed to *any* member, not just +to owners. + +This matches the channel deniability property by design (see +`channels-overview.md` §"Signing scope: roster only, content +optional"): unsigned content is precisely what enables cryptographic +deniability — no third party can prove a member authored anything. +The trade-off is that the operator on the delivery path cannot be +prevented from forging in the same channel. + +**A single compromised relay** + +*can:* + +- Fabricate content messages attributed to any member, not just to + owners. Detectable by other members through cross-relay + consistency (same TODO as the channel case: difference detection + not yet implemented). +- Modify the text or content of messages in transit and re-attribute + the modified message to its original author. +- Drop content messages selectively — same property as channels. + +*cannot:* + +- Forge signed administrative events: `XGrpInfo`, `XGrpPrefs`, + `XGrpMemRole`, `XGrpMemRestrict`, `XGrpMemDel`, `XGrpDel`, + `XGrpLeave`, `XInfo` (`Protocol.hs:1221`). Roster manipulation, + profile changes, and member-attributed leave / profile-update + events all require valid signatures. +- Substitute the channel profile or impersonate an owner — the + channel's entity ID and owner authorization chain are validated + by every recipient against the channel link. The new `memberRole` + field is part of the (owner-signed) channel profile, so a + compromised relay also cannot fabricate a different joiner role + than the owner configured. +- Alter authoritative state on owner devices. + +**Mitigation.** No code change for the MVP. The future-work fix is +opt-in content signing per the channel roadmap +(`channels-overview.md` §"Future work" / "Transcript integrity" / +"Opt-in content signing"). When that ships, owners of Public groups +will be able to require all content (member or owner) to carry a +signature; member keys are already disseminated to other members +via the prior plan (`2026-04-29-member-profile-sending-channels.md`), +so verification on the recipient side is not a separate effort. + +In the meantime, the create-flow help text for "Public group" on +both platforms (§4.3, §5.3) carries this trade-off framing: "In a +Public group, the relay forwards messages on behalf of every member. +A compromised relay could change message text or attribute fabricated +messages to any member. Use a secret group if you need non- +repudiable peer-to-peer messaging." This is the same trade-off +channels make for owner posts; making it explicit at create time +lets users choose Public-group-via-relay vs secret-group based on +whether they value scale or content integrity. + +### 6.A.2 What is unchanged from channels + +Every other property of the channel threat model carries over +without change. In particular: + +- A relay cannot impersonate an owner or substitute the channel + profile (signed events, validated entity ID). The configured + `memberRole` is part of the signed profile, so the relay cannot + unilaterally elevate or demote joiners relative to what the owner + specified. +- A relay cannot determine subscriber / member real identity or + network address (inherited from SMP transport). +- All-relays-compromised-and-colluding cannot forge signed events + or alter owner-authoritative state. +- A passive network observer cannot determine which Public group a + member is in, or correlate Public-group activity with other + SimpleX activity. + +Public-group members get the same participant-privacy guarantees as +channel subscribers, and Public-group owners get the same key-loss +risk profile as channel owners (see `channels-overview.md` +§"Compromise of owner keys" and §"Loss of all owner devices"). + +**Out of scope for now: member-to-member DMs in relay-mediated +groups.** In channels, members do not DM each other today. In Public +groups, this plan prohibits the affordance (client-side and +defensively on the receive path) and the relay does not forward +`XGrpDirectInv`. The relay therefore does not see a "member DM +graph" — that threat (which a forwarded-DM design would have +introduced) does not exist under this plan. A future plan can +re-introduce member-to-member DMs and revisit the metadata trade-off +explicitly; the design space is sketched in §10. + +### 6.A.3 Release-notes line + +For the Public-groups release notes, include a one-line summary of +the new property: + +> "In a Public group, the relay you choose could in principle alter +> or fabricate group messages attributed to any member. Pick relays +> you trust, or use a secret group if you need peer-to-peer message +> integrity." + +## 7. Migration / compatibility + +- **Existing channels unaffected.** Pre-upgrade profiles have no + `memberRole`; readers fall back to `defaultMemberRoleFor GTChannel = + GRObserver`. No data migration. +- **Older clients** decode `groupType = "group"` as `GTUnknown` and must + refuse to join with a "needs newer version" alert; they ignore unknown + fields on channel profiles otherwise. +- **Older relays** forward Public-group traffic but resolve joiner role + from their global config — joiners via un-upgraded relays get the legacy + role and cannot post. Mitigation: warn the owner at create time if any + selected relay's chat version is below `publicGroupsVersion`. Soft + warning, not a hard block. + +## 8. Open questions + +1. **Future member-DM design.** (i) Relay-forwarded `XGrpDirectInv` + (simple; leaks DM-graph metadata); (ii) relay-blind rendezvous via + per-member queues on the profile (privacy-preserving; new protocol). + Either re-derives §6. +2. **`memberAdmission` on relay-mediated join.** Hardcoded `GAAccepted` + bypasses review/captcha; generic relay-mediated-groups gap; defer. +3. **Distinct icon for Public groups.** Visually different from channel- + antenna and secret-group-people metaphors. Pending design review. +4. **`channelSubscriberRole` removal.** Verify no out-of-tree consumer + reads it before deleting. +5. **`memberRole` on profile edit.** MVP exposes no UI; Haskell accepts + the edit but role-rebase of existing members is undefined. Deferred. +6. **Roster filter in members view.** Paginate/filter for 100K+ members? + Generic relay-roster question; defer. +7. **Connect-plan wording.** "subscribe / join / connect via relays" — + pick per `groupType`; update tests when CLI string changes. + +## 9. Sequencing + +1. **Prerequisite:** member-profile dissemination plan lands first. +2. **Backend:** types, `memberRole` field, command parameters, profile- + based role derivation, removal of `channelSubscriberRole`, defensive + `XGrpDirectInv` refusal, tests 1–15. +3. **iOS** then **Kotlin** (independent of each other; API defaults are + backward compatible): model, audit, create flow, strings; then views, + icons, ConnectPlan, DM-affordance suppression. +4. **Older-client refusal, version-bump release notes, channel-docs + updates** ship with the backend release. + +## 10. Adjacent work (not planned here) + +- **Owner→relay communication of rejection rules.** Joiner-role side is + fixed here (travels on the signed profile); rejection-rule side + (admission/captcha) is still relay-side config. Future plan: carry it + on the profile too. +- **Owner-signature verification on the channel profile by relays.** + Affects channels equally; does not gate this plan. +- **Member-to-member DMs in relay-mediated groups.** Deferred (§1, §8 Q1). + A future plan must re-derive §6 — relay-forwarded DMs would re-introduce + (sender, target, time) metadata exposure this plan + avoids. diff --git a/plans/2026-05-08-relay-announce-impl.md b/plans/2026-05-08-relay-announce-impl.md new file mode 100644 index 0000000000..f617c78a31 --- /dev/null +++ b/plans/2026-05-08-relay-announce-impl.md @@ -0,0 +1,695 @@ +# Implementation plan: owner-pushed relay announcement (`XGrpRelayNew`) + +Companion to `/workspace/plans/2026-05-08-relay-announce.md` (overview). This file is the +file-and-symbol-level diff guide. Read the overview first. + +All file/line references are against the working tree at the start of the implementation; +they will drift slightly as edits land. Cite this plan when something looks unfamiliar. + +--- + +## 1. Step ordering and commit shape + +Compilation must hold after every step. The order below is the smallest reviewable +sequence; steps S1–S5 are intentionally split into two PRs: a wire-format-only PR and a +behaviour PR, so reviewers can evaluate the new event in isolation. + +PR 1 — wire format (compiles, no behaviour change) + +- S1 `Protocol.hs`: add `XGrpRelayNew` constructor, tag `x.grp.relay.new`, + `toCMEventTag`, JSON encode/parse, `isForwardedGroupMsg` row. +- S2 `Protocol.hs`: extend `requiresSignature` to include `XGrpRelayNew_`. +- S3 `docs/protocol/channels-protocol.md`: signing-table row + new "Relay addition" + subsection. + +PR 2 — receive + send + forward (one logical change) + +- S4 `Store/Groups.hs`: add active-status filter in place to the inner + `getGroupMemberByRelayLink` lookup inside `getCreateRelayForMember`. +- S5 `Library/Internal.hs`: introduce `connectToRelayAsync`. Move `syncSubscriberRelays` + from `Commands.hs` to `Internal.hs` and pivot its add-half to `connectToRelayAsync`. +- S6 `Library/Commands.hs`: drop the now-unused sync `connectToRelay`; `APIConnectPreparedGroup` + keeps the existing sync call (see §6 — left in place); update import of + `syncSubscriberRelays`. Keep `retryRelayConnectionAsync` as-is. +- S7 `Library/Subscriber.hs`: add forward-only case to `processEvent`, add + `XGrpRelayNew` case to `processForwardedMsg`, add owner send at end of LINK callback. +- S8 Tests in `tests/ChatTests/Channels.hs` (or split across files per §11). + +S1–S3 land in PR 1; S4–S8 in PR 2. PR 2 must not be split: the owner-side send and the +subscriber-side handler must ship together to avoid asymmetry where one direction is +emitted but not consumed. + +--- + +## 2. `Protocol.hs` — wire format (S1, S2) + +### 2.1 GADT constructor (Protocol.hs:443-445) + +Add at line 446 (immediately after `XGrpRelayTest`, before `XGrpMemNew`): + +``` +XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json +``` + +Rationale: keeps the relay-related events grouped. Single `ShortLinkContact` field, no +record syntax, mirrors `XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json` at +Protocol.hs:444. **Do not** introduce a record wrapper or `RelayInfo` envelope — the +overview locked the shape to a single field; the receiver looks the link up locally. + +### 2.2 Tag GADT and string encoding (Protocol.hs:986-988, 1043-1045, 1101-1103) + +- Insert `XGrpRelayNew_ :: CMEventTag 'Json` after `XGrpRelayTest_` (line 988). +- In `strEncode`, add `XGrpRelayNew_ -> "x.grp.relay.new"` after `XGrpRelayTest_` (line 1045). +- In `strDecode` map (line 1103), add `"x.grp.relay.new" -> XGrpRelayNew_` after + `"x.grp.relay.test" -> XGrpRelayTest_`. + +The `_` -> `XUnknown_` fallback at line 1129 already gives correct old-client behaviour; +no change there. + +### 2.3 `toCMEventTag` (Protocol.hs:1133-1184) + +Add `XGrpRelayNew _ -> XGrpRelayNew_` after the `XGrpRelayTest` line (1157). + +### 2.4 JSON parse / encode (Protocol.hs:1308-1314, 1378-1382) + +- `appJsonToCM`/`msg` parser (1271-1344): add + `XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink"` + immediately after `XGrpRelayAcpt_` (line 1309). Field name `"relayLink"` matches the + `XGrpRelayAcpt` precedent (1309) — do not invent a new key. +- `chatToAppMessage`/`params` encoder (1354-1410): add + `XGrpRelayNew relayLink -> o ["relayLink" .= relayLink]` + after the `XGrpRelayAcpt` clause (1379). Same key. + +### 2.5 `isForwardedGroupMsg` (Protocol.hs:484-503) + +Add a single case `XGrpRelayNew _ -> True` in the listed group of `True` cases (e.g. +between `XGrpMemNew {} -> True` (495) and `XGrpMemRole {} -> True` (496)). Rationale: +relays must forward this event to subscribers; it is the entire point. The comment +above the function (line 482) already says actual filtering happens in `processEvent`; +the listing here is for the send-side `memberSendAction` decisions about pre-member +forwarding (Internal.hs:2202), which we want to behave the same as `XGrpMemNew`. + +### 2.6 `requiresSignature` (Protocol.hs:1221-1231) + +Add `XGrpRelayNew_ -> True` to the list. Rationale: this is an administrative event; +must reuse the existing required-signature gate. Without this, `withVerifiedMsg` +(Subscriber.hs:3385-3407) would treat a missing signature as acceptable +(`signatureOptional` becomes `True`), breaking the threat model from +`channels-protocol.md` §"Message signing". + +### 2.7 What NOT to change + +- Do not touch `hasNotification` or `hasDeliveryReceipt` — relay-add is administrative, + not a notification surface for the user. The relay's delivery pipeline (delivery_task / + delivery_job) already handles forwarding without an entry in either table. +- Do not touch `unverifiedAllowed` (Protocol.hs:1240-1249). Owners always know their own + key; subscribers always have the owner key from link data. The "no key" branch is for + member-to-member events, not for owner-signed administrative events. + +--- + +## 3. `Store/Groups.hs` — active-status filter on relay-link lookup (S4) + +### 3.1 The current shape (Store/Groups.hs:1376-1407) + +`getCreateRelayForMember` runs `getGroupMemberByRelayLink` (an inner `let` at 1380-1385), +falls back to `createRelayMember`. The inner SQL filters on `group_id = ? AND relay_link += ?` only — no status filter. The schema permits multiple rows with the same +`(group_id, relay_link)` over time: when a relay is removed by the owner, its row is +preserved with `GSMemLeft` (this drives the "removed by operator" UI on the subscriber +side). For the existing subscriber-join flow (`APIConnectPreparedGroup → connectToRelay`, +Commands.hs:2141 / 3597-3613) the unfiltered lookup happens to work because rows in that +path are recent and active. For the new subscriber receive path we must filter to *active* +rows so that a re-add after a `GSMemLeft` creates a fresh row instead of resurrecting the +historical one. + +### 3.2 The change + +Add an active-status filter in place to the existing inner `let`. No extraction, no new +top-level function: + +``` +getGroupMemberByRelayLink = + maybeFirstRow (toContactMember vr user) $ + DB.query + db + (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ? AND m.member_status IN (?,?,?,?,?,?,?)") + ( (groupId, relayLink) + :. (GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced) + :. (GSMemConnected, GSMemComplete, GSMemCreator) + ) +``` + +The seven statuses are the `memberCurrent'`-true set from Types.hs:1318-1334: +`GSMemIntroduced`, `GSMemIntroInvited`, `GSMemAccepted`, `GSMemAnnounced`, +`GSMemConnected`, `GSMemComplete`, `GSMemCreator`. Tuple shape is illustrative — match the +existing `:.` chaining convention used elsewhere in the module. + +Justification for SQL-level filter (vs. Haskell post-filter): `maybeFirstRow` returns +whatever row the engine yields first. With `GSMemLeft` history rows preserved alongside +active rows, an unfiltered query is non-deterministic without `ORDER BY`. Filtering in +SQL eliminates the ambiguity at the query level. The list of statuses is tiny and +stable. + +### 3.3 Existing call site unaffected + +`getCreateRelayForMember`'s lone existing caller is `connectToRelay` (Commands.hs:3597-3613), +invoked from `APIConnectPreparedGroup` (Commands.hs:2141). Rows it creates are inserted +with `GSMemAccepted` (line 1403), which is `memberCurrent`. The filtered lookup still +finds them on retry, so the subscriber-join flow's reuse-on-retry behaviour is preserved. +No signature or call-site change is needed in `Commands.hs`. + +### 3.4 What NOT to change + +- Do not extract `getGroupMemberByRelayLink` to a top-level function. The + filter-in-place shape is the minimal diff; both call sites (existing + `APIConnectPreparedGroup → connectToRelay` and new `connectToRelayAsync`) share one + definition by going through `getCreateRelayForMember`. +- Do not modify `getGroupMember`, `getGroupMembers`, or other lookups. The change is + scoped to the relay-link lookup inside `getCreateRelayForMember`. +- Do not delete the historical `GSMemLeft` row when re-adding a relay. The + delete-or-update logic in `syncSubscriberRelays` removes only when the link is no + longer in the channel's link data (Commands.hs:3623-3633); on re-add it remains in + link data, so the historical row stays untouched and is filtered out by the new + lookup. + +--- + +## 4. `Library/Internal.hs` — `connectToRelayAsync` and moved `syncSubscriberRelays` (S5) + +### 4.1 New helper + +Place near the existing relay/group plumbing (e.g. after `setGroupLinkDataAsync` at +Internal.hs:1316-1322) so that all relay-link async helpers cluster together. + +``` +connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () +``` + +Body — described, not coded: + +1. `vr <- chatVersionRange`. +2. `gVar <- asks random` — needed by `getCreateRelayForMember` via the create branch. +3. `relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink`. + With the active-status filter from §3.2, this atomically returns the existing active + row (if any) or creates a fresh `GSMemAccepted` row. `GSMemLeft` history rows are + invisible to the lookup, so re-add after removal creates a new row beside the + historical one. +4. Idempotence check on `activeConn relayMember`: + + - `Just _` → `pure ()` (skip; an earlier path already bound a connection on this + row. The agent layer handles transient failures internally; permanent-failure + recovery is deferred to explicit retry paths and channel re-join.) + - `Nothing` → either a freshly created row or a leftover row from an attempt that + never bound a connection; proceed to step 5. +5. `subMode <- chatReadVar subscriptionMode`. +6. `newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink` + (Commands.hs:2479 — already returns `(CommandId, ConnId)` for binding). +7. `withFastStore' $ \db -> createRelayMemberConnectionAsync db user gInfo relayMember relayLink newConnIds subMode` + (Direct.hs:225-244). +8. Return. Continuation is the existing `CFGetRelayDataJoin` LDATA callback at + Subscriber.hs:1131-1160 — unchanged. + +Store-call conventions: `getCreateRelayForMember` is `ExceptT StoreError IO`, so use +`withFastStore`. `createRelayMemberConnectionAsync` is `IO`, so `withFastStore'`. Both +match what `retryRelayConnectionAsync` (Commands.hs:2168-2174) and `connectToRelay` +(Commands.hs:3597-3613) already use. + +### 4.2 Locking argument + +`connectToRelayAsync` is called from two sites (after this PR): +- The forwarded `XGrpRelayNew` handler in `processForwardedMsg`. The entire receive path + is wrapped in `withEntityLock "processAgentMessage" lockEntity` (Subscriber.hs:117) and + the lock entity for any group connection is `CLGroup groupId` (Connections.hs:51-72). +- `syncSubscriberRelays`, called from `APIGetUpdatedGroupLinkData` inside + `withGroupLock "syncSubscriberRelays" groupId` (Commands.hs:1787) — also `CLGroup groupId`. + +Both paths therefore hold the same lock for the same group. The `getCreateRelayForMember` +call (lookup-or-create, atomic within its own transaction) and the `activeConn` check on +its result are performed under that lock, and any subsequent agent commands +(`getAgentConnShortLinkAsync`, `createRelayMemberConnectionAsync`) only persist state +that will be observed under the same lock by the next event's check. No additional lock +is needed. No `justCreated` flag, no per-link mutex. + +### 4.3 Move `syncSubscriberRelays` from `Commands.hs:3614-3633` to `Internal.hs` + +Place right below `connectToRelayAsync`. Body changes: + +- Replace the single `connectToRelay` call inside the `forM_ newRelayLinks` loop + (Commands.hs:3621-3622) with `connectToRelayAsync user gInfo rlnk`. Keep the + per-relay `void . tryAllErrors` wrapping verbatim — equivalent to the existing + pattern at Commands.hs:3621-3622 with only the connect helper substituted: + + ``` + forM_ newRelayLinks $ \rlnk -> void . tryAllErrors $ + connectToRelayAsync user gInfo rlnk + ``` + + `connectToRelayAsync` can fail at three local operations + (`getCreateRelayForMember` → store error if creating; `getAgentConnShortLinkAsync` + → agent error; `createRelayMemberConnectionAsync` → store error). Per-relay error + isolation costs nothing and ensures a failure on relay R1 does not short-circuit + attempts for R2, R3 in the same batch. The outer `void . tryAllErrors` (3615) is + preserved as well; it remains the catch-all for the whole sync operation. +- Remove half: keep verbatim — `deleteMemberConnection`, `deleteOrUpdateMemberRecord` + calls (3631-3632), the `null activeRelayMembers` guard (3629), and the + `localRelayMembers` filter (3617). + +Type signature after move (matches current except for module location): + +``` +syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM () +``` + +### 4.4 Imports / exports + +- `Internal.hs` likely already imports the relevant `Store.Groups`/`Store.Direct` + symbols; if `getCreateRelayForMember` or `createRelayMemberConnectionAsync` are not + imported, add them. +- Export `connectToRelayAsync` and `syncSubscriberRelays` from `Internal.hs` (it is a + module without an explicit export list — see "module Simplex.Chat.Library.Internal where" + near top — so any new top-level binding is automatically exported). + +### 4.5 What NOT to change + +- Do not change `connectToRelay` (sync, Commands.hs:3597-3613) signature. PR 2 keeps it + alive for the subscriber's initial channel-join — see §5.1. +- Do not touch `retryRelayConnectionAsync` (Commands.hs:2168-2174). Its retry semantics + are tied to the subscriber's initial channel-join (`APIConnectPreparedGroup`, + Commands.hs:2108-2161) and remain on that path. +- Do not introduce any new `withGroupLock` inside `connectToRelayAsync`. The caller's + lock is sufficient (see §4.2). + +--- + +## 5. `Library/Commands.hs` — drop unused sync helper, fix imports (S6) + +### 5.1 Decide what to delete + +Audit `connectToRelay` callers: only `APIConnectPreparedGroup` (Commands.hs:2108-2161) +uses it. That command is the **subscriber's** initial channel-join entry point +(not owner channel creation — owner-side relay invitation flows through +`APIAddGroupRelays` and `x.grp.relay.inv`/`x.grp.relay.acpt`, see channels-protocol.md +§"Relay acceptance"). At join time, the subscriber does +`mapConcurrently (connectToRelay user gInfo') relays` (Commands.hs:2141) to connect to +all relays in parallel during the join handshake. + +The sync flow is intentional there: +- the user is on a "joining channel" spinner; +- failures must surface immediately to UI so the user sees a meaningful error + instead of a stuck spinner; +- the existing flow already chains async retry via `retryRelayConnectionAsync` + (Commands.hs:2159) for the relays that fail with temporary errors — sync handles + the immediate-feedback path, async handles tail recovery. + +**Default; reviewer to confirm**: keep `connectToRelay` for the +`APIConnectPreparedGroup` path. The overview's "deletable once event-driven path is +wired" was conditional ("once no caller remains"). Subscriber join has different UX +semantics from event-driven relay sync; convergence onto async-only is a separate +concern and is out of scope for this PR. + +### 5.2 Move `syncSubscriberRelays` reference + +`APIGetUpdatedGroupLinkData` at Commands.hs:1787-1788 currently references +`syncSubscriberRelays` as a local where-binding inside `processChatCommand` (it is the +inner `where`-defined function at 3614). After moving it to `Internal.hs`, the call site +at 1788 unchanged but the local binding at 3614-3633 deleted. Imports auto-rerouted via +`Simplex.Chat.Library.Internal` (already imported at the top of Commands.hs). + +### 5.3 What NOT to change + +- Do not change `APIGetUpdatedGroupLinkData`'s `withGroupLock` wrapper or the `gInfo'` + it passes to the sync function. The lock and the link-data refresh are still required. +- Do not change `retryRelayConnectionAsync`. It is the right primitive for the + subscriber-join retry use case (`APIConnectPreparedGroup` tail recovery, + Commands.hs:2159); the new event-driven path is independent. + +--- + +## 6. `Library/Subscriber.hs` — owner send, relay forward, subscriber receive (S7) + +### 6.1 Owner — send site in LINK callback (Subscriber.hs:1300-1333) + +The relevant block: + +``` +LINK _link auData -> + withCompletedCommand conn agentMsg $ \CommandData {cmdFunction} -> + case cmdFunction of + CFSetShortLink -> + case (ucGroupId_, auData) of + (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do + (gInfo, gLink, relays, relaysChanged) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user groupId + gLink <- getGroupLink db user gInfo + relays <- liftIO $ getGroupRelays db gInfo + (relays', changed) <- liftIO $ foldrM (updateRelay db) ([], False) relays + liftIO $ setGroupInProgressDone db gInfo + pure (gInfo, gLink, relays', changed) + toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged + where + updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) = + case relayLink of + Just rLink + | rLink `elem` relayLinks && relayStatus == RSAccepted -> do + relay' <- updateRelayStatus db relay RSActive + pure (relay' : acc, True) + ... +``` + +Plan: + +1. Extend the `updateRelay` accumulator from `([GroupRelay], Bool)` to + `([GroupRelay], Bool, [ShortLinkContact])`: keep the existing `Bool` for the + `CEvtGroupLinkDataUpdated`'s `relaysChanged` flag, and add a new + `[ShortLinkContact]` collecting the links of relays that just transitioned + `RSAccepted → RSActive`. In the `RSAccepted → RSActive` branch, replace + `pure (relay' : acc, True)` with `pure (relay' : acc, True, rLink : newlyActiveLinks)`. + In the `RSActive → RSInactive` branch (which also sets `changed = True` today, + line 1330), keep the `Bool` flip but pass `newlyActiveLinks` through unchanged + — removals are explicitly out of scope for the announce push (overview + §"Owner — send site"). Other branches pass both extra fields through unchanged. +2. Bind `(gInfo, gLink, relays, relaysChanged, newlyActiveLinks)` from the `withStore` + block; pass `relaysChanged` to the existing `CEvtGroupLinkDataUpdated` `toView` + call so its semantics are preserved exactly; pass `newlyActiveLinks` to the new + send block in step 3. + +3. After the `toView`, add (still inside `(Just groupId, UserContactLinkData ...)` + case). The send block fetches all relay members and filters inline (see §6.2): + + ``` + let newlyActiveLinks = ... -- collected from the fold accumulator + forM_ (L.nonEmpty newlyActiveLinks) $ \newlyActive -> do + allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + let recipients = filter + (\m -> memberStatus m == GSMemConnected && relayLink m `notElem` newlyActiveLinks) + allRelayMembers + events = XGrpRelayNew <$> newlyActive + unless (null recipients) $ + void $ sendGroupMessages user gInfo Nothing False recipients events + ``` + + - `sendGroupMessages` signature (Internal.hs:2049): `User -> GroupInfo -> Maybe + GroupChatScope -> ShowGroupAsSender -> [GroupMember] -> NonEmpty (ChatMsgEvent e) -> CM (NonEmpty (Either ChatError SndMessage), GroupSndResult)`. + - `Nothing` for `Maybe GroupChatScope`: this is administrative, not scoped to a + support side-channel. Justified by `XGrpInfo` / `XGrpPrefs` send patterns elsewhere + where signed admin events use `Nothing`. + - `False` for `ShowGroupAsSender`: this is signed by the owner; relays must verify + the owner signature via `withVerifiedMsg` (Subscriber.hs:3385). `asGroup = True` + uses `CBChannel` binding (channels-protocol.md §"Channel-as-sender"), which has no + member ID and is not what we want — verification needs the owner's member ID. + - `void` discards the per-member result; logging is handled by the existing send + pipeline. + +### 6.2 Recipients query + +No new Store helper. Inline the filter in the LINK callback: + +- After the `withStore` block that runs the fold, call + `withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo` to get + `[GroupMember]` (Store/Groups.hs:1185-1191). +- Filter in Haskell: + `filter (\m -> memberStatus m == GSMemConnected && relayLink m \`notElem\` newlyActiveLinks)`. +- `memberStatus == GSMemConnected` already implies `memberCurrent` (Types.hs:1318-1334); + do not add a redundant `memberCurrent` check. +- Pass the filtered list as the recipients argument to `sendGroupMessages`. + +Justification: one-shot use, low frequency (LINK callback only), no benefit +to introducing a new Store function. `vr` and `user` are already in scope at +the LINK callback (Subscriber.hs:1306, inside `processContactConnMessage`). + +### 6.3 Defensive batching + +Per overview, the receive-loop group lock serializes `XGrpRelayAcpt` handling (which +calls `setGroupLinkDataAsync`) so each LINK callback typically sees a single +`RSAccepted → RSActive` transition. Coding the send as `NonEmpty (XGrpRelayNew _)` keeps +the path correct if the agent ever consolidates `setConnShortLink` writes. The +`L.nonEmpty newlyActive` guard handles the empty case (no transition this callback). + +### 6.4 Relay — `processEvent` case (Subscriber.hs:990-1032) + +Insert this case before the catch-all `_ -> Nothing <$ messageError ...` at 1032: + +``` +XGrpRelayNew _ -> pure $ Just (DeliveryTaskContext (DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}) False) +``` + +Justification by precedent: +- `XMsgNew` (991) → `newGroupContentMessage` returns `Just (ctx js)` where `ctx` is + `DeliveryTaskContext js False` (line 983). The `False` is the "don't include in + history" flag — relay forwards but doesn't snapshot. +- `XGrpMemNew` (1011) → `xGrpMemNew` returns `Just (ctx (DJSGroup {…}))`. We want + identical broadcast scope (all subscribers, no support-only channel). +- `XGrpDel` (1022) is the only event that uses `DJRelayRemoved`; that is for + relay-removal-by-owner, not relevant here. + +`DJDeliveryJob {includePending = False}` matches `XMsgNew`'s default (search +`Delivery.hs` for `DJDeliveryJob` constructor — `includePending = False` is the +non-administrative-state-change default; `XGrpInfo` uses `True` because it changes +group profile state and pending members must learn it on accept). The relay +**stores no member record for the announced relay** (overview §"Relay — forward +only"), so subscribers entering pending state later will instead learn via on-open +`syncSubscriberRelays`. `includePending = False` is correct. + +What NOT to do: +- Do not add an `xGrpRelayNew` handler on the relay side — the relay is forward-only. +- Do not create a `GroupMember` record for the announced relay on the relay. Departure + from `XGrpMemNew` semantics is intentional; relays don't connect to other relays of + the same channel. + +### 6.5 Subscriber — `processForwardedMsg` case (Subscriber.hs:3354-3383) + +Add to the inner `case event of` (just before the catch-all `_ -> messageError ...` at +3378): + +``` +XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \_author -> connectToRelayAsync user gInfo rl +``` + +Notes: +- `withAuthor` (3380-3383) requires `author_ :: Maybe GroupMember` to be `Just` — + enforces the "must be attributable to a signing owner" invariant. `FwdChannel` (3351 + via `processForwardedMsg (VMUnsigned chatMsg) Nothing`) makes `author_ = Nothing`, + which `withAuthor` rejects with `messageError`. This is the desired behaviour: the + event must be owner-signed and attributed. +- Signature verification happens upstream in `withVerifiedMsg` (3385-3407) before + `processForwardedMsg` is invoked (3348-3349). With `requiresSignature` returning + `True` for `XGrpRelayNew_` (§2.6), an unsigned forwarded `XGrpRelayNew` triggers the + bad-signature path at 3389-3391. +- The `_author` is used only as an authorisation token here. The connect helper does + not need the author identity — the author is the owner whose link data already + carried the relay key, and the relay member's keys/profile are fetched from the + relay's own short link. + +### 6.6 `xGrpMsgForward` — no change needed + +Already validates the forwarder is a relay (`isMemberGrpFwdRelay`, 3340) and dispatches +to `processForwardedMsg`. Adding the new event tag inside that switch is the entirety +of the receive-side change. + +### 6.7 What NOT to change in `Subscriber.hs` + +- Do not touch the `CFGetRelayDataJoin` LDATA callback (1131-1160). Its end state + (subscriber-side) is exactly the continuation we want; the helper hands off to it. +- Do not touch the `CON` handler at 823-865 for relay members. The `firstConnectedHost` + branch (855-859) handles the first-connected-relay UI events; subsequent relays go + through 859. After `XGrpRelayNew`-driven connect, the new relay's `CON` will land in + this same handler and get `firstConnectedHost = False` (because at least one relay is + already connected), which is correct. +- Do not modify the `CONF`/`XGrpRelayAcpt` path at 768-772. That is owner-side. + +--- + +## 7. `docs/protocol/channels-protocol.md` updates (S3) + +### 7.1 Signing-required table + +Section: `## Protocol → ### Message signing → "Which messages require signatures:"` +table (lines 84-97). Add a row after `x.grp.mem.restrict`: + +``` +| `x.grp.relay.new` | Announce new relay to subscribers | Required | +``` + +Phrasing matches existing `Description` cells (verb + object). + +### 7.2 New subsection "Relay addition" + +Insert after the existing `### Relay acceptance` subsection (lines 42-58). Heading +level `###`, four short paragraphs: + +1. **Owner-side trigger.** When the owner has accepted a relay (existing flow, + `x.grp.relay.acpt` at line 36) and the agent confirms the link-data update by + delivering the LINK event, the owner promotes the relay locally to active and + sends `x.grp.relay.new` to every other currently-connected relay of the channel + (excluding the relay being announced). +2. **Wire format.** Single-field JSON object: `{"relayLink": ""}`. + Owner-signed via the same `CBGroup` binding prefix used for all administrative + events (see [Message signing](#message-signing)). +3. **Relay forwarding semantics.** Each relay forwards `x.grp.relay.new` verbatim to + all of its subscribers via the standard delivery pipeline (delivery_task / + delivery_job, see [Delivery pipeline](#delivery-pipeline)). The relay does **not** + create a member record for the announced relay — relays do not connect to other + relays of the same channel. +4. **Subscriber receive semantics.** The subscriber resolves the announced short link + asynchronously, creates a relay-member row (or reuses an existing active row), and + binds the resulting agent connection without blocking the receive loop. If the + subscriber's client doesn't recognise the event (older version), it is parsed as + `XUnknown` and ignored; the next `APIGetUpdatedGroupLinkData` (channel open) reaches + the same end state via `syncSubscriberRelays`. +5. **Idempotence.** The receive loop wraps each agent message in a per-group entity + lock (`CLGroup groupId`); the same lock is held by `APIGetUpdatedGroupLinkData`. + A duplicate `x.grp.relay.new` arriving from a second relay finds an active row + + active connection and is a no-op. + +### 7.3 What NOT to change + +- Do not renumber existing sections. +- Do not modify the `Binary batch format` section — `x.grp.relay.new` is a + `signedElement` like every other administrative event; no new ABNF. +- Do not touch the `Channel-as-sender messages` section — `XGrpRelayNew` is owner-bound, + `CBGroup`, never `CBChannel`. + +--- + +## 8. Test plan (S8) + +All tests live under `tests/ChatTests/Channels.hs` (or a dedicated +`tests/ChatTests/Channels/RelayAnnounce.hs` if the file is getting unwieldy — confirm +with reviewer). Each test maps to a row in the overview's "Test surface". + +| Overview test | Concrete test name | Harness | +|---|---|---| +| Owner adds relay → subscribers receive `XGrpRelayNew` and connect without channel open | `testRelayAnnounceOnlineSubscriber` | uses `testChat3` (owner + relay + subscriber); after channel is up, owner adds a second relay; assert subscriber's relay-member count for that group becomes 2 with both `GSMemConnected`, no `APIGetUpdatedGroupLinkData` invoked. | +| Two relays forward the announce; subscriber connects exactly once | `testRelayAnnounceDedupes` | `testChat4` (owner + 2 existing relays + subscriber); owner adds third relay; both existing relays forward; assert exactly one new relay-member row, exactly one connection. Inspect via `withCCStore (getGroupRelayMembers …)`. | +| Race vs. `APIGetUpdatedGroupLinkData` for same relay | `testRelayAnnounceRaceWithSync` | drive `APIGetUpdatedGroupLinkData` and `XGrpRelayNew` concurrently; assert no double row; rely on the existing `withGroupLock` to serialize. | +| `GSMemLeft` row preserved on re-add | `testRelayAnnounceReAddPreservesHistory` | owner adds relay, removes it, adds again with same link; assert two `GroupMember` rows for that link (one `GSMemLeft`, one current); the historical row is what drives the "removed by operator" UI. | +| Old subscriber ignores | `testRelayAnnounceOldSubscriber` | use `chatVersionRange` overrides to simulate an older subscriber; assert event is logged as unknown but produces no error item; `syncSubscriberRelays` invocation on next channel open creates the row. | +| Old relay drops | `testRelayAnnounceOldRelay` | inverse: relay's `chatVersionRange` does not include `XGrpRelayNew_` → `processEvent` default `messageError "unsupported"`. Subscribers fall back to on-open sync. | +| Bad signature | `testRelayAnnounceBadSignature` | inject an unsigned (or wrong-signed) `XGrpRelayNew` directly via the test SMP harness; assert `RGEMsgBadSignature` chat item is created on subscriber. | + +Helpers reused: `withSmpServer`, `testChat`, `testChat3`, `testChat4`, `awaitListChat`, +`withCCStore`, `getGroupRelayMembers`. Add a small helper in the test module +`assertRelayMemberCount :: TestCC -> GroupId -> Int -> IO ()` if not already present. + +For the dedup test specifically, assertion shape: + +``` +m <- getGroupRelayMembers db vr user gInfo +let relayRows = filter (\GM -> relayLink GM == Just newRl && memberCurrent GM) m +length relayRows `shouldBe` 1 +length (filter (isJust . activeConn) relayRows) `shouldBe` 1 +``` + +--- + +## 9. Risk register + +1. **Race: event arrives during channel open.** The receive loop and + `APIGetUpdatedGroupLinkData` share `CLGroup groupId`. Whichever path runs first + creates/uses the row; the second sees an active row + active conn (or creates one + if not yet) and is a no-op. Tested via `testRelayAnnounceRaceWithSync`. + +2. **Agent coalescing of `setConnShortLink` writes.** Today the receive-loop group lock + serializes `XGrpRelayAcpt` handling, so each LINK callback sees one transition. If + the agent ever batches multiple writes into one callback, the `NonEmpty + (XGrpRelayNew _)` send path stays correct: every newly-active relay gets announced. + No fix needed; defensive shape is already there. + +3. **Old relay between owner and subscriber.** Old relay's `processEvent` default branch + drops the event with `messageError "unsupported"`. Subscribers behind that relay + recover via on-open `syncSubscriberRelays`. Documented in the protocol doc and + covered by `testRelayAnnounceOldRelay`. + +4. **Malformed signature.** `requiresSignature XGrpRelayNew_ = True` causes + `withVerifiedMsg` to reject and produce `RGEMsgBadSignature`. Standard path; tested. + +5. **Agent error during `getAgentConnShortLinkAsync` (step 3 of + `connectToRelayAsync`).** If the failure happens before + `createRelayMemberConnectionAsync` runs, `activeConn` is `Nothing` + and the next trigger retries automatically. If the call succeeds + but a later async step (LDATA, CONF, CON) stalls, `activeConn` + exists in a non-`ConnReady` state; the chat layer does not retry + by design (Option A simple skip). The agent layer's internal + retries on subscription resume drive recovery for transient + network failures. Permanent stalls are recovered via explicit + retry paths (`retryRelayConnectionAsync`, channel re-join). + +6. **Link-data fetch failure after pre-created member row.** Two + sub-cases. (a) `createRelayMemberConnectionAsync` not yet run: + `activeConn = Nothing`, next trigger retries (`XGrpRelayNew` + arrival from another relay or channel re-open via + `syncSubscriberRelays`). (b) Connection record exists but LDATA + failed: `activeConn = Just _`, chat layer skips by Option A; + agent layer retries internally on subscription resume. + +7. **Active-status filter on lookup breaks other call sites.** The filter is added in + place on `getCreateRelayForMember`'s inner lookup. Its lone existing caller is the + subscriber-join path (`APIConnectPreparedGroup` → `connectToRelay`, Commands.hs:2141 + / 3597-3613); rows there are created with `GSMemAccepted`, which is `memberCurrent`, + so the filtered lookup still finds them on retry. Observable behaviour unchanged for + the existing caller. Audit done in §3.3; reviewer to confirm. + +8. **Multiple owners (future).** `LINK` callback only fires for the local owner's own + `setConnShortLink` calls (per existing TODO at Subscriber.hs:1327-1329). A second + owner adding a relay won't trigger the event from this owner — the second owner + would emit it themselves. Out of scope for current single-owner channels. + +--- + +## 10. Backward compatibility + +- **No schema migration.** The plan adds zero columns and zero tables. The new lookup + uses an existing column (`group_members.relay_link`) with an existing index path. +- **No protocol-version bump in the chat versioning.** The new tag is parsed as + `XUnknown` by clients that do not recognise `"x.grp.relay.new"` (Protocol.hs:1129 + default branch in `strDecode`). `XUnknown` is silently ignored when reached by + `processEvent` (Subscriber.hs:1032 catch-all `messageError`); since this is on the + receive side of an old client, the message is logged as unsupported and the channel + state is unaffected. +- **No serialization-compat shim.** The single-field JSON form means old clients fall + through to `XUnknown_` cleanly without any optional-field hand-rolling. + +--- + +## 11. Out of scope + +- **Owner authorization-chain pushes.** Adding/removing owners is governed by the + multi-owner roadmap (channels-overview.md §"Governance evolution"). `XGrpRelayNew` + does not carry owner-chain payload. +- **Profile pushes.** Subscriber profile changes are out of scope; relay profile + arrives via the relay's own link data in the existing `CFGetRelayDataJoin` LDATA + flow. +- **Content batching beyond the LINK callback.** The `NonEmpty` shape is defensive + for agent-side coalescing, not a general batching mechanism. +- **Retry-on-failure semantics for the new async path.** Existing + `retryRelayConnectionAsync` (Commands.hs:2168-2174) covers the subscriber-join + retry of failed initial connects (`APIConnectPreparedGroup` tail recovery). For + event-driven re-attempts, on-open `syncSubscriberRelays` is the recovery mechanism; + per-link retry timers are not added. +- **Deletion of `connectToRelay` (sync).** Default kept. The lone caller is + `APIConnectPreparedGroup` (subscriber's initial channel-join flow, + Commands.hs:2108-2161), not owner channel creation. Deletion is a reviewer-confirmed + follow-up if subscriber-join is converged onto async — see §5.1 for why the sync + flow is intentional there. + +--- + +## 12. Concerns with overview + +- **Overview §"Files touched" lists "remove sync `connectToRelay`".** After tracing + Commands.hs:2141 (`mapConcurrently (connectToRelay user gInfo') relays` inside + `APIConnectPreparedGroup`), the sync helper still has a real caller — the + **subscriber's initial channel-join** (not owner setup). Deleting it now would + either break that path or force `APIConnectPreparedGroup` onto the async helper, + which is a separate concern (different UX expectations: spinner-blocking immediate + feedback vs. fire-and-forget). Plan defers this to a follow-up. Reviewer to confirm. + +- **Overview §"Subscriber — receive" mentions `withAuthor XGrpRelayNew_`.** That + function name (the tag) does not currently exist in `Protocol.hs`; it lands in §2.2 + of this plan. Naming preserved verbatim. + +- **Overview §"Test surface" "Owner with old relay → relay drops the event".** The + current `processEvent` default branch is at Subscriber.hs:1032. Verified: the + default `_ -> Nothing <$ messageError ("unsupported message: " <> tshow event)` + drops the event after logging. This matches the overview's expectation. diff --git a/plans/2026-05-08-relay-announce.md b/plans/2026-05-08-relay-announce.md new file mode 100644 index 0000000000..37ffbd88ba --- /dev/null +++ b/plans/2026-05-08-relay-announce.md @@ -0,0 +1,119 @@ +# Plan: owner-pushed relay announcement (`XGrpRelayNew`) + +## Goal +Subscribers learn of newly added relays immediately via an owner-pushed event, +rather than only on next channel open via `syncSubscriberRelays`. + +## Wire-format +- New event: `XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json`, tag `x.grp.relay.new`. +- Add to `isForwardedGroupMsg` in `Protocol.hs`. +- Add to required-signed-by-owner table in `docs/protocol/channels-protocol.md`. + Reuses existing `CBGroup`-prefixed signing infrastructure. + +## Owner — send site +- In LINK callback at `Subscriber.hs:1305-1322`, after the fold over relays + that drives `RSAccepted → RSActive` transitions. +- Collect `relayLink` for every relay that transitioned to Active in this callback. +- If non-empty, build `events = XGrpRelayNew rl1 :| [XGrpRelayNew rl2, ...]` + and call `sendGroupMessages user gInfo Nothing False otherRelays events`. +- Recipients: channel's currently-connected relays minus the newly-active ones + (the announced relays don't need self-announcement). +- Batched shape is defensive, not load-bearing. The receive-loop group lock + serializes `XGrpRelayAcpt` handling and the subsequent + `setGroupLinkDataAsync` → LINK chain, so each LINK callback typically + transitions at most one relay. Coding the send as a `NonEmpty` of + `XGrpRelayNew` events keeps the path correct if the agent ever consolidates + link-data writes. + +## Relay — forward only +- `processEvent` (Subscriber.hs:980-1032) gets a new case: + `XGrpRelayNew _ -> pure $ Just (DeliveryTaskContext (DJSGroup ...) False)`. +- No local handler — relay does NOT create a member record for the announced + relay (departure from `XGrpMemNew` semantics; relays don't connect to other + relays of the same channel). +- Forwarding is verbatim through binary-batch format, signature preserved. +- Old relay (no tag): `_ -> messageError "unsupported"` path drops the message. + Fallback: subscriber's on-open `syncSubscriberRelays` still works. + +## Subscriber — receive +- Add case in `processForwardedMsg` (Subscriber.hs:3357-3378): + `XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> connectToRelayAsync user gInfo rl`. +- Author resolution + signature verification via existing `withAuthor` / + `withVerifiedMsg` machinery — same boundary as `XGrpInfo` etc. today. +- `FwdChannel` (channel-as-sender) is NOT valid for this event — it is + administrative and must be attributed to a signing owner. + +## Subscriber — `connectToRelayAsync` helper +Place in `Internal.hs`. Both event handler and `syncSubscriberRelays` call it. +Body: + + 1. Look up active (`memberCurrent`) relay-member row by `relay_link`. + - If found AND has active connection → skip (already in flight or done). + - If found but no active connection → use it; proceed. + - If not found → create new relay-member row (with `relay_link`, role + `GRRelay`, status `GSMemAccepted`, no member-id/key/profile yet). + 2. `getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink` + → returns `(cmdId, agentConnId)`. + 3. `createRelayMemberConnectionAsync` binds those to the relay-member. + 4. Return. Continuation is the existing `CFGetRelayDataJoin` LDATA callback + at Subscriber.hs:1131-1160 (updates relay-member with member-id/key/profile, + calls `joinAgentConnectionAsync` → eventual `CON` flips status to + `GSMemConnected`). + +A `GSMemLeft` historical row for the same `relay_link` is left in place +(displays "removed by operator"). Lookup must filter to `memberCurrent`. + +## Idempotence and races +- Receive loop wraps each agent message in `withEntityLock` keyed by the + connection's lock entity (Subscriber.hs:115-117). Relay-member connections + resolve to `CLGroup groupId` (Store/Connections.hs:51-65). +- `APIGetUpdatedGroupLinkData` already uses `withGroupLock "syncSubscriberRelays" groupId`. +- Same key on both paths → event handler and open-channel command cannot + interleave for a given group_id. No additional lock needed. +- Inside the lock, "active row + active conn" check is sufficient. No + `justCreated` flag, no per-link mutex. + +## `syncSubscriberRelays` migration +- Move from `Commands.hs` to `Internal.hs`. +- "Add" half: replace synchronous `connectToRelay` with `connectToRelayAsync`. +- "Remove" half (Commands.hs:3623-3633): unchanged. +- `connectToRelay` (sync) deletable once event-driven path is wired and + no caller remains. + +## Old client compatibility +- Old subscriber: parses `XGrpRelayNew` as `XUnknown`, ignores. On-open + `syncSubscriberRelays` is the fallback path. +- Old relay: drops the message in `processEvent`'s default branch. Subscribers + on those relays fall back to on-open sync. Acceptable graceful degradation. + +## Test surface +- Owner adds relay → existing subscribers (online) receive `XGrpRelayNew` and + connect without channel open. +- Channel with two existing relays: owner adds a third relay; both existing + relays forward `XGrpRelayNew` for the new relay to subscribers in parallel + → shared-msg-id dedup leaves only one copy reaching the helper; subscriber + connects to the announced relay exactly once. +- `XGrpRelayNew` arrives while subscriber is mid-`APIGetUpdatedGroupLinkData` + for the same relay → group lock serializes; no double connection. +- Subscriber re-add scenario: previous `GSMemLeft` row for same `relay_link` + → new active row created, old row preserved for history. +- Old subscriber receives forwarded `XGrpRelayNew` → ignored, channel-open + sync still recovers. +- Owner with old relay → relay drops the event; subscribers learn on open. +- Bad signature on `XGrpRelayNew` → rejected with bad-signature event. + +## Files touched (anticipated) +- `src/Simplex/Chat/Protocol.hs` — event constructor, tag, JSON encode/parse, + `isForwardedGroupMsg`. +- `src/Simplex/Chat/Library/Internal.hs` — `connectToRelayAsync` helper, + `syncSubscriberRelays` moved here. +- `src/Simplex/Chat/Library/Subscriber.hs` — owner send (LINK callback), + relay forward-only `processEvent` case, subscriber forwarded + `processForwardedMsg` case. +- `src/Simplex/Chat/Library/Commands.hs` — remove sync `connectToRelay`, + `APIGetUpdatedGroupLinkData` calls async helper. +- `src/Simplex/Chat/Store/Groups.hs` — adjust relay-member lookup to filter + on `memberCurrent`. +- `docs/protocol/channels-protocol.md` — signing-required table row, + relay-addition subsection. +- `tests/ChatTests/...` — tests per "Test surface" above. diff --git a/plans/2026-05-09-desktop-tray-implementation.md b/plans/2026-05-09-desktop-tray-implementation.md new file mode 100644 index 0000000000..a3f047cd07 --- /dev/null +++ b/plans/2026-05-09-desktop-tray-implementation.md @@ -0,0 +1,422 @@ +# Desktop tray icon — implementation plan + +Companion to the design at `plans/2026-05-09-desktop-tray.md`. Read that first. + +## What + +Seven small commits that build the feature incrementally. After each commit the build is green and the app still runs; only the last commit makes the feature visible to the user end-to-end. + +## Why + +We split the work this way so each commit is reviewable on its own and revertable without unwinding others. The order keeps the build green throughout (no commit introduces a reference to something the next commit will define). + +## How + +### Pre-flight + +- Pull the branch `sh/tray` (current branch). It is at `stable`. +- Confirm dev environment can build desktop: `cd apps/multiplatform && ./gradlew :common:desktopMainClasses` — should succeed before any change. +- Read `plans/2026-05-09-desktop-tray.md` end to end. The implementation steps below assume that design is settled. + +--- + +### Task 1 — `CloseBehavior` enum + preference + +**Files** +- `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` + +**What to add.** The enum lives next to other small enums in this file (search for `enum class LAMode` for placement convention). The preference goes in `class AppPreferences` next to `notificationsMode`. + +Match the existing pattern (use `values().firstOrNull { it.name == this }`, not `entries`, to stay consistent with `LAMode` and others in this file): + +```kotlin +enum class CloseBehavior { + Ask, Quit, MinimizeToTray; + companion object { val default = Ask } +} + +// In AppPreferences: +val closeBehavior: SharedPreference = + mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) +``` + +Add the constant at the bottom of `AppPreferences` next to other `SHARED_PREFS_*` constants: + +```kotlin +private const val SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR = "DesktopCloseBehavior" +``` + +**Verify.** Build: `./gradlew :common:desktopMainClasses` — succeeds. No behavior change yet. + +**Commit.** `desktop: add CloseBehavior preference` + +--- + +### Task 2 — Window-visibility state + branching close handler (no dialog, no tray yet) + +**(Note: a `Task 2 — Add ComposeNativeTray dependency` is removed. We now use Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`, already on the classpath via the `org.jetbrains.compose` plugin. No new dep.)** + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` + +**What to change.** + +1. Add `windowVisible` to `SimplexWindowState` (the class at line 227): + +```kotlin +class SimplexWindowState { + // ...existing fields... + val windowVisible = mutableStateOf(true) +} +``` + +2. In `AppWindow`, pass it to `Window`: + +```kotlin +Window( + state = windowState, + visible = simplexWindowState.windowVisible.value, + icon = painterResource(MR.images.ic_simplex), + onCloseRequest = { handleCloseRequest(closedByError) }, + // ...rest unchanged... +) +``` + +3. Add the handler at file scope (or near `showApp`). Temporarily make `Ask` fall through to `Quit` — the dialog comes in Task 3: + +```kotlin +private fun ApplicationScope.handleCloseRequest(closedByError: MutableState) { + if (closedByError.value) { closedByError.value = false; exitApplication(); return } + when (appPrefs.closeBehavior.get()) { + CloseBehavior.Quit, CloseBehavior.Ask -> { + closedByError.value = false + exitApplication() + } + CloseBehavior.MinimizeToTray -> { + simplexWindowState.windowVisible.value = false + } + } +} +``` + +The `MinimizeToTray` branch will get a tray-availability guard in Task 5 (defensive: a user could have set the pref on a different machine where tray works). + +(Imports: `chat.simplex.common.model.CloseBehavior`, `chat.simplex.common.model.ChatController.appPrefs`.) + +**Verify.** Build + run desktop: + +``` +./gradlew :desktop:run +``` + +Click X — app exits exactly as today. No dialog, no tray. (Internal preference is `Ask`, branch falls through to Quit.) + +**Commit.** `desktop: branch close handler on CloseBehavior preference` + +--- + +### Task 3 — First-close dialog + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)* +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Strings.** Add to `strings.xml`: + +```xml +Minimize to tray? +If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. +Close the app +Minimize to tray +``` + +**`DesktopTray.kt` — dialog only.** A `mutableStateOf?>` global, and a Composable that, when set, renders a non-dismissible `Dialog` with the two buttons. Skeleton: + +```kotlin +package chat.simplex.common + +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* +import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.stringResource + +private val pendingCloseChoice = mutableStateOf(null) + +private data class CloseChoice(val onClose: () -> Unit, val onMinimize: () -> Unit) + +fun requestCloseBehavior(onClose: () -> Unit, onMinimize: () -> Unit) { + pendingCloseChoice.value = CloseChoice(onClose, onMinimize) +} + +@Composable +fun CloseBehaviorDialog() { + val choice = pendingCloseChoice.value ?: return + Dialog( + onCloseRequest = { /* swallow — non-dismissible */ }, + state = rememberDialogState(width = 420.dp, height = 220.dp), + title = stringResource(MR.strings.close_behavior_dialog_title), + resizable = false, + ) { + Column(Modifier.padding(24.dp)) { + Text(stringResource(MR.strings.close_behavior_dialog_text)) + Spacer(Modifier.height(24.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { pendingCloseChoice.value = null; choice.onClose() }, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.error), + ) { Text(stringResource(MR.strings.close_behavior_dialog_close)) } + // Hide the Minimize button when tray isn't supported (stock GNOME). + // The dialog still asks once so the user gets a definitive Quit answer + // and doesn't see the dialog again. trayIsAvailable is defined in Task 5; + // until then, the button is always shown. + Button( + onClick = { pendingCloseChoice.value = null; choice.onMinimize() }, + colors = ButtonDefaults.buttonColors(backgroundColor = MaterialTheme.colors.primary), + ) { Text(stringResource(MR.strings.close_behavior_dialog_minimize)) } + } + } + } +} +``` + +**Wire it up in `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`, render `CloseBehaviorDialog()` alongside `AppWindow`. Update `handleCloseRequest`'s `Ask` branch: + +```kotlin +CloseBehavior.Ask -> requestCloseBehavior( + onClose = { + appPrefs.closeBehavior.set(CloseBehavior.Quit) + closedByError.value = false + exitApplication() + }, + onMinimize = { + appPrefs.closeBehavior.set(CloseBehavior.MinimizeToTray) + simplexWindowState.windowVisible.value = false + } +) +``` + +**Verify.** Run, click X — dialog appears with the exact text and button colors. Click "Close the app" → exits. Reopen, click X — exits without dialog (preference is `Quit`). + +To reset the preference for re-testing, delete the SimpleX Chat desktop preferences file: +- Linux: `~/.config/simplex/SimpleXChatDesktop.properties` +- macOS: `~/Library/Preferences/SimpleXChatDesktop.properties` +- Windows: `%AppData%\SimpleX\SimpleXChatDesktop.properties` + +Click "Minimize to tray" → window hides; the app process keeps running but is invisible (no tray icon yet — that's Task 6). Kill the JVM with Ctrl-C in the terminal to recover. + +**Commit.** `desktop: first-close dialog for tray choice` + +--- + +### Task 4 — Tray icon resources + +**Files** +- `apps/multiplatform/common/src/commonMain/resources/MR/images/ic_simplex_tray_dot.svg` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Icons.** Reuse the existing `MR.images.ic_simplex` for the no-unread case and add a single new asset for the unread case: + +- `ic_simplex_tray_dot` — copy of `ic_simplex.svg` with a small red filled circle added in the bottom-right (~6px radius in the 40×40 viewBox). + +Drop the SVG into `MR/images/`. Moko picks it up; refer to as `MR.images.ic_simplex_tray_dot`. Run a build to check generation: `./gradlew :common:generateMRcommonMain`. + +**Strings.** Tray menu items + tooltip strings: + +```xml +Show SimpleX +Quit SimpleX +SimpleX +SimpleX — %d unread +``` + +**Verify.** Build succeeds; the generated `MR.images.ic_simplex_tray_dot` and `MR.strings.tray_*` symbols compile when referenced from a temporary scratch file (delete after). + +**Commit.** `desktop: tray icon assets and menu strings` + +--- + +### Task 5 — Tray composable (no unread indicator yet) + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` + +**Add to `DesktopTray.kt`.** Tray-availability probe, functions to show window and quit, the Tray composable itself. + +```kotlin +import androidx.compose.ui.window.ApplicationScope +import androidx.compose.ui.window.Tray +import androidx.compose.ui.window.MenuBar +import dev.icerock.moko.resources.compose.painterResource +import dev.icerock.moko.resources.compose.stringResource +import java.awt.SystemTray + +// Probed once at startup. Performs a real add/remove of a transparent TrayIcon +// because SystemTray.isSupported() can return true while add() throws (JDK-8322750). +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe); tray.remove(probe); true + } catch (e: AWTException) { false } catch (e: SecurityException) { false } +} + +fun showWindow() { + simplexWindowState.windowVisible.value = true + simplexWindowState.window?.toFront() + simplexWindowState.window?.requestFocus() +} + +@Composable +fun ApplicationScope.SimplexTray(closedByError: MutableState) { + if (!trayIsAvailable) return + if (appPrefs.closeBehavior.state.value != CloseBehavior.MinimizeToTray) return + Tray( + icon = painterResource(MR.images.ic_simplex_tray), + tooltip = stringResource(MR.strings.tray_tooltip), + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { + closedByError.value = false + exitApplication() + }) + } + ) +} +``` + +(Note: this uses Compose Multiplatform's built-in `androidx.compose.ui.window.Tray`. The API is `icon: Painter`, `onAction` (not `primaryAction`), menu DSL uses `Separator()` (not `Divider()`).) + +**Update `DesktopApp.kt`'s close handler** to add the defensive tray-availability check from Task 2's TODO: + +```kotlin +CloseBehavior.MinimizeToTray -> { + if (trayIsAvailable) { + simplexWindowState.windowVisible.value = false + } else { + closedByError.value = false + exitApplication() + } +} +``` + +**Wire into `DesktopApp.kt`.** Inside `application(exitProcessOnExit = false) { … }`: + +```kotlin +SimplexTray(closedByError) +CloseBehaviorDialog() +AppWindow(closedByError) +``` + +The order doesn't affect rendering — the tray and dialog are top-level surfaces. + +**Verify.** Run; in the dialog pick "Minimize to tray". Window hides; tray icon appears. Left-click tray — window restores. Right-click tray — menu has "Show SimpleX" and "Quit SimpleX". Both work. Quit, restart — preference persists; clicking X hides directly without dialog. Tray icon appears at app startup (because the preference is now `MinimizeToTray`). + +**Commit.** `desktop: system tray icon with show/quit menu` + +--- + +### Task 6 — Unread indicator + tooltip count + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` + +**Change `SimplexTray`.** Replace the static icon and tooltip with reactive ones: + +```kotlin +// UserInfo.unreadCount is incremented only when ntfsEnabled(item) — see SimpleXAPI.kt:2781-2783. +val unread by remember { + derivedStateOf { ChatModel.users.sumOf { it.unreadCount } } +} +val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex +val tooltip = + if (unread > 0) stringResource(MR.strings.tray_tooltip_unread, unread) + else stringResource(MR.strings.tray_tooltip) + +Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + // onAction + menu unchanged +) +``` + +**Verify.** +1. With "Minimize to tray" enabled, hide the window. +2. Trigger a notification (have another account/contact send you a message; or open a direct chat with notifications enabled and post from another device). +3. Tray icon switches to the red-dot variant; tooltip shows "SimpleX — 1 unread" (or higher). +4. Click tray, view the message in the relevant chat. Icon reverts to the plain variant; tooltip becomes "SimpleX". + +**Commit.** `desktop: unread indicator on tray icon` + +--- + +### Task 7 — Appearance settings toggle + +**Files** +- `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt` +- `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` + +**Strings.** + +```xml +Minimize to tray when closing window +Keep SimpleX running in the background to receive messages. +``` + +**UI row.** In `AppearanceLayout` (the Composable around line ~38), add a new section row using the existing `SectionItemView` / `SettingsActionItemWithContent` / similar patterns visible in this file. The entire row is gated on `trayIsAvailable` — if the OS has no tray host, the toggle is omitted. Read the surrounding rows for the exact convention; the snippet below is illustrative: + +```kotlin +if (trayIsAvailable) { + val pref = remember { appPrefs.closeBehavior.state } + val on = pref.value == CloseBehavior.MinimizeToTray + SectionItemView { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(MR.strings.appearance_minimize_to_tray)) + Text( + stringResource(MR.strings.appearance_minimize_to_tray_desc), + style = MaterialTheme.typography.caption, + color = MaterialTheme.colors.onSurface.copy(alpha = 0.7f) + ) + } + Switch( + checked = on, + onCheckedChange = { checked -> + appPrefs.closeBehavior.set(if (checked) CloseBehavior.MinimizeToTray else CloseBehavior.Quit) + } + ) + } + } +} +``` + +Place the row in the existing `AppearanceLayout` Composable, after the theme/dark-mode rows and before the language selector — that grouping is for general window-and-display preferences and the new toggle fits there. Match the styling of nearby rows. If a clearer section emerges during implementation, add a new `SectionView` with a "Window" header instead. + +**Verify.** Open Appearance settings; toggle the row off — tray icon disappears; click X exits with no dialog. Toggle back on — tray icon reappears (Compose recomposes the gated `Tray` composable). Window-close behavior still depends on the toggle. + +**Commit.** `desktop: Appearance toggle for minimize-to-tray` + +--- + +### Final manual test pass + +Run the full test plan from the spec on each platform you can reach (Linux KDE, Windows 11, macOS): + +1. Fresh install (clear `~/.config/simplex/` or per-OS data dir). Click X → dialog with the right text and button colors. Esc / outside-tap do nothing. +2. Pick Close → exits. Reopen → click X → exits with no dialog. +3. Reset, pick Minimize to tray → window hides, tray icon shows. +4. Receive a message → red-dot variant + tooltip count. +5. Click tray → window restores and focuses (acceptable if focus is best-effort per spec). +6. Right-click tray → Show / Quit both work. +7. Appearance toggle off → tray vanishes, X exits without dialog. +8. Appearance toggle on → tray reappears. + +If anything fails, file follow-ups; the spec's "out of scope" list catches the expected omissions (autostart, number-on-icon, etc.). diff --git a/plans/2026-05-09-desktop-tray.md b/plans/2026-05-09-desktop-tray.md new file mode 100644 index 0000000000..ce67a83240 --- /dev/null +++ b/plans/2026-05-09-desktop-tray.md @@ -0,0 +1,197 @@ +# Desktop tray icon — minimize to tray on close + +## What + +Add a system tray icon (Windows notification area, Linux StatusNotifierItem, macOS menu bar) to the SimpleX desktop app, with a "minimize to tray" close behavior gated on first-time user choice. + +Three pieces: + +1. **First-close dialog** — the first time the user clicks the window's close (X) button, a modal asks whether to close the app or minimize it to the tray. The choice is remembered. +2. **Tray icon** — when the user has chosen "minimize to tray", the app installs a tray icon with a small right-click menu (Show / Quit) and an unread indicator. Clicking the icon restores the window. +3. **Appearance setting** — a "Minimize to tray when closing window" toggle in Appearance settings lets the user change their mind later. + +Scope: Linux + Windows + macOS. No autostart. No number-on-icon unread badge. No profile switcher in the tray menu. + +## Why + +Today, closing the SimpleX desktop window quits the process and the user stops receiving messages until they reopen the app. There is no way to keep the app running quietly in the background, which is the standard expectation for a chat client. + +We want this to be opt-in rather than a behavior change for existing users — hence the dialog on first close. Users who prefer the current quit-on-close behavior get exactly that with one click and never see the dialog again. Users who want background message delivery get it with one click and can manage it from settings. + +We are using Compose Multiplatform's built-in `androidx.compose.ui.window.Tray` rather than a third-party library. It works cleanly on Windows, macOS, and Linux desktops with a system tray host (KDE Plasma, XFCE, Cinnamon, MATE, GNOME with the AppIndicator extension). The trade-off is that on stock GNOME the JDK deliberately returns `false` from `SystemTray.isSupported()` (per JDK-8322750), so we **probe at startup and disable the feature entirely** when the OS reports no tray support — the dialog hides the "Minimize to tray" option and the Appearance toggle is hidden too. Users with a working tray get the feature; users without never see broken/invisible UI. + +All tray-specific code lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces calling into tray functionality from `commonMain`. + +Users upgrading from a prior version will see the dialog on their first window-close after the update — that is intentional. The dialog is the chosen mechanism for getting consent before keeping a process running in the background, and an existing user has no way to give that consent in advance. + +## How + +### Close behavior — preference and flow + +Add an enum preference: + +```kotlin +enum class CloseBehavior { Ask, Quit, MinimizeToTray } + +// in AppPreferences: +val closeBehavior: SharedPreference = + mkSafeEnumPreference(SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR, CloseBehavior.default) +``` + +`Ask` is the default for fresh installs and for users upgrading from a version that did not have this preference. + +Replace the inline close handler in `DesktopApp.kt` (currently `onCloseRequest = { closedByError.value = false; exitApplication() }`) with a function that branches on the preference: + +- **Crash recovery first.** If `closedByError.value == true`, exit immediately with no dialog, no minimize. The crash handler at `DesktopApp.kt:46-47` dispatches `WINDOW_CLOSING` and depends on the application loop ending so it can re-enter. Honouring `closedByError` is what keeps that path working. +- `Quit` → exit immediately, as today. +- `MinimizeToTray` → set `simplexWindowState.windowVisible.value = false` and return. +- `Ask` → show the first-close dialog. The dialog's button writes the preference and then performs the corresponding action. + +The same handler is invoked for the X button, Alt+F4 on Windows, and the macOS red traffic-light close — Compose routes all three through `onCloseRequest`. **macOS Cmd+Q is not routed through `onCloseRequest`**: it goes through the application menu's Quit and calls `exitApplication()` directly. We accept that as "always quit" — Cmd+Q is an explicit user intent to quit the application and should not be intercepted by the dialog. Programmatic `WindowEvent.WINDOW_CLOSING` (e.g. from the crash handler) reaches `onCloseRequest` and is handled by the `closedByError` branch above. + +The dialog is non-dismissible (no Esc, no outside-tap) so the user must choose. Wording verbatim: + +> **Minimize to tray?** +> +> If you choose Close, messages won't be received. +> You can change it later in Appearance settings. +> +> [ Close the app ] [ Minimize to tray ] + +The "Close the app" button uses `MaterialTheme.colors.error` (red); "Minimize to tray" uses `MaterialTheme.colors.primary` (blue). The dialog is implemented bespoke (not via the existing `AlertManager`), because `AlertManager` does not support the non-dismissible + custom-button-color combination needed here. + +The Compose application loop already runs with `exitProcessOnExit = false`, so hiding the window does not exit the process. No restructuring of `showApp()` is needed. + +### Tray icon + +No new dependency. We use `androidx.compose.ui.window.Tray` (built into Compose Multiplatform, already on the classpath). It wraps `java.awt.SystemTray` under the hood — works wherever AWT's tray works, returns silently when it doesn't. + +**Tray availability probe.** `java.awt.SystemTray.isSupported()` alone is not reliable — there is a JDK pattern where it returns `true` but `SystemTray.add()` then throws `AWTException` (and Compose-MP does not catch it). We expose a `desktopMain` value that runs a real add/remove of a transparent `TrayIcon` inside a `try/catch` and caches the result: + +```kotlin +val trayIsAvailable: Boolean by lazy { + if (!SystemTray.isSupported()) return@lazy false + try { + val tray = SystemTray.getSystemTray() + val probe = TrayIcon(BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB)) + tray.add(probe) + tray.remove(probe) + true + } catch (e: AWTException) { false } + catch (e: SecurityException) { false } +} +``` + +The probe is force-evaluated at the top of `showApp()` (off the EDT) so the JDK-8322750 GNOME detection subprocess does not block composition. When `false`: the Appearance toggle is hidden, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the preference was carried over from a tray-capable machine). + +The tray composable lives next to `AppWindow` inside `application(exitProcessOnExit = false) { … }` in `showApp()`. It is gated by the preference AND by tray availability: + +```kotlin +if (trayIsAvailable && appPrefs.closeBehavior.state.value == CloseBehavior.MinimizeToTray) { + // UserInfo.unreadCount is the pre-aggregated, ntfs-filtered counter — see SimpleXAPI.kt:2781-2783. + val unread by remember { derivedStateOf { + ChatModel.users.sumOf { it.unreadCount } + } } + val iconRes = if (unread > 0) MR.images.ic_simplex_tray_dot else MR.images.ic_simplex + val tooltip = if (unread > 0) + stringResource(MR.strings.tray_tooltip_unread, unread) + else + stringResource(MR.strings.tray_tooltip) + Tray( + icon = painterResource(iconRes), + tooltip = tooltip, + onAction = ::showWindow, + menu = { + Item(stringResource(MR.strings.tray_show), onClick = ::showWindow) + Separator() + Item(stringResource(MR.strings.tray_quit), onClick = { exitApplication() }) + } + ) +} +``` + +Note: Compose's `Tray` takes `icon: Painter` (not `iconContent`), `onAction` (not `primaryAction`), and the menu DSL uses `Separator()` (not `Divider()`). These are the right names for the built-in API. + +`showWindow()` sets `windowVisible.value = true` and calls `window?.toFront()` + `window?.requestFocus()`. Quitting from the tray menu just calls `exitApplication()` — `closedByError` is already `false` in the non-crash path, so the outer loop in `showApp()` terminates cleanly. + +**Unread indicator.** Icon swap based on `hasUnread`: reuse `ic_simplex` when zero, `ic_simplex_tray_dot` (same icon with a red dot overlay in the bottom-right) otherwise. Compose passes the `Painter` into AWT via `Painter.toAwtImage(density, layoutDirection, size)` — a single bitmap per state. One new image resource is enough: +- `MR.images.ic_simplex_tray_dot` — base icon with the red-dot overlay. + +**Icon size.** Compose `Tray` rasterises the `Painter` once at a per-platform target size: Linux 22×22, Windows 16×16, macOS 22×22 (with retina 2×). It's a single bitmap, so we source the painter at a comfortable size (e.g. via a `painterResource(MR.images.ic_simplex)` from the 40×40 SVG already shipped) and let the conversion handle the scale. We accept the slight scaling cost on 16×16 Windows panels rather than ship multiple size variants. + +**Tooltip.** Plain "SimpleX" when unread is zero; "SimpleX — N unread" otherwise. + +**Window restore is best-effort.** Compose Multiplatform issue [#4231](https://github.com/JetBrains/compose-multiplatform/issues/4231) documents that `toFront()` does not always pull the restored window above other windows on Linux/Windows — the OS may flash the taskbar entry instead. Acceptable for v1; if it bites users we can add the `isAlwaysOnTop = true; toFront(); isAlwaysOnTop = false` workaround in a follow-up. + +**No collision with the existing notification path.** `NtfManager.desktop.kt:178-188` contains an `java.awt.SystemTray` hack inside a private helper that turns out to be unreachable — the live notification path is `displayNotificationViaLib` (TwoSlices). The hack will not fire and cannot conflict with our tray icon. Cleaning up that dead code is out of scope here. + +**Toggling at runtime.** The `Tray { … }` composable is gated on `closeBehavior.state.value == MinimizeToTray`; Compose's recomposition lifecycle handles install/uninstall when the user flips the setting. No `LaunchedEffect` is needed. + +**Android isolation.** All tray code (the `Tray` composable, the close-behavior dialog, `showWindow`, the `trayIsAvailable` probe) lives in `desktopMain` only. The Android target compiles none of it — there are no expect/actual surfaces from `commonMain` calling into tray functionality. The only shared piece is the `CloseBehavior` enum + `closeBehavior` preference in `SimpleXAPI.kt`, which is plain data and never references tray APIs. + +### Appearance settings row + +In `Appearance.desktop.kt`, add one row to the existing settings section — **only when `trayIsAvailable`**: + +> ☑ **Minimize to tray when closing window** +> *Keep SimpleX running in the background to receive messages.* + +The toggle maps to the preference: + +- `MinimizeToTray` → on. +- `Quit` or `Ask` → off. + +Flipping on writes `MinimizeToTray`. Flipping off writes `Quit`. Touching the toggle resolves the `Ask` state to a definitive value — so a fresh-install user who opens Appearance settings, flips the row off, and then closes the window will *not* see the dialog (their preference is now `Quit`). This matches the user's apparent intent (they made a choice in settings) and avoids the surprise of a dialog appearing for a setting they thought they had already configured. + +When `trayIsAvailable` is `false` (stock GNOME without AppIndicator extension), the entire row is omitted from Appearance settings, the first-close dialog is skipped (`Ask` migrates silently to `Quit`), and the close handler treats `MinimizeToTray` as `Quit` (in case the user previously enabled it on a different machine). + +The wording "Minimize to tray" is used uniformly across all platforms, including macOS where the more native term would be "menu bar". A consistent in-app term is more important here than per-platform purity. + +### Files changed + +| File | Change | +|---|---| +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | Add `CloseBehavior` enum, `closeBehavior` preference, `SHARED_PREFS_DESKTOP_CLOSE_BEHAVIOR` constant. *(already in this branch as commit 1)* | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopApp.kt` | Replace inline `onCloseRequest`; add `windowVisible` to `SimplexWindowState`; wire `Window(visible = …)`; host the `Tray` composable conditionally on `trayIsAvailable && closeBehavior == MinimizeToTray`. | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/DesktopTray.kt` *(new)* | `trayIsAvailable` probe, `requestCloseBehavior` + `CloseBehaviorDialog`, `SimplexTray` composable, `showWindow` helper. | +| `apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/usersettings/Appearance.desktop.kt` | Add the toggle row (gated on `trayIsAvailable`). | +| `apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml` | Add 8 new strings (dialog title/body/buttons, settings row, tray menu). | +| `apps/multiplatform/common/src/commonMain/resources/MR/images/` | Add `ic_simplex_tray` + `ic_simplex_tray_dot`. | + +No `build.gradle.kts` change — Compose's `Tray` is already on the classpath via the existing `org.jetbrains.compose` plugin. + +### New strings + +```xml +Minimize to tray? +If you choose Close, messages won\'t be received.\nYou can change it later in Appearance settings. +Close the app +Minimize to tray +Minimize to tray when closing window +Keep SimpleX running in the background to receive messages. +Show SimpleX +Quit SimpleX +``` + +### Out of scope + +The following are deliberately not in this PR: + +- **Run on system startup / autostart entries.** Per-platform integration (Windows registry Run key, Linux `~/.config/autostart/*.desktop`, macOS LaunchAgents) is its own design. +- **Number-on-icon unread badges.** Cross-platform text rendering on tray icons is fragile across DPIs and macOS menu bar tinting. +- **Per-profile switcher / mute / mark-all-read** in the tray menu. Keep the menu to Show / Quit for now. +- **macOS template (auto-tinting) icon.** Compose `Tray` doesn't expose `NSImage.setTemplate:`; the tray icon will be a colored bitmap on macOS. Acceptable initial cost. +- **GNOME workaround documentation.** Users on stock GNOME won't see the option at all (probe returns false). We don't bundle or recommend the AppIndicator extension from the app itself; if we want to surface that guidance, it goes in the website/help docs, not in this PR. + +### Test plan + +Verified manually on at least one Linux (KDE Plasma), Windows 11, and macOS host: + +1. Fresh install. Click X on the window. Dialog appears with the exact text and button colors. Dialog cannot be dismissed by Esc or outside-click. +2. Click "Close the app". App exits. Reopen, click X — app exits with no dialog (preference is now `Quit`). +3. Reset preference (or fresh install). Click X, click "Minimize to tray". Window hides. Tray icon appears. +4. Send a message to yourself / receive one. Tray icon switches to the red-dot variant; tooltip updates with unread count. +5. Click tray icon (left-click). Window restores and gains focus. Unread is cleared on viewing the chat. +6. Right-click tray icon. Menu shows "Show SimpleX" and "Quit SimpleX". Both work. +7. Open Appearance settings, flip "Minimize to tray when closing window" off. Tray icon disappears. Click X — app exits with no dialog. +8. Flip the toggle back on. Tray icon appears immediately (the composable is gated on the preference, so installation/removal follows the toggle). diff --git a/plans/2026-05-09-fix-image-text-overlap.md b/plans/2026-05-09-fix-image-text-overlap.md new file mode 100644 index 0000000000..f759a454e0 --- /dev/null +++ b/plans/2026-05-09-fix-image-text-overlap.md @@ -0,0 +1,83 @@ +# Fix tall image preview overlapping caption text + +Branch: `nd/fix-image-text-overlap` · commit `0a3dcd249` · analogous to iOS PR [#6732](https://github.com/simplex-chat/simplex-chat/pull/6732). + +## 1. Problem statement + +For a tall image (height/width ≳ 2.33), the chat-bubble caption text was rendered on top of the bottom of the image instead of cleanly below it. The image looked like it had a semi-transparent text watermark across its lower section. Reproduced on Android and desktop with any sufficiently tall image; never reproduced on images with `height ≤ 2.33 × width`. + +iOS already had the analogous fix (PR #6732, commit `b24d003a8`); Android/desktop did not. + +## 2. Solution summary + +One line in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.kt`. The image preview Box's `Modifier.aspectRatio(width / height)` is wrapped with `.coerceAtLeast(1f / 2.33f)` so the aspect ratio cannot go below the floor at which the layout starts to break. + +```diff +- Modifier.width(w).aspectRatio(previewBitmap.width.toFloat() / previewBitmap.height.toFloat()) ++ Modifier.width(w).aspectRatio((previewBitmap.width.toFloat() / previewBitmap.height.toFloat()).coerceAtLeast(1f / 2.33f)) +``` + +Total diff: 1 file, +1 / −1. + +## 3. Root cause + +`PriorityLayout` (`FramedItemView.kt:480`, added in #6726) caps image-region height at `constraints.maxWidth × 2.33f` and passes that as `maxHeight` to its image child: + +```kotlin +val maxImageHeight = (constraints.maxWidth * 2.33f).toInt().coerceAtMost(constraints.maxHeight) +val imageConstraints = constraints.copy(maxHeight = maxImageHeight) +val imagePlaceable = … .measure(imageConstraints) +``` + +The image child is `CIImageView`'s outer Box, modified (line 185 pre-fix) by `Modifier.width(w).aspectRatio(previewBitmap.width / previewBitmap.height)`. Compose's `aspectRatio` modifier picks a layout size satisfying both the parent's constraints AND the requested ratio — by trying `tryMaxWidth`, `tryMaxHeight`, `tryMinWidth`, `tryMinHeight` in order and returning the first satisfying `IntSize`. + +When the natural ratio is below `1/2.33`, every candidate violates one bound: + +- `tryMaxWidth`: implied `height = W / ratio > W × 2.33 = parentMaxHeight` → fails the height cap. +- `tryMaxHeight`: implied `width = parentMaxHeight × ratio < W` → fails the fixed `.width(W)` lower bound. +- `tryMinWidth` / `tryMinHeight`: same failures. + +`findSize()` returns `IntSize.Zero`, and `aspectRatio` falls through to passing the parent's UNBOUNDED-height-shape constraints down (`wrappedConstraints = constraints` — see Compose Foundation's `AspectRatioNode`). The inner `Image` (`.width(W).contentScale=FillWidth`) then sizes itself by intrinsic aspect, with `height = W × imgH/imgW`, drawing past the layout box `clipToBounds` would have given it. The text below then renders on the same vertical strip as the painter's overflow — visible as overlap. + +`PriorityLayout`'s height cap (added in #6726) prevents the original crash but does not, on its own, prevent this visual overflow, because the cap propagates through `imageConstraints` to a modifier that silently drops it. + +## 4. The fix in detail + +`coerceAtLeast(1f / 2.33f)` raises the ratio's floor to exactly the value where Compose's `tryMaxWidth` succeeds: + +- At `ratio = 1/2.33`, implied `height = W / (1/2.33) = W × 2.33 ≤ parentMaxHeight` (since `W ≤ parentMaxWidth` and `parentMaxHeight = parentMaxWidth × 2.33`). +- `findSize` returns `(W, W × 2.33)`. `wrappedConstraints` becomes `Constraints.fixed(W, W × 2.33)` — both dimensions fixed. +- Inner `Image`'s `paint` modifier sees `hasFixedDimens=true` and returns the constraints unchanged. Image bounds = `(W, W × 2.33)`. +- `Image`'s built-in `clipToBounds` clips the painter's `FillWidth` overflow to the bounded layout. `PriorityLayout` reads `imagePlaceable.measuredHeight = W × 2.33` and places the caption text immediately below. + +For images at or above the floor (ratio ≥ 1/2.33), `coerceAtLeast` is a no-op — the ratio is unchanged, the layout is unchanged, no behavioural diff. The change only takes effect for images that triggered the bug. + +`coerceAtLeast` (not `maxOf`) matches the idiom already used at `FramedItemView.kt:480` (`.coerceAtMost(constraints.maxHeight)`) — both clamp at the bound where the layout starts to break. Reads as "ensure the ratio is at least 1/2.33". + +## 5. Why this specific shape + +- **Why preserve the existing expression untouched.** The pre-fix `previewBitmap.width.toFloat() / previewBitmap.height.toFloat()` is the aspect-ratio computation. Wrapping it `(...).coerceAtLeast(1f / 2.33f)` makes the diff purely additive — a reviewer reading the patch sees "the existing ratio is now floored", with zero risk that the inner expression silently changed. Diff is one line, character-minimal. + +- **Why no `MAX_IMAGE_HEIGHT_RATIO` constant.** Two use sites of `2.33f` after the fix (`PriorityLayout` line 480 and the new clamp). `good-code-v5.md`: *"Three similar lines are better than a premature abstraction."* If a third site appears (link preview, video preview, etc.) the constant earns its place. Until then, local duplication mirrors the convention already in `PriorityLayout`. + +- **Why no edit to `CIVideoView`.** The screenshot showed only the image-preview bug. iOS PR #6732 also touched `CIVideoView` and `CILinkView`, but on Android/desktop the video preview's outer Box has no `aspectRatio` modifier at all — it is sized by its inner `VideoPreviewImageView`'s `.width(width).FillWidth`, which is paint-clamped by `imageConstraints.maxHeight` and reports a correct layout size. If a tall-video overlap is actually reported in the wild, the fix is a separate commit; speculatively replicating the iOS scope would expand the diff and review surface beyond what the bug requires. + +- **Why no `fillMaxSize + ContentScale.Crop` rewrite of the inner `Image`.** A previous iteration of this fix did that to mirror iOS's `scaledToFill` change. It works, but is structurally redundant: once the outer Box's `aspectRatio` is bounded, the inner `Image`'s existing `paint` modifier (`hasFixedDimens=true → return constraints`) and `clipToBounds` already produce the same visual result. The rewrite was extra structure, not bug-fix; reverted. + +- **Why no `PriorityLayout` constant rename.** `2.33f` is already inline at line 480 and works as-is. Extracting it to `MAX_IMAGE_HEIGHT_RATIO` would be a rename bundled with a bugfix — `good-code-v5.md`: *"a rename in a diff signals a meaningful change to the reviewer — a gratuitous rename wastes reviewer attention and can mask real changes."* Out of scope. + +- **Why `coerceAtLeast(1f / 2.33f)` and not `coerceAtLeast(0.43f)`.** The form `1f / 2.33f` makes the relationship to `PriorityLayout`'s `2.33f` height multiplier visually explicit. `0.43f` would be opaque and would drift independently if either value changed. + +## 6. Verification + +Manual sanity (Android debug APK): + +- Send a tall screenshot (height ≫ width) with a caption → caption now sits below the cropped image preview, no overlap. +- Send an image where `height ≤ 2.33 × width` → preview unchanged from pre-fix (the clamp is a no-op for these). +- Tap the cropped preview → fullscreen viewer opens the full image at native aspect (the clamp only affects the inline preview, not the viewer). + +## 7. Risk and rollback + +- **Blast radius** is the single-Box modifier in `CIImageView` for non-`smallView` mode. `smallView` (used by `ChatPreviewView` thumbnails) takes the `else Modifier` branch and is untouched. +- The clamp is a no-op for images that did not trigger the bug, so regression risk on non-tall images is zero by construction. +- Rollback: `git revert 0a3dcd249` and force-push the branch (or just drop the commit before merge). diff --git a/plans/2026-05-11-channel-owner-unlimited-delete.md b/plans/2026-05-11-channel-owner-unlimited-delete.md new file mode 100644 index 0000000000..29461f3ebd --- /dev/null +++ b/plans/2026-05-11-channel-owner-unlimited-delete.md @@ -0,0 +1,59 @@ +# Channel editorial delete from history + +## Problem + +Channel owners cannot delete content older than 24 hours. The limit makes sense in p2p groups (no authority, each member holds independent copy). In channels, the owner is the authority - their content, their right to remove it. + +## Design + +Rather than bypassing the 24-hour time limit for broadcast deletes, we add a new delete mode: "delete from history." The relay removes the message from its store but does not forward the deletion to subscribers. Subscribers who already received the message keep it - the operation cleans the relay's history, not the subscriber's device. + +This is the right separation: within 24 hours, broadcast delete reaches subscribers and removes from relays. After 24 hours, history delete cleans relays only - no retroactive rewriting of subscriber devices. + +## Changes + +### Protocol.hs + +`XMsgDel` gains `onlyHistory :: Bool` field. Defaults to `False` (backward compatible - old clients don't send it, parser defaults missing field to `False`). Encoded only when `True` via `justTrue`. + +### CIContent.hs + +New `CIDMHistory` delete mode alongside `CIDMBroadcast`, `CIDMInternal`, `CIDMInternalMark`. + +### Commands.hs + +`APIDeleteChatItem` group path: +- `CIDMHistory` - validates `publicGroupEditor` (channel + role >= GRModerator), sends `XMsgDel` with `onlyHistory = True`, no time check. Rejected for non-channels and insufficient role. +- `CIDMInternal` - rejected for channel editorial roles (they should use `CIDMHistory` instead). +- `CIDMBroadcast` - unchanged (24-hour time check applies to everyone). + +### Subscriber.hs + +Relay `processEvent`: when `XMsgDel` has `onlyHistory = True`, processes the delete locally (marks item in relay's store) but returns `Nothing` for the delivery task - no forwarding to subscribers. + +Subscriber `processForwardedMsg`: ignores the `onlyHistory` flag (wildcard match) - if a message somehow arrives, process as normal delete. + +### Types.hs + +`publicGroupEditor :: GroupInfo -> GroupMember -> Bool` - shared predicate for channel editorial role check. + +### UI (iOS + Kotlin) + +Delete dialog for channel editorial roles (owner/admin/moderator): +- First button: "Delete from history" (iOS) / "From history" (Kotlin) - always available, sends `CIDMHistory` +- Second button: "For everyone" - only within 24-hour window, sends `CIDMBroadcast` +- No "Delete for me" option for editorial roles + +Batch selection follows the same logic - "From history" replaces "Delete for me" for editorial roles. + +API error handling: `apiDeleteChatItems` and `apiDeleteMemberChatItems` now show error alerts on failure (was silently swallowed on Android). + +## Backward compatibility + +- Old relays: don't recognize `onlyHistory`, process `XMsgDel` normally (forward to subscribers). Acceptable - the delete reaches subscribers, which is more than the owner intended but not harmful. +- Old subscribers: `onlyHistory` field is ignored (not in their parser, and the relay won't forward it anyway). +- Old owners: never send `onlyHistory = True`, behavior unchanged. + +## Test + +`testChannelMessageDeleteFromHistory` - owner sends message, deletes with `history` mode, verifies relay processes locally but subscribers don't receive deletion, verifies `internal` mode is rejected for channel editorial roles. diff --git a/plans/2026-05-11-fix-call-bind-port.md b/plans/2026-05-11-fix-call-bind-port.md new file mode 100644 index 0000000000..2c1ee016da --- /dev/null +++ b/plans/2026-05-11-fix-call-bind-port.md @@ -0,0 +1,168 @@ +# Desktop call server: pick a free port when `localhost:50395` is busy + +Branch: `nd/fix-call-bind-port` · code commit `587b79779` · PR [#6963](https://github.com/simplex-chat/simplex-chat/pull/6963). + +## 1. Problem statement + +On Desktop, a WebRTC call runs in the system browser, served by an embedded `NanoWSD` HTTP+WebSocket server. `startServer()` bound that server to a hard-coded port, `SERVER_PORT = 50395` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt:23`). If port 50395 was already in use — another instance of the app, a leftover server thread, or any unrelated process — `NanoHTTPD.start()` propagated the bind failure and the call could not start: + +``` +java.net.BindException: Address already in use: bind + at java.base/sun.nio.ch.Net.bind0(Native Method) + at java.base/sun.nio.ch.Net.bind(Unknown Source) + at java.base/sun.nio.ch.Net.bind(Unknown Source) + at java.base/sun.nio.ch.NioSocketImpl.bind(Unknown Source) + at java.base/java.net.ServerSocket.bind(Unknown Source) + at java.base/java.net.ServerSocket.bind(Unknown Source) + at org.nanohttpd.protocols.http.ServerRunnable.run(ServerRunnable.java:63) + at java.base/java.lang.Thread.run(Unknown Source) +``` + +A call should not be a single point of contention on one fixed TCP port. When 50395 is taken, the call should bind a different port and proceed. + +Scope: Desktop only. Android renders the call in an in-process `WebView` via `WebViewAssetLoader` — no local server, no port — and is unaffected. + +## 2. Solution summary + +Three changes, all in `CallView.desktop.kt`, plus a one-line spec note. Total diff: 2 files, +24 / −15. + +1. **`startServer` retries on a free port.** It gains a `port: Int = SERVER_PORT` parameter (used only by the retry; the single existing call site is unchanged by the default). `server.start()` is wrapped: on `BindException`, log a warning, stop the half-initialised server, and recurse once with `port = 0` — which makes the OS assign any free port. The recursion terminates because `port == 0` rethrows (the kernel does not hand out a busy ephemeral port). +2. **`WebRTCController` opens the browser at the port actually bound.** Previously it opened `http://localhost:50395/simplex/call/` *before* calling `startServer`; now it starts the server first and uses `server.listeningPort` for the URL — which equals `50395` in the normal case, and equals the OS-assigned port after a fallback. +3. **Spec note** in `apps/multiplatform/spec/services/calls.md` describing the fallback. + +```diff ++import java.net.BindException + + val server = remember { +- try { +- uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/") +- } catch (e: Exception) { +- ... endCall() ... +- } +- startServer(onResponse) ++ startServer(onResponse).apply { ++ try { ++ uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") ++ } catch (e: Exception) { ++ ... endCall() ... ++ } ++ } + } + +-fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD { +- val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) { /* unchanged */ } +- server.start(60_000_000) ++fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { ++ val server = object: NanoWSD(SERVER_HOST, port) { /* unchanged */ } ++ try { ++ server.start(60_000_000) ++ } catch (e: BindException) { ++ if (port == 0) throw e ++ Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") ++ server.stop() ++ return startServer(onResponse, port = 0) ++ } + return server + } +``` + +The `NanoWSD` object body (request handling, resource serving) is untouched. + +## 3. Root cause / how NanoHTTPD binds + +`startServer()` builds an anonymous `NanoWSD(SERVER_HOST, SERVER_PORT)` and calls `server.start(60_000_000)`. Inside `NanoHTTPD.start(timeout)`: + +```java +this.myServerSocket = this.getServerSocketFactory().create(); +this.myServerSocket.setReuseAddress(true); +ServerRunnable serverRunnable = createServerRunnable(timeout); +this.myThread = new Thread(serverRunnable); +this.myThread.start(); +while (!serverRunnable.hasBinded() && serverRunnable.getBindException() == null) { Thread.sleep(10L); } +if (serverRunnable.getBindException() != null) throw serverRunnable.getBindException(); +``` + +`ServerRunnable.run()` does the actual `myServerSocket.bind(new InetSocketAddress(hostname, myPort))` on its own thread; if that throws (port in use → `java.net.BindException`, a subclass of `IOException`), it stores the exception, returns, and the accept loop is never entered. `start()` observes the stored exception and rethrows it — which is why the stack trace in the report shows `ServerRunnable.run` rather than `NanoHTTPD.start`: it is the same exception object, captured at the failed `bind`. + +`setReuseAddress(true)` already handles the benign case (a just-closed server in `TIME_WAIT`), so the only way `start()` fails this way is a genuine conflict: something else is listening on `50395`. Pre-fix that exception escaped `startServer` → escaped the `remember {}` initialiser in `WebRTCController` → the call view could not establish its control channel. + +A fixed port is also unnecessary on the wire. The browser page only ever connects back to *the origin it was served from*: `apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/ui.js` opens `new WebSocket(`ws://${location.host}`)`, and `call.html` references its assets with root-relative paths (`/desktop/style.css`, `/call.js`, …). So the page follows whatever host:port the Kotlin side opened in the browser — there is no second place that hard-codes `50395`. + +## 4. The fix in detail + +### 4.1 Retry on `port = 0` + +`NanoHTTPD`/`NanoWSD` accept port `0`, the standard "let the OS pick a free ephemeral port" convention; after `start()`, `getListeningPort()` (Kotlin: `listeningPort`) returns the concrete port the kernel assigned. So the retry needs no port-scanning loop and no arbitrary range — one fallback attempt, guaranteed to find a free port if one exists at all. + +```kotlin +fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD { + val server = object: NanoWSD(SERVER_HOST, port) { /* unchanged */ } + try { + server.start(60_000_000) + } catch (e: BindException) { + if (port == 0) throw e + Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}") + server.stop() + return startServer(onResponse, port = 0) + } + return server +} +``` + +- `port: Int = SERVER_PORT` — the parameter exists for the recursive retry. `startServer` has exactly one caller in the tree (`WebRTCController`), and the default keeps that call site byte-identical. A default-valued parameter used for internal recursion is a routine Kotlin idiom (`fun f(x, acc = init)`). +- `catch (e: BindException)` — deliberately narrower than `IOException`. The reported failure mode is specifically "address already in use"; any *other* `start()` failure (e.g. an I/O error creating the socket) is not something a different port fixes, so it propagates exactly as before. Surgical: handle the bug, nothing else. +- `if (port == 0) throw e` — terminates the recursion. If even the OS-assigned port fails to bind, that is a pathological condition (no ephemeral ports at all); rethrow rather than loop, preserving the original "give up" behaviour on the second failure. +- `server.stop()` — `start()` assigns `myServerSocket` (an unbound `ServerSocket`) and `myThread` (which has already exited, having caught the bind error) *before* failing. `stop()` closes that orphaned socket and joins the dead thread. Pre-fix this leak was transient (the exception terminated the call attempt); now that the call *recovers* instead of failing, the half-initialised server must be released explicitly. `stop()` is the same call the existing `onDispose` already makes on the live server. + +### 4.2 Start the server before opening the browser + +The browser URL must carry the port the server actually bound, which is only known after `start()`. So the order in `WebRTCController`'s `remember {}` is inverted: `startServer` first, then `uriHandler.openUri`. + +```kotlin +val server = remember { + startServer(onResponse).apply { + try { + uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/") + } catch (e: Exception) { + Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}") + AlertManager.shared.showAlertMsg( + title = generalGetString(MR.strings.unable_to_open_browser_title), + text = generalGetString(MR.strings.unable_to_open_browser_desc) + ) + endCall() + } + } +} +``` + +- `.apply { … }` keeps the whole thing one memoized expression that yields the `NanoWSD` (as before), with no `val server = …; …; server` shadowing, and reads as "start the server, then (side effect) open the browser at its port". `listeningPort` resolves on the `apply` receiver. +- In the normal case `listeningPort == 50395`, so the opened URL is character-for-character what it was pre-fix — the browser keeps its per-origin permissions (camera/mic are granted to `localhost:50395`). Only a fallback changes the origin, and only for that call. +- Side benefit: pre-fix the browser was launched *before* the server's `start()` returned, so the page could (briefly) hit a not-yet-listening socket and rely on its own retry; now the server is provably listening before the browser is told about it. Strictly safer ordering. +- The error handling (alert + `endCall()`) is preserved verbatim; only its position moved. + +### 4.3 Spec note + +`apps/multiplatform/spec/services/calls.md` (the file the code links back to) gains one sentence on the NanoWSD bullet — "If that port is already in use it falls back to an OS-assigned free port (`port 0`); `WebRTCController` reads `server.listeningPort` for the browser URL" — and the WebRTCController bullet now reads "Starts the server, then opens `http://localhost:/simplex/call/` (normally `50395`)". + +## 5. Why this specific shape — alternatives considered + +- **Always bind `port = 0`, drop the fixed port.** Simplest possible code, no retry. Rejected: browser permissions (camera/mic, autoplay) are scoped per *origin* = `scheme://host:port`. A port that changes every call would re-prompt the user for camera/mic on every call. Keeping `50395` as the primary value preserves the granted permission; `0` is the *fallback*, used only on conflict. +- **Scan a fixed range (`50395, 50396, … 50404`).** More "predictable-ish" than an ephemeral port, but it can still be exhausted, needs a loop with an off-by-one boundary, and re-introduces the very problem (a finite set of fixed ports) in miniature. `port = 0` delegates the search to the kernel — one call, can't be exhausted while any port is free. Standard idiom; NanoHTTPD supports it directly. +- **Catch `IOException` instead of `BindException`.** Broader than the bug. A non-bind `start()` failure isn't fixed by retrying on another port; let it propagate. A narrow catch makes the diff describe exactly the failure it handles. +- **Extract the `NanoWSD` object into a local `fun newServer(port)` and `try { newServer(SERVER_PORT)… } catch { newServer(0)… }`.** Functionally equivalent, but it re-indents the ~25-line object body for no behavioural reason — a noisier diff. The default-parameter + tail-recursion form leaves the object body byte-identical and adds only the retry wrapper. +- **Move the browser-open into a `LaunchedEffect`.** Cleaner separation of "construct" vs "side effect", but it defers the launch past first composition (a behaviour change beyond the bug) and adds an effect to reason about. The pre-fix code already opened the browser inside `remember {}`; `.apply { }` keeps that timing while removing the only real wart (the open ran *before* the server existed). +- **Update every doc that mentions `localhost:50395`** (`product/flows/calling.md`, `product/glossary.md`, `product/rules.md`, `product/views/call.md`). Out of scope here: `50395` is still the primary port and those are higher-level narrative docs; only `spec/services/calls.md` (which the code references and which describes the exact mechanism) is updated. A follow-up can sweep the rest if desired. + +## 6. Verification + +- `./gradlew :common:compileKotlinDesktop` → `BUILD SUCCESSFUL` (only pre-existing deprecation warnings; nothing in the changed file). +- A full Linux x86_64 AppImage was built from this branch and launched (Compose software renderer in the test VM); the desktop app starts normally. +- Manual, normal path: starting a call opens the system browser at `http://localhost:50395/simplex/call/` exactly as before; the WebSocket connects and the call proceeds. +- Manual, fallback path: occupy the port first (e.g. `python3 -m http.server 50395`, or `nc -l 50395`) and then start a call → the log shows `Call server port 50395 is busy, using a random port: …`, the browser is opened at the OS-assigned port, the page's `ws://${location.host}` WebSocket connects to that same port, and the call proceeds. + +## 7. Risk and rollback + +- **Blast radius**: `startServer` and the `remember {}` initialiser in `WebRTCController`, Desktop only. Android (WebView, no server) is untouched; iOS is unrelated. +- The fallback branch executes only when `50395` is genuinely occupied — rare. The common path is unchanged except for the start-then-open ordering, which is strictly safer. +- Per-origin browser permissions are preserved on the common path (port unchanged); a fallback resets them for that one call — a clear improvement over the call failing outright. +- **Rollback**: `git revert 587b79779` (and drop the commit before merge if desired). No data, schema, or protocol surface is touched. diff --git a/plans/2026-05-11-link-tracking-whitelist.md b/plans/2026-05-11-link-tracking-whitelist.md new file mode 100644 index 0000000000..7bebe0ea03 --- /dev/null +++ b/plans/2026-05-11-link-tracking-whitelist.md @@ -0,0 +1,85 @@ +# "Remove link tracking" strips whitelisted query parameters (`?list=` in YouTube links, github `ref`) + +Design doc for the fix in PR #6965 (`nd/fix-list-in-link` → `master`). + +## Problem — what prompted this + +With **"remove link tracking"** enabled (Settings → Privacy & security), sending a message +with a YouTube link that has a `list` query parameter — `https://www.youtube.com/playlist?list=PL...` +or a video-in-playlist link `https://www.youtube.com/watch?v=...&list=PL...` — sent the URL +with `?list=...` removed, so the recipient got a plain (non-playlist) link instead of the +playlist. Fixing that `?list=` stripping is the immediate purpose of this change. + +iOS, Android and desktop are all affected — the URI sanitiser lives in the shared Haskell +core (`src/Simplex/Chat/Markdown.hs`). + +## Cause + +"Remove link tracking" on send uses *safe mode* of `sanitizeUri`: +`ComposeView.sanitizeMessage` → `parseSanitizeUri(_, safe = true)` → `chatParseUri 1` → +`sanitizeUri True`. + +`sanitizeUri` has three branches that pick which query parameters to keep; two of them +already consult `qsWhitelist` (the list of parameter names known *not* to be tracking — `q`, +`search`, `list`, `page`, youtube's `v`/`t`, github's `ref`, …): + +```haskell +let sanitizedQS + | safe = filter (not . isSafeBlacklisted . fst) originalQS -- ← whitelist NOT consulted + | isNamePath = case originalQS of + p@(n, _) : ps -> (if isWhitelisted n || not (isBlacklisted n) then (p :) else id) $ filter (isWhitelisted . fst) ps + [] -> [] + | otherwise = filter (isWhitelisted . fst) originalQS +... +isSafeBlacklisted p = any (`B.isPrefixOf` p) qsSafeBlacklist +qsSafeBlacklist = [ "ad", "af", ..., "li", ..., "ref", ... ] -- name *prefixes*; "li" → LinkedIn (li_fat_id, lipi, licu) +``` + +The safe-mode branch is the odd one out: it drops a parameter whenever its name *starts +with* a known tracking prefix, and never looks at `qsWhitelist`. So `list` was dropped +because `"li"` is a tracking prefix, and github's whitelisted `ref` was dropped because +`"ref"` is itself a tracking prefix — even though both are explicitly listed as non-tracking +and are kept by every other branch. + +## Fix + +Make the safe-mode branch apply the same "whitelisted *or* not blacklisted" rule the other +branches already use: + +```haskell +| safe = filter (\(n, _) -> isWhitelisted n || not (isSafeBlacklisted n)) originalQS +``` + +This *removes* a special case rather than adding one — `list` is no longer handled +differently from any other whitelisted parameter; `qsWhitelist` becomes authoritative in all +three branches. Effects relative to the previous behaviour: + +- `list` is kept everywhere (the reported `?list=` bug); +- github's `ref` is kept on `github.com` in safe mode too (it was already kept in eager + mode — it's in the whitelist for exactly that reason); +- nothing else changes: of all whitelist entries, only `list` (vs the `"li"` prefix) and + `ref` (vs the `"ref"` prefix) collide with a `qsSafeBlacklist` prefix today; +- every actual tracking parameter is still stripped — `qsWhitelist` does not contain + `li_fat_id`, `lipi`, `licu`, `utm*`, etc., and `ref` on any non-github host stays stripped. + +Regression tests added in `testSanitizeUri` (`tests/MarkdownTests.hs`): + +```haskell +it "should keep whitelisted parameters in safe mode even if they match a blacklist prefix" $ do + "https://example.com/playlist?list=abc" `sanitized` Nothing -- "list" is whitelisted, "li" is blacklisted + "https://example.com/playlist?list=abc&si=def" `sanitized` Just "https://example.com/playlist?list=abc" + "https://github.com/owner/repo?ref=main" `sanitized` Nothing -- "ref" is whitelisted for github.com +``` + +Verified: full library + test rebuild, then `cabal run simplex-chat-test -- --match /sanitizeUri/` +→ 4 examples, 0 failures (the new block plus the three pre-existing `sanitizeUri` cases). + +## Alternatives considered + +- **Special-case `list`** (`isSafeBlacklisted p = p /= "list" && …`). Smallest possible diff, + provably zero collateral, but it hard-codes one parameter name into a predicate and leaves + the structural inconsistency (safe mode ignoring the whitelist) in place — a fix by + exception rather than by rule. (This was the first version; replaced.) +- **Narrow the `"li"` blacklist entry to `"li_"`.** Fixes `list` but stops matching `lipi` + and `licu` (real LinkedIn email-link params), i.e. changes more than `list` while still + not addressing `ref` or the underlying inconsistency. diff --git a/plans/2026-05-12-link-trailing-underscore-exclamation.md b/plans/2026-05-12-link-trailing-underscore-exclamation.md new file mode 100644 index 0000000000..c92da7e8df --- /dev/null +++ b/plans/2026-05-12-link-trailing-underscore-exclamation.md @@ -0,0 +1,151 @@ +# Links: trailing `_` and `!` dropped from the highlighted link + +Design doc for the fix shipped in PR #6973. + +## Problem + +A bare URL or domain ending in `_` (or `!`) was highlighted as a link only up +to the last non-`_` character — the trailing `_` rendered as plain, +non-clickable text. For example `https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` +showed `…The_Lord_of_the_Rings` as a blue link followed by a separate, plain +`_`. Reported for `_`; the same defect applies to `!`. + +## Background — how bare links are parsed + +`parseMarkdown` (`src/Simplex/Chat/Markdown.hs`) splits a message into +fragments; a fragment that isn't a recognized markdown construct falls through +`wordP` → `wordMD`, which decides whether the "word" (the run up to the next +space) is a URI / SimpleX link / domain / email. + +To handle the very common case of a link immediately followed by sentence +punctuation — `check out https://simplex.chat.` or `(https://simplex.chat)` — +`wordMD` peels a trailing run of "punctuation" off the word and re-emits it as +`unmarked` text: + +```haskell +where + punct = T.takeWhileEnd isPunctuation' s + s' = T.dropWhileEnd isPunctuation' s + res md' = if T.null punct then md' else md' :|: unmarked punct +``` + +`isPunctuation'` is `Data.Char.isPunctuation` with exemptions for characters +that legitimately *end* a URL: `/` (trailing path separator, e.g. +`https://github.com/simplex-chat/`) and `)` (Wikipedia disambiguation, e.g. +`…/wiki/Servo_(software)`). + +All link-highlighting surfaces derive from the result of this parser: the +desktop/Android UI (`TextItemView.kt`) and iOS UI (`MsgContentView.swift`) +both call `chatParseMarkdown` (FFI into the bundled Haskell core) and style a +`Uri` / `HyperLink` fragment by its whole `text`; the compose-preview path uses +the same function; `Styled.hs` does the terminal rendering. So whatever the +parser puts inside the `Uri` fragment is exactly what gets highlighted, on +every platform. + +## Root cause + +`Data.Char.isPunctuation '_' == True` — `_` is Unicode `ConnectorPunctuation` +(`Pc`). `isPunctuation '!' == True` — `!` is `OtherPunctuation` (`Po`). Neither +was in the `isPunctuation'` exemption list, so a trailing `_` or `!` was always +stripped from the URI text. + +For `https://simplex.chat/page_name_`: + +- `punct = "_"`, `s' = "https://simplex.chat/page_name"` +- output: `Uri "https://simplex.chat/page_name" :|: unmarked "_"` + +The UI highlights the `Uri` fragment by its text, so the `_` lands outside the +blue/clickable span — exactly the reported behaviour. + +## Fix + +Add `_` and `!` to the `isPunctuation'` exemptions, alongside `/` and `)`: + +```haskell +isPunctuation' = \case + '/' -> False + ')' -> False + '_' -> False + '!' -> False + c -> isPunctuation c +``` + +`T.takeWhileEnd isPunctuation'` now stops at a trailing `_`/`!`, so the full +token is kept in `s'` and emitted as a single `Uri` fragment. Anything still +trailing it (`.`, `,`, ` …`) is peeled off as before: + +- `https://simplex.chat/page_name_` → `Uri "https://simplex.chat/page_name_"` +- `https://simplex.chat/page_name_, hello` → `Uri "…/page_name_" :|: unmarked ", hello"` +- `https://simplex.chat/page!` → `Uri "https://simplex.chat/page!"` + +## Why this is the right place + +- `wordMD`/`isPunctuation'` is the single point where bare-link text is + trimmed, and it already encodes "these characters legitimately end a link." + `_` and `!` belong in that list next to `/` and `)`. +- `_` and `!` are RFC 3986–valid URL characters (`_` is in `unreserved`, `!` is + a `sub-delim`); `_` is never sentence-ending punctuation. +- Fixing it in the parser fixes every surface at once (desktop, Android, iOS, + terminal, compose preview), because they all consume the same `FormattedText`. + A UI-layer patch would have to be repeated per platform and would leave + `Styled.hs` wrong. + +## Why a wider change is not in scope + +- The reported bug is fully resolved by the two-line addition to the exemption + `case`. Nothing more is required. +- `isPunctuation'` is shared by the URI, domain and email branches of `wordMD`. + Exempting `_`/`!` for all three is the intended behaviour, with one minor + knock-on: `user@example.com!` now renders as plain text rather than + `Email "user@example.com" :|: unmarked "!"`, because `user@example.com!` + isn't a valid email so the whole token isn't recognized. This is acceptable — + `!` is now treated consistently as part of the token everywhere — and is + preferable to splitting `isPunctuation'` into a URI predicate and an email + predicate, which adds structure for a marginal case. Phones are unaffected + (`phoneP` is a separate parser that doesn't use `isPunctuation'`). +- `good-code-v5.md` — *"Find the minimal change … the smallest structural + modification that achieves the goal."* The smallest modification that + resolves the report is two lines in the exemption `case`. + +## Backward compatibility + +Pure parsing change, no wire-format impact. `FormattedText` keeps the same +shape; only which characters fall inside a `Uri`/`Email` fragment changes. +Messages already stored keep their previously-parsed formatting — re-parsing +happens on compose / receive, not on display of stored items. An old client +receiving a message authored by a fixed client parses the raw text itself and +behaves per its own (older) rule — no incompatibility either way. + +## Verification + +`tests/MarkdownTests.hs`, `describe "text with Uri"` — four cases added: + +- `"https://simplex.chat/page_name_" <==> uri "https://simplex.chat/page_name_"` + — the trailing `_` is part of the link. +- `"https://simplex.chat/page_name_, hello" <==> uri "https://simplex.chat/page_name_" <> ", hello"` + — `_` kept, the `, hello` after it still peeled off. +- `"https://simplex.chat/page!" <==> uri "https://simplex.chat/page!"` +- `"https://simplex.chat/page!, hello" <==> uri "https://simplex.chat/page!" <> ", hello"` + +`MarkdownTests` suite: 38 examples, 0 failures. The existing exemption / peel +coverage is unchanged — `…/simplex-chat/`, `…/wiki/Servo_(software)`, +`https://simplex.chat.` → link + `.`, `https://simplex.chat, hello` → link + +`, hello`, etc. + +Manual sanity (desktop, Linux AppImage build): a message containing +`https://en.wikipedia.org/wiki/The_Lord_of_the_Rings_` highlights the whole URL +including the trailing `_`. + +## Alternatives considered and rejected + +- **Split `isPunctuation'` into a URI predicate and an email predicate** so `!` + is kept only inside URLs. Adds a second predicate and a branch solely to + preserve `Email "x@y.z" :|: unmarked "!"` on `x@y.z!` — a marginal case. The + shared predicate is simpler; rejected. +- **Strip `_`/`!` only when followed by more URL-looking text.** Requires + look-ahead the trailing-trim model doesn't have, for no real benefit — `_` + and `!` aren't sentence punctuation in the first place. +- **Extend the link span over a trailing `_` in the UI layer.** Wrong layer: + the parser is the single source of truth for `FormattedText`, consumed by + three platforms plus the terminal renderer; a UI-only patch would diverge per + platform. diff --git a/plans/2026-05-13-desktop-single-instance.md b/plans/2026-05-13-desktop-single-instance.md new file mode 100644 index 0000000000..87c5f7c9bb --- /dev/null +++ b/plans/2026-05-13-desktop-single-instance.md @@ -0,0 +1,30 @@ +# Desktop single instance - restore on duplicate launch + +## Problem + +After tray support (#6970), the desktop app can minimize to tray. The process stays alive holding the database. When the user clicks the app launcher again (forgetting about the tray), a second process starts and either crashes on the SQLite lock or runs in a degraded state. + +## Design + +Two files in `dataDir`: `simplex.started` (lock file) and `simplex.show` (signal file). + +### Startup + +1. Try `FileChannel.tryLock(0, 1, false)` on `simplex.started`. +2. **Lock acquired**: delete stale `simplex.show` if present (leftover from crash), start a daemon `WatchService` on `dataDir` for `ENTRY_CREATE`, start the app normally. +3. **Lock taken** (another process holds it): create `simplex.show`, exit. The running instance detects it and shows its window. +4. **Lock fails** (IOException, filesystem doesn't support locks, etc.): start normally but disable minimize-to-tray. Close quits the app. No worse than before tray support existed. + +### Signal handling + +While the lock is held, the daemon watcher runs for the JVM lifetime. When `simplex.show` appears it deletes the file and posts `showWindow()` to the EDT. `showWindow()` sets `windowVisible = true`, clears `ICONIFIED`, and brings the window to front — restores from tray, from taskbar-minimize, or just raises if visible-but-behind. + +Minimize-to-tray is only available when `singleInstanceLock` is held. If the lock couldn't be acquired (case 4), close always quits - preventing the scenario where two tray'd instances fight over the database. + +### Crash recovery + +The OS releases the file lock when the process dies. `simplex.show` may be left behind but is harmless - the next startup (step 2) deletes it. + +## Scope + +Linux, Windows, macOS. Per-data-directory - separate installs with different `dataDir` run independently. diff --git a/plans/2026-05-13-fix-group-link-share.md b/plans/2026-05-13-fix-group-link-share.md new file mode 100644 index 0000000000..65c9999156 --- /dev/null +++ b/plans/2026-05-13-fix-group-link-share.md @@ -0,0 +1,122 @@ +# Share Channel Link — Filter Saved Messages, Gate Plain Groups (Multiplatform) + +PR: [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) · branch `nd/fix-group-link-share` · final commit `312072a5e` + +## 1. The bug + +Two failures on the Android/Desktop "Share via chat" flow for a channel link, both ending in a server error string the user sees in a red toast: + +1. **Picking Saved Messages as the destination.** Server returns `chat commandError Failed reading: empty`. The user cannot share a channel link to their own note folder. +2. **Source group is not a channel.** The "Share via chat" button on the link-management screen (`GroupLinkView.kt`) renders for plain groups too. Tapping it produces `chat commandError not a public group`. + +Both are reachable from the multiplatform client only. iOS does not hit either: it uses a different picker filter and gates the button by `publicGroup != nil` (which already implies `useRelays`). + +## 2. Root cause + +### Bug #1 — Saved Messages is not a valid destination for this command + +`APIShareChatMsgContent` is parsed with `sendRefP` (`src/Simplex/Chat/Library/Commands.hs:5426`): + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The client emits `*` for `ChatType.Local` (Saved Messages) via the standard `chatType.rawValue` prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour. Sharing a channel link to one's own note folder is not a meaningful operation — the user can save the channel link by other means (copy from the channel-link screen). The client offered the destination by accident: the picker (`ShareListView.kt:199`) included `ChatInfo.Local` for every `SharedContent` flavour, including `SharedContent.ChatLink`. + +### Bug #2 — share button rendered for plain groups + +`GroupLinkView.kt:261` renders the share-via-chat button whenever `shareGroupInfo` is non-null: + +```kotlin +if (shareGroupInfo != null) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +Two callers pass `shareGroupInfo` — `GroupChatInfoView.kt:170` and `ChatView.kt:3207` — both pass it unconditionally as `shareGroupInfo = groupInfo`. So a plain group (`useRelays == false`) ends up with a button whose action calls `APIShareChatMsgContent` against a source the server refuses with `not a public group`. + +The sibling button in `GroupChatInfoView.kt:602` is already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }`. `GroupLinkView` was missing the equivalent gate. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | Widen the server: add `SRLocal NoteFolderId` to `SendRef` with a parser branch; replace the `Nothing → throwCmdError "not a public group"` arm with a build-from-short-link path producing an unsigned `MCChat`. | The first commit on this branch (`7d4648b9f`). Widens the protocol surface (a new destination grammar) and the message domain (an unsigned card variant whose recipient story is unspecified), to make reachable a feature the user can achieve another way. Rejected as poor design. | +| B | **Final** — client-side, multiplatform only: filter `ChatInfo.Local` out of the picker for `SharedContent.ChatLink`; add `&& isChannel` to the button gate in `GroupLinkView`. | Two single-line changes. The failing paths become unreachable from the UI. Forward to Saved Messages, all other share flavours, iOS, and Haskell are untouched. | + +Approach B is the smaller fix and aligns the UI with the server's grammar — the picker no longer offers a destination the server refuses, and the button no longer appears where there is no channel link to share. + +## 4. Final implementation + +### 4.1 `ShareListView.kt:199` — exclude Local from the channel-link picker + +```kotlin +val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready && it.chatInfo.sendMsgEnabled && !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local) }.sortedByDescending { it.chatInfo is ChatInfo.Local } +``` + +One clause appended to the existing predicate: `&& !(chatModel.sharedContent.value is SharedContent.ChatLink && it.chatInfo is ChatInfo.Local)`. Reads as "exclude (sharing-link AND local)". Kotlin's `is` binds tighter than `&&`, so the inner parens are only around the AND for `!` to negate. + +`chatModel.sharedContent.value` is read inside the filter lambda, once per chat. Inside `derivedStateOf`, each read registers a Compose dependency — same dependency set as a hoisted `val` would produce, and small enough (`chats.value.size`) that there is no observable cost. The hunk is +1/-1. + +The trailing `sortedByDescending { it.chatInfo is ChatInfo.Local }` is left untouched. It is a no-op when no Locals are present, and removing it would touch a line that does not need to change. + +Other `SharedContent` flavours (`Text`, `Media`, `File`, `Forward`) keep their previous behaviour. Forwarding to Saved Messages still works — the new clause is false when `sharedContent` is not `ChatLink`. + +### 4.2 `GroupLinkView.kt:261` — gate the share button by `isChannel` + +```kotlin +if (shareGroupInfo != null && isChannel) { + SettingsActionItem(painterResource(MR.images.ic_forward), stringResource(MR.strings.share_via_chat), …) +} +``` + +`isChannel` is the existing parameter of this view (declared at line 35 and 175, used for channel-specific rows throughout the file). Both callers already pass `isChannel = groupInfo.useRelays`, so the new clause is equivalent to "render only when `useRelays == true`" — matching the rule for the sibling button in `GroupChatInfoView.kt:602`. The hunk is +1/-1. + +### 4.3 What is *not* changed + +- **Haskell.** `src/Simplex/Chat/Controller.hs` and `src/Simplex/Chat/Library/Commands.hs` stay at master. `SendRef` has no `SRLocal` constructor; `APIShareChatMsgContent` still refuses non-public sources with `not a public group`; `sendRefP` has no `*` branch. The client just never sends those commands now. +- **iOS.** No file under `apps/ios/` is touched. `filterChatsToForwardTo` (`apps/ios/SimpleXChat/ChatUtils.swift:56`) still inserts `.local` at index 0 — iOS's share-channel picker uses the same function as forward, and changing it would touch the forward picker. `GroupLinkView.swift:110` already gates by `groupInfo?.groupProfile.publicGroup != nil`, which already implies `useRelays` on iOS (only channels carry a `publicGroup` profile in practice). Neither failure has been reported on iOS through this flow. +- **`GroupChatInfoView.kt`** `ShareViaChatButton`. Already wrapped in `if (groupInfo.useRelays) { … if (channelLink != null) … }` (lines 602–614). Nothing to change. +- **`ChatItemForwardingView`** equivalent on iOS, `ComposeView.kt` consumer of `SharedContent.ChatLink`, the `apiShareChatMsgContent` API surface, and every other share/forward path. The new filter clause is false outside `SharedContent.ChatLink`, so all other consumers see the same picker. + +## 5. Why this works + +The server is the source of truth for which destinations and which sources are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope) — defined by `sendRefP`. Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with a `publicGroup` profile and `groupLink` — defined by the `Just PublicGroupProfile {…}` arm of `APIShareChatMsgContent`. + +The client's job is to offer choices the server will accept. Bug #1 was an offer mismatch (Local in the destination list); bug #2 was an offer mismatch (button rendered on a source with no `publicGroup`). The fix narrows the client's offers to match the server's grammar — without changing the server, and without adding state that has to be kept in sync. + +Two booleans, two single-line changes. The picker filter clause is false for every `SharedContent` flavour that is not `ChatLink`, so no other share path is affected. The button gate reuses `isChannel`, the existing parameter that the rest of the file already uses for channel-vs-group dispatch. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the share-channel-link picker is no longer possible.** This is the bug fix. The destination simply isn't listed. +2. **"Share via chat" in `GroupLinkView` is hidden on plain groups.** Previously rendered but unusable; now correctly hidden. +3. **Forward picker, Media picker, File picker, Text picker — unchanged.** New filter clause is false for every non-`ChatLink` `SharedContent`. +4. **`ShareViaChatButton` in `GroupChatInfoView` — unchanged.** Already gated correctly. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Linux desktop build** succeeded end-to-end against the current branch tip (`312072a5e`), producing `SimpleX_Chat-x86_64-fix-group-link-share.AppImage` via `bash /home/user/build/linux.sh`. +2. **Diff is exactly two single-line hunks** in two files: + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupLinkView.kt | 2 +-` + - `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt | 2 +-` +3. **Manual on desktop:** + - Open a public channel that is not yours → profile → "Share via chat" → picker shows direct + group destinations only, no "Saved Messages" row → pick a contact → channel-link card appears in compose. + - Open a plain group → group-link management screen → no "Share via chat" button. + - Open a channel's group-link management screen → "Share via chat" button still appears. + - Forward an existing message → picker still shows Saved Messages at the top (regression check). + +## 8. Trade-offs and follow-ups + +1. **iOS retains the bug-#1 path latent.** `filterChatsToForwardTo` inserts `.local` for the channel-link picker on iOS. The same fix as the Kotlin one — pass `includeLocal: false` from `shareChannelPicker` — is a separate, scoped change for an iOS PR. Out of scope here. +2. **`chatModel.sharedContent.value` read inside the filter lambda** evaluates per chat in the predicate, rather than hoisted to a `val` once. Diff minimality wins: the original line was one line, the new line is one line. If profiling ever showed this on a hot path (it does not — `derivedStateOf` and chat-list sizes), hoisting is a trivial follow-up. +3. **The `sortedByDescending { it.chatInfo is ChatInfo.Local }` call** remains in the channel-link path even though there are no Locals to sort. Removing it for that path only would require splitting the chain. Diff minimality: leave it. diff --git a/plans/2026-05-13-fix-privacy-links-import.md b/plans/2026-05-13-fix-privacy-links-import.md new file mode 100644 index 0000000000..2596704c24 --- /dev/null +++ b/plans/2026-05-13-fix-privacy-links-import.md @@ -0,0 +1,148 @@ +# "Remove link tracking" setting does not persist across database import + +PR: [#6977](https://github.com/simplex-chat/simplex-chat/pull/6977) · branch `nd/fix-privacy-links-import` → `master` + +## 1. Problem statement + +The **Settings → Privacy & security → Remove link tracking** toggle (`privacySanitizeLinks`) is silently dropped when a user moves their chat database to another device or reinstalls. Reproduction: + +1. Device A: enable "Remove link tracking", export chat database. +2. Device B (fresh install) or same device after re-install: import the database. +3. Open Settings → Privacy & security on B: the toggle is **off**. + +All three platforms are affected (Android, desktop, iOS) and any combination of source/target. Every other v6.5 "Safe web links" privacy guarantee survives the import; only "Remove link tracking" reverts. + +## 2. Solution summary + +The preference is stored locally only (Android `SharedPreferences`, iOS `UserDefaults` group). The cross-device transport for app settings is the `AppSettings` JSON record that travels with the database via `apiGetAppSettings` / `apiSaveAppSettings`. `privacySanitizeLinks` was absent from this record in all three layers (Haskell core, Kotlin multiplatform, Swift iOS), so it had nothing to ride on. + +Fix: add `privacySanitizeLinks :: Maybe Bool` to the `AppSettings` record in each of the three layers, wired identically to the reference field `privacyAskToApproveRelays`. Default in all three layers is `false`, matching today's local default. The fix is strictly additive (`+18` lines, 5 files, no deletions); no schema change, no command/API change, no UI change. + +## 3. Detailed tech design + +### 3.1 The round-trip the fix plugs into + +``` +Device A Device B +───────── ───────── +local pref store local pref store + ↑ ↑ importIntoApp() + │ user toggles UI │ + │ AppSettings ← apiGetAppSettings(local prepareForExport) + │ ↑ + │ archive (.zip with │ +local pref → AppSettings.current chat.db) ─────┐ + → prepareForExport │ │ + → apiSaveAppSettings │ │ + → app_settings DB row ─────────┘ │ + │ + core: combineAppSettings + stored <|> platformDefaults <|> defaults +``` + +`AppSettings.current` reads every local pref; `prepareForExport` strips fields equal to their default (space optimisation); `apiSaveAppSettings` writes the JSON into the `app_settings` table of the chat DB, which travels inside the archive. On import, the receiving client runs `apiGetAppSettings(local.prepareForExport())`; the core merges stored ⟶ local-platform ⟶ hardcoded-defaults with `Alternative` (`<|>`) and returns the result; the client's `importIntoApp` applies any non-null fields to its local store. + +A field that is **absent from `AppSettings`** at any of the three layers never enters this pipeline and is therefore lost on import. `privacySanitizeLinks` was such a field. + +### 3.2 Three-layer parity + +The three `AppSettings` definitions must agree on every field name, default value, and the four operations: + +| Operation | Haskell | Kotlin | Swift | +|---|---|---|---| +| field declaration | `data AppSettings` (`src/Simplex/Chat/AppSettings.hs:28`) | `data class AppSettings` (`SimpleXAPI.kt:8038`) | `struct AppSettings` (`AppAPITypes.swift:2118`) | +| default | `defaultAppSettings` (`AppSettings.hs:79`) | `defaults` (`SimpleXAPI.kt:8157`) | `defaults` (`AppAPITypes.swift:2188`) | +| "missing key" parse default | `defaultParseAppSettings` (`AppSettings.hs:116`) | implicit `null` | implicit `nil` | +| merge fallback | `combineAppSettings` (`AppSettings.hs:153`) | n/a (only one source) | n/a | +| JSON parser | hand-written `parseJSON` (`AppSettings.hs:207`) | `@Serializable` derived | `Codable` derived | +| read-from-local | n/a (clients send it) | `AppSettings.current` (`SimpleXAPI.kt:8193`) | `AppSettings.current` (`AppSettings.swift:71`) | +| write-to-local | n/a (clients apply it) | `importIntoApp` (`SimpleXAPI.kt:8110`) | `updateIosGroupDefaults` / `init from cfg` (`AppSettings.swift:13`) | +| serialize-only-non-default | n/a | `prepareForExport` (`SimpleXAPI.kt:8072`) | `prepareForExport` (`AppAPITypes.swift:2151`) | + +The fix adds one line to every cell that exists for `privacyAskToApproveRelays`. Default value is `false` (matches `mkBoolPreference(..., false)` and the `registerGroupDefaults` entry). + +### 3.3 Round-trip correctness — case analysis + +The core's `combineAppSettings = stored <|> platformDefaults <|> defaultAppSettings` (with `Alternative` on `Maybe`) means: take the stored value if present, else what the client said its default is, else the hardcoded default. The client's `prepareForExport` only includes a field when it differs from the client's `defaults`. With both `defaults` set to `false`: + +| Case | Archive carries | Local pref before | platformDefaults sent | Merged | Result | +|---|---|---|---|---|---| +| New archive, source had on | `Just true` | false | `Nothing` (default) | `Just true` | **on** ✓ | +| New archive, source had off (default) | `Nothing` (stripped) | false | `Nothing` | `Just false` (from defaults) | **off** ✓ | +| New archive, source had off | `Nothing` | true (local toggled) | `Just true` | `Just true` | **on** (local wins, archive silent) ✓ | +| Old archive (pre-fix) | field unknown | false | `Nothing` | `Just false` | **off** (unchanged from before fix) | +| Old archive | field unknown | true | `Just true` | `Just true` | **on** (local preserved) ✓ | +| Cross-platform | `Just true` | false | `Nothing` | `Just true` | **on** ✓ | + +The only "interesting" semantic — *archive silent on the field while local has it on* — preserves local. This matches how every other field in `AppSettings` behaves and matches user intent ("I toggled it on this device, then imported some old archive — keep it on"). + +### 3.4 Edge cases verified + +- **Downgrade then upgrade.** New code → toggle on → export. Imported on *old* code: `parseJSON` ignores unknown keys, DB row is rewritten without the field. Re-upgrade: field absent, falls through to `Just false`. This is the standard "old client drops new fields" semantics for every prior AppSettings addition; not introduced by this PR. + +- **iOS `BoolDefault` before `set` is ever called.** `apps/ios/SimpleXChat/AppGroup.swift:100` already registers `GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS: false` in `registerGroupDefaults`. So `privacySanitizeLinksGroupDefault.get()` returns `false` on first read — no NaN/nil sentinel risk. + +- **JSON field ordering.** `deriveToJSON defaultJSON` uses record-field order; new field is inserted between `privacyLinkPreviews` and `privacyShowChatPreviews`, shifting subsequent keys. No external consumer compares the JSON byte-for-byte; the existing `testAppSettings` test compares `J.encode defaultAppSettings` on both sides of the wire and so is self-consistent under the addition. + +- **`omitNothingFields = True`.** The Haskell `defaultJSON` config (`Simplex.Messaging.Parsers`) strips `Nothing` fields from JSON output, so `defaultParseAppSettings` (every field `Nothing`) does not pollute archives or wire payloads when used as a fallback. + +- **iOS NSE / SE extensions.** Neither references `privacySanitizeLinks`. No additional wiring required. + +### 3.5 What was deliberately not done + +- **Flipping the *user-facing* default to `true`.** Other privacy fields in `defaultAppSettings` are `Just True` (encrypt local files, ask to approve relays). "Remove link tracking" remains `Just False` because the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) is `false`. Aligning the `AppSettings` default with the local default keeps the `prepareForExport` "differs-from-default" comparison consistent — otherwise off-by-default users would suddenly serialise `false` everywhere and on-by-default users would serialise nothing, inverting the wire shape. Whether the *product* default should be flipped to on is a separate question for a separate change. + +- **Adding `apiSaveAppSettings` on toggle.** Toggling the pref in `PrivacySettings.kt` writes only to shared prefs; the DB's `app_settings` row stays stale until a separate trigger (theme change, export, migration) syncs. The export and migration paths already call `apiSaveAppSettings(AppSettings.current.prepareForExport())` immediately before producing the archive, so every UI-initiated export captures the current value. Plugging the sync into every toggle is a broader change affecting every AppSettings field equally — out of scope. + +- **Fixing `privacyChatListOpenLinks`.** The Kotlin `AppSettings` declares it (`SimpleXAPI.kt:8046`); the Haskell record and the Swift struct do not. Same failure mode as the bug being fixed here — almost certainly does not persist across Android-to-Android imports. Out of scope; should be tracked separately. + +- **Adding a targeted test.** The existing `testAppSettings` exercises a JSON round-trip with `defaultAppSettings`, so the new field rides through implicitly. A field-specific test (`defaultAppSettings { privacySanitizeLinks = Just True }`) would tighten coverage against a future client dropping the field; recommended as a small follow-up. + +## 4. Detailed implementation plan + +### 4.1 Files touched + +| File | Δ | Purpose | +|---|---|---| +| `src/Simplex/Chat/AppSettings.hs` | +6 / 0 | record field, `defaultAppSettings`, `defaultParseAppSettings`, `combineAppSettings`, JSON parser line, record reassembly | +| `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt` | +5 / 0 | data class field, `prepareForExport`, `importIntoApp`, `defaults`, `current` | +| `apps/ios/Shared/Model/AppAPITypes.swift` | +3 / 0 | struct field, `prepareForExport`, `defaults` | +| `apps/ios/SimpleXChat/AppGroup.swift` | +2 / 0 | new `privacySanitizeLinksGroupDefault: BoolDefault` next to existing privacy defaults | +| `apps/ios/Shared/Views/UserSettings/AppSettings.swift` | +2 / 0 | import side (`set`), export side (`get`) | + +Total: 5 files, +18 / 0. No deletions. + +### 4.2 Step-by-step (commit `15457a903`) + +1. **`AppSettings.hs`** — add `privacySanitizeLinks :: Maybe Bool` to the record (between `privacyLinkPreviews` and `privacyShowChatPreviews`); set `Just False` in `defaultAppSettings`; `Nothing` in `defaultParseAppSettings`; `p privacySanitizeLinks` in `combineAppSettings`; `privacySanitizeLinks <- p "privacySanitizeLinks"` in `parseJSON`; add to record reassembly. Field position consistent with name groupings. + +2. **`SimpleXAPI.kt`** — same insertions in `data class AppSettings`, `prepareForExport`, `importIntoApp`, `defaults`, `current`. Local pref already exists (`SimpleXAPI.kt:126`). + +3. **`AppAPITypes.swift`** — same insertions in `struct AppSettings`, `prepareForExport`, `defaults`. + +4. **`AppGroup.swift`** — add `public let privacySanitizeLinksGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_SANITIZE_LINKS)`. The key constant (line 31) and registered default `false` (line 100) already exist; only the typed wrapper for non-`@AppStorage` access was missing. + +5. **`AppSettings.swift` (iOS view extension)** — import side: `if let val = privacySanitizeLinks { privacySanitizeLinksGroupDefault.set(val) }`. Export side: `c.privacySanitizeLinks = privacySanitizeLinksGroupDefault.get()`. + +### 4.3 Verification + +- Haskell `testAppSettings` (`tests/ChatTests/Direct.hs:2768`) covers the JSON round-trip through `defaultAppSettings`; the new field flows through both sides of the equality, so existing assertions hold. +- Manual test plan (in PR description): + 1. Enable on Android, export DB, import on a second Android device — toggle stays on. + 2. Enable on iOS, export, import on a second iOS device — toggle stays on. + 3. Enable on desktop, export, fresh-install + import — toggle stays on. + 4. Cross-platform: export from Android, import on iOS, and vice versa — toggle preserved. + 5. Fresh install with no archive — toggle defaults to off (unchanged). + +### 4.4 Risk and rollback + +- **Blast radius**: the `AppSettings` JSON payload. Every other field is untouched (positional inserts, no reordering of existing fields beyond the natural shift). +- **Backwards compatibility**: old clients (no field) parsing new JSON ignore the key. New clients (with field) parsing old JSON see `Nothing`, fall through to `defaultAppSettings` and the local pref is set to its default. Either direction is safe. +- **Rollback**: `git revert 15457a903`. Restores pre-fix behaviour (the field-loss bug returns). + +## 5. Why this specific shape + +- The bug has exactly one cause: a missing field in the round-trip payload. The smallest fix is to add the field. Anything larger (e.g. broadening `importIntoApp` to scan all shared prefs, or pinning the value in a side channel) would be a structural change that does not improve correctness. +- The `<|>` merge in `combineAppSettings` already gives the right behaviour for every edge case (archive-silent local-set, fresh install, downgrade) once the field exists. No new merge logic needed. +- The default `false` is forced: any other choice would either contradict the local pref default (`mkBoolPreference(..., false)`, iOS `registerGroupDefaults: false`) or invert the wire shape of `prepareForExport`. +- Final PR is 5 files, +18 / 0. Three of those files are the three `AppSettings` records; the other two are the iOS wiring the new field needs in order to read and write its group default. No other file in the codebase needed touching. diff --git a/plans/2026-05-13-relay-refuse-rejoin.md b/plans/2026-05-13-relay-refuse-rejoin.md new file mode 100644 index 0000000000..e33a525c03 --- /dev/null +++ b/plans/2026-05-13-relay-refuse-rejoin.md @@ -0,0 +1,347 @@ +Plan rewritten for conciseness with fresh-context re-evaluation; supersedes earlier revisions. + +# Plan: relay refuses to rejoin a channel it left + +## 1. Identifier + +Gating key: `GroupRelayInvitation.groupLink :: ShortLinkContact` (Types.hs:884-889). Available at `xGrpRelayInv` (Subscriber.hs:1524-1528) before any DB write or network call. The relay already stores this value on every `groups` row it processes (column `relay_request_group_link`, M20260222:38), and the existing `relay_own_status` column already carries the relay's lifecycle for the channel — refusal slots into that state machine as a new `RSRejected` variant. Lookup is a single SELECT against `groups`. Link rotation by the owner bypasses refusal; `publicGroupId` (Types.hs:790) would resist that but is only known after `getShortLinkConnReq'` — defer that gating to a follow-up. + +## 2. Storage + +No new column, no new type, no new field on `GroupInfo`. The existing `relay_own_status TEXT` (M20260222:37) is the carrier. + +`RelayStatus` (`src/Simplex/Chat/Types/Shared.hs:81-114`) gains an `RSRejected` constructor (encoded as `"rejected"`). It is reused on both sides: on the relay it is the row's own state after `APILeaveGroup`; on the owner it is the `GroupRelay.relayStatus` after `XGrpRelayReject` arrives in §5. + +State-machine slot for `RSRejected` on the relay: + +- `updateRelayOwnStatus_` (Store/Groups.hs:1593-1597) writes `relay_inactive_at = Just currentTs` only when the new status is `RSInactive`. `RSRejected` therefore correctly leaves `relay_inactive_at = NULL`, so the row is NOT eligible for `checkRelayInactiveGroups` cleanup (Commands.hs:4812-4817). +- `checkRelayServedGroups` (Commands.hs:4795-4810) iterates only `getRelayServedGroups` rows — `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607). RSRejected rows are not iterated. +- `xGrpMemDel` writer at Subscriber.hs:3132 currently flips any non-NULL `relay_own_status` to `RSInactive` when the owner removes the relay member. That would silently regress `RSRejected → RSInactive` and let a subsequent `XGrpRelayInv` slip through (the lookup checks `'rejected'`). The write at line 3132 is tightened to skip when the row is already `RSRejected`: + +```haskell +when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ + updateRelayOwnStatus_ db gInfo RSInactive +``` + +New migration `M20260514_relay_request_group_link_index` adds a partial index — the column is unindexed today and the new gate SELECTs on it. SQLite: + +```sql +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +``` + +Postgres mirror. Partial-on-`IS NOT NULL` because most rows on owner-only or p2p installs leave the column NULL. Both engines support partial indexes. Down: `DROP INDEX idx_groups_relay_request_group_link`. + +One helper, added next to the existing `relay_*` helpers in `src/Simplex/Chat/Store/Groups.hs`: + +```haskell +isRelayGroupRefused :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRefused db User {userId} groupLink = + fromOnly . head <$> DB.query db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) +``` + +`EXISTS … LIMIT 1` because more than one `groups` row may share `relay_request_group_link` (`createRelayRequestGroup` at Store/Groups.hs:1526 INSERTs unconditionally). If any matching row has `relay_own_status = 'rejected'`, the channel is refused. The equality check naturally excludes other states (NULL, RSInvited, RSAccepted, RSActive, RSInactive). + +All other operator-allow and leave writes reuse existing helpers `updateRelayOwnStatus_` and `updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1597). No new write helpers. + +## 3. Rejection point — `xGrpRelayInv` (Subscriber.hs:1524) + +```haskell +xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () +xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + refused <- withStore' $ \db -> isRelayGroupRefused db user groupLink + if refused + then sendRelayRejection `catchAllErrors` eToView + else do + initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay + lift $ void $ getRelayRequestWorker True + where + sendRelayRejection = do + let pqSup = PQSupportOff + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` chatVRange + connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) False invId pqSup + dm <- encodeConnInfoPQ pqSup chatV XGrpRelayReject + void $ withAgent $ \a -> + acceptContact a NRMBackground (aUserId user) connId False invId dm pqSup subMode + deleteAgentConnectionAsync' connId False +``` + +**Why synchronous `acceptContact` (not `acceptContactAsync`).** `acceptContactAsync` enqueues a JOIN agent command; the CONF send and the snd-queue creation happen later inside the agent's command worker (Agent.hs:1826-1830). If we immediately call `deleteAgentConnectionAsync' acId True`, `setConnDeleted` runs, `prepareDeleteConnections_` finds zero rcv queues (no JOIN yet), `deleteConn db (Just timeout) connId` finds zero `snd_message_deliveries` and calls `deleteConnRecord`. The connection record is gone before the JOIN worker can send the CONF — the rejection signal is silently dropped. + +`acceptContact` (Internal.hs:881-912 precedent; Agent.hs:1437-1442 → `joinConn` 1263 → `joinConnSrv` 1358-1369 for CRContactUri → `sendInvitation` Agent/Client.hs:1796-1799 → `sendOrProxySMPMessage` 1084-1094 → `sendSMPMessage`/`proxySMPMessage`) hands the CONF to the SMP server via a direct SMP client call. The CONF does NOT go through `snd_message_deliveries` — it is transmitted inline. Subsequent `deleteAgentConnectionAsync' connId False` is therefore safe. The cost is one SMP round-trip blocking the receive loop, which the refusal path can absorb. + +No chat-layer `Connection` row is persisted for the refused contact — the agent owns the connection state, and `deleteAgentConnectionAsync'` cleans it up. + +If `sendInvitation` throws (SMP server unreachable), `acceptContact` throws before reaching its internal `acceptInvitation` step and the agent-allocated rcv queue from `newRcvConnSrv` is left for the agent's eventual cleanup. The owner receives no rejection and falls back to the silent-degradation path (GroupRelay stuck at `RSInvited`). The outer `catchAllErrors eToView` prevents the receive loop from being held by the bubbled-up exception. + +## 4. Wire format — `XGrpRelayReject` + +Empty-payload event, owner-relay direct contact channel only. Not group-signed. Naming matches the existing `XGrpLinkReject` precedent (Protocol.hs:440, tag:985, string:1043). + +`src/Simplex/Chat/Protocol.hs`: + +- GADT constructor (after `XGrpRelayNew`, line 446): `XGrpRelayReject :: ChatMsgEvent 'Json` +- Tag GADT (after `XGrpRelayNew_`, line 991): `XGrpRelayReject_ :: CMEventTag 'Json` +- `strEncode` (line 1049): `XGrpRelayReject_ -> "x.grp.relay.reject"` +- `strDecode` (line 1108): `"x.grp.relay.reject" -> XGrpRelayReject_` +- `toCMEventTag` (line 1163): `XGrpRelayReject -> XGrpRelayReject_` +- JSON parse (line 1321): `XGrpRelayReject_ -> pure XGrpRelayReject` +- JSON encode (line 1391): `XGrpRelayReject -> JM.empty` — matches `XGrpLeave -> JM.empty` (1402) and `XDirectDel -> JM.empty` (1379). +- **No** entry in `isForwardedGroupMsg` (485-505) or `requiresSignature` (1227-1238). + +Older owner clients parse the unknown tag as `XUnknown` (default branch at 1134) and hit the CONF handler's catch-all `_ -> messageError "CONF from invited member must have x.grp.acpt"`. No state change, no crash; the GroupRelay stays at `RSInvited` — the same end state as today's "relay never responds" mode. The owner UI shows the relay as permanently "invited" with no progress; documented degradation. + +`docs/protocol/channels-protocol.md`: insert a `### Relay refusal` subsection between `### Relay addition` (61-73) and `### Subscriber connection` (75). Paragraphs: + +1. **Trigger** — relay's `APILeaveGroup` sets `relay_own_status = 'rejected'` on the relay's local `groups` row for the channel. +2. **Signal** — empty-payload `x.grp.relay.reject` over the owner-relay direct contact channel. +3. **Owner handling** — `GroupRelay` transitions `RSInvited → RSRejected`; final. Cleared only by the relay operator running `/group allow `. +4. **Limitations** — (a) older owner clients log a CONF parse error and leave their `GroupRelay` at `RSInvited` indefinitely (same UX as a relay that doesn't respond); (b) older relay binaries do not enforce refusal — mixed-version deployments where some relays are old behave asymmetrically. + +## 5. Owner-side state + +`RelayStatus` gains `RSRejected` (§2). Add to `relayStatusText`, `textEncode`, `textDecode`. + +CONF handler arm in `src/Simplex/Chat/Library/Subscriber.hs:760-773` (immediately after the existing `XGrpRelayAcpt` clause): + +```haskell +XGrpRelayReject + | memberRole' membership == GROwner && isRelay m -> do + relay <- withStore $ \db -> do + liftIO $ updateGroupMemberStatus db userId m GSMemRejected + relay <- getGroupRelayByGMId db (groupMemberId' m) + liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + let m' = m {memberStatus = GSMemRejected} + deleteMemberConnection m' + toView $ CEvtGroupRelayUpdated user gInfo m' relay + | otherwise -> messageError "x.grp.relay.reject: only owner can receive relay rejection" +``` + +`getGroupRelayByGMId` (Store/Groups.hs:1307) and `updateRelayStatusFromTo` (1438-1442) are already exported. `updateRelayStatusFromTo` is conditional on the current status equalling `RSInvited` — racing CONFs cannot regress an already-rejected or already-active row. `deleteMemberConnection` (Internal.hs:1807-1808) safely no-ops when `activeConn` is `Nothing`. `CEvtGroupRelayUpdated` (Controller.hs:900) carries exactly the iOS payload. + +`addRelays` (Commands.hs:3942-3976) persists `GroupRelay` with `RSNew → RSInvited` before sending `XGrpRelayInv`, so the row exists when the CONF arrives. A second user-initiated `addRelays` after rejection creates a fresh row, independent of the rejected one — no automatic retry. + +## 6. Refusal write — `APILeaveGroup` (Commands.hs:2919-2935) + +Currently `leaveChannelRelay` does NOT touch `relay_own_status` — verified at Commands.hs:2938-2947. The new write is added to the existing leave path, unconditionally on the relay-leave branch: + +```haskell +APILeaveGroup groupId -> withUser $ \user@User {userId} -> do + gInfo@GroupInfo {membership} <- withFastStore $ \db -> getGroupInfo db vr user groupId + filesInfo <- withFastStore' $ \db -> getGroupFileInfo db user gInfo + withGroupLock "leaveGroup" groupId $ do + cancelFilesInProgress user filesInfo + msg <- if useRelays' gInfo && isRelay membership + then leaveChannelRelay gInfo + else leaveGroupSendMsg user gInfo + (gInfo', scopeInfo) <- mkLocalGroupChatScope gInfo + ci <- saveSndChatItem user (CDGroupSnd gInfo' scopeInfo) msg (CISndGroupEvent SGEUserLeft) + toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] + deleteGroupLinkIfExists user gInfo' + withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft + -- NEW: mark the relay's local groups row as refused + when (useRelays' gInfo && isRelay membership) $ + withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSRejected + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} +``` + +`updateRelayOwnStatus_` (Store/Groups.hs:1593) writes unconditionally. The prior status can legitimately be any of `RSInvited` (operator leaves mid-request, placeholder profile still in place — verified at Store/Groups.hs:1531-1541), `RSAccepted` (waiting for health-check), `RSActive` (steady state), or `RSInactive` (already inactive — re-leaving). The earlier rev's `publicGroup == Nothing` throw was wrong: `RSInvited` is a real lifecycle state with `publicGroup = Nothing` (`createRelayRequestGroup` at Store/Groups.hs:1531 uses a placeholder profile until `updateGroupProfile` at Subscriber.hs:3847 runs inside the relay-request worker). Writing `RSRejected` unconditionally on the relay-leave path correctly cancels an in-progress invitation: `getNextPendingRelayRequest` (Store/RelayRequests.hs:60-72) selects only rows where `relay_own_status = 'invited'`, so the flip to `RSRejected` removes the row from the worker queue. + +## 7. Operator command — relay side + +One API command. Operator discovers rejected channels through `/gs` (see §7.2). + +`src/Simplex/Chat/Controller.hs` (after `APITestChatRelay` at ~408): + +```haskell +| APIAllowRelayGroup {groupId :: GroupId} +-- response (after CRGroupRelays at ~737): +| CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} +``` + +Parser entries (`src/Simplex/Chat/Library/Commands.hs:5033+`). `GroupId = Int64` is a type alias (Types.hs:449), so `A.decimal` decodes directly — matches `APILeaveGroup <$> A.decimal` at 5021: + +```haskell +"/_relay allow " *> (APIAllowRelayGroup <$> A.decimal), +"/group allow " *> (APIAllowRelayGroup <$> A.decimal), +``` + +Handler: + +```haskell +APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId + gInfo' <- withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSRejected RSInactive + pure $ CRRelayGroupAllowed user gInfo' +``` + +`updateRelayOwnStatusFromTo` (Store/Groups.hs:1587-1591) atomically transitions only if the current status equals the from-state — a non-rejected row stays unchanged and the response reports the unchanged `gInfo`. The transition to `RSInactive` writes `relay_inactive_at = currentTs` via `updateRelayOwnStatus_` (1593-1597), so the row becomes eligible for `checkRelayInactiveGroups` connection cleanup on TTL — the correct hygiene state for a previously-rejected, now-cleared row. + +No event to the owner. The owner's next user-initiated `addRelays` succeeds normally (the relay's `xGrpRelayInv` finds no `'rejected'` row for the link). Operator authorization is the chat-relay binary's process-level access. + +### 7.1 Guard against deleting a rejected group + +`APIDeleteChat CTGroup` at Commands.hs:1242-1246 lets the operator delete the group once `memberCurrent membership` is false (post-leave). That path would silently clear the refusal — an accidental `/d` should not undo a moderation decision. Add a guard immediately after the existing `unless canDelete` check: + +```haskell +when (relayOwnStatus gInfo == Just RSRejected) $ + throwChatError $ CECommandError "cannot delete a rejected channel; run /_relay allow first" +``` + +`checkRelayInactiveGroups` (Commands.hs:4812-4817) only deletes connections via `deleteGroupConnections`, not group rows, so no guard is needed there. + +### 7.2 Surface `[rejected]` in `/gs` + +`viewGroupsList` in `src/Simplex/Chat/View.hs:1432-1459`. Extend `groupSS`'s destructure to pull `relayOwnStatus` while keeping the existing `GroupSummary {currentMembers}` pattern (used at line 1456 by `memberCount`), and append `[rejected]` between status and alias: + +```haskell +groupSS g@GroupInfo { membership + , chatSettings = ChatSettings {enableNtfs} + , groupSummary = GroupSummary {currentMembers} + , relayOwnStatus + } = + case memberStatus membership of + GSMemInvited -> groupInvitation' g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g + where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" + … +``` + +## 8. iOS + +No iOS storage-side change. The owner-side `RSRejected` rendering is the same as the rev-4 plan. + +`apps/ios/SimpleXChat/ChatTypes.swift:2637-2643 + 2708-2718`: + +```swift +public enum RelayStatus: String, Decodable, Equatable, Hashable { + … + case rsRejected = "rejected" +} +extension RelayStatus { public var text: LocalizedStringKey { + switch self { … case .rsRejected: "rejected" } +}} +``` + +`apps/ios/Shared/Views/NewChat/AddChannelView.swift:487-504` (`relayStatusIndicator`): + +```swift +let isRejected = status == .rsRejected +let color: Color = + connFailed || removed || isRejected ? .red + : (status == .rsActive ? .green : .yellow) +let text: LocalizedStringKey = + connFailed ? "failed" + : memberStatus == .memLeft ? "removed by operator" + : isRejected ? "rejected" + : removed ? "removed" + : status.text +``` + +`apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift`, inside the existing `Section` after the `Relay address` block at line 195: + +```swift +if groupRelay?.relayStatus == .rsRejected { + infoRow("Status", "rejected by relay operator") +} +``` + +`ChannelRelaysView.swift` requires no change — the existing fall-through in `ownerRelayStatusText` (line 114-127) to `groupRelays.first(…)?.relayStatus.text` already renders `"rejected"`. + +`GroupMemberStatus.memRejected` already exists at ChatTypes.swift:3002. No iOS enum change; cited here so an iOS-only reviewer doesn't drop the case. + +Per `apps/ios/CODE.md` Change Protocol, the implementer updates `apps/ios/spec/state.md`, `apps/ios/spec/api.md`, `apps/ios/spec/client/chat-view.md`, `apps/ios/product/views/group-info.md`, `apps/ios/spec/impact.md`, and `apps/ios/product/concepts.md`. + +Kotlin/Android/desktop port is a separate PR. + +## 9. Tests — `tests/ChatTests/RelayRefused.hs` + +All tests use the existing channel harness and block on chat events, not `threadDelay`. + +- **`testRelayRefuseAfterLeave`** — relay1 leaves; owner re-adds; owner blocks on `CEvtGroupRelayUpdated`; assert owner `relayStatus == RSRejected`, member `GSMemRejected`, channel link data excludes relay1. Also assert relay's `groups.relay_own_status = 'rejected'`. Deterministic delivery check for the sync-accept-then-delete path. +- **`testRelayAllowAcceptsAgain`** — operator runs `/group allow `; relay's `groups.relay_own_status` becomes `'inactive'`; owner re-adds; relay reaches `RSActive` on a fresh `GroupRelay` row. +- **`testRelayDoesNotRefuseUnrelatedChannel`** — relay1 leaves channel A; owner of unrelated channel B issues `XGrpRelayInv`; relay1 accepts B; only A's `groups` row has `relay_own_status = 'rejected'`. +- **`testRelayRefuseRaceConcurrentInvitations`** — owner sends two `XGrpRelayInv` for the same channel concurrently after the relay has left; both refuse; relay's `groups` table acquires no placeholder row for the second invitation (both lookups match the same rejected row). +- **`testRelayForwardCompatOldOwner`** — owner's `chatVersionRange` excludes `x.grp.relay.reject`; relay refuses; owner emits `messageError` and the GroupRelay row stays at `RSInvited`; no crash. +- **`testRelayDeleteRejectedBlocked`** — relay1 leaves channel A; operator runs `/d #A`; deletion fails with the guard error from §7.1; channel still exists; operator runs `/group allow ` then `/d #A`; deletion succeeds. +- **`testRelayRejectSurvivesOwnerRemoveRelayMember`** — relay1 leaves channel A (sets `RSRejected`); owner sends `XGrpMemDel` removing relay1; assert relay's `groups.relay_own_status` is still `'rejected'`, not flipped to `'inactive'`. Covers the §2 tightening of `xGrpMemDel` at Subscriber.hs:3132. +- **`testNonOwnerXGrpRelayRejectIgnored`** — owner-side negative case: deliver an `XGrpRelayReject` CONF on a connection where either `memberRole' membership /= GROwner` or the sender member is not `isRelay`; assert the owner emits `messageError` and neither the GroupRelay row nor the member status changes. + +## 10. Adversarial review + +- **Existing `RSInactive` consumers.** Three call sites filter on `Just RSInactive` to mean "relay not serving — drop normal delivery": + - Subscriber.hs:936 (`MSG` handler filters delivery tasks). + - Subscriber.hs:3571 (delivery-task worker rejects `DJDeliveryJob`). + - Subscriber.hs:3641 (delivery-job worker errors `DJDeliveryJob`). + All three must broaden to also match `Just RSRejected` — both states share the "not serving" semantic. `DJRelayRemoved` is handled in a separate branch and remains status-independent. Add a small predicate (e.g., `relayNotServing :: Maybe RelayStatus -> Bool`) near the existing `relayOwnStatus` accessors. +- **`xGrpMemDel` writer at Subscriber.hs:3132** — this is also a writer of `relay_own_status`, not a filter. It flips any non-NULL status to `RSInactive` when the owner removes the relay member. Tightened in §2 to skip when the row is already `RSRejected`; otherwise the refusal would be silently undone by a normal protocol event. +- **Health-check loop never touches RSRejected.** `getRelayServedGroups` filters `relay_own_status IN (RSAccepted, RSActive)` (Store/Groups.hs:1607); RSRejected rows are not iterated. `updateRelayOwnStatusFromTo` calls in Commands.hs:4808-4809 only transition RSAccepted↔RSActive↔RSInactive. With the §2 tightening of `xGrpMemDel` line 3132, no path can silently undo a refusal. +- **Operator deletes a rejected group** — blocked at `APIDeleteChat CTGroup` per §7.1. +- **Timing side channel** — refusal path is one synchronous SMP round-trip; accepted path is much longer. Passive SMP-server observation can distinguish, though relay load adds variance to both paths. SMP server already infers relay-channel relationships from connection patterns; marginal additional leak. +- **Information leakage in `XGrpRelayReject`** — empty payload. +- **Concurrent leave-then-rejoin** — operator-facing contract: invitations arriving before the leave commits locally are processed normally; invitations after are refused. Note that `xGrpRelayInv` does NOT take `withGroupLock "leaveGroup" groupId` (no group ID is known at REQ time); the bound is the SQL commit of the `relay_own_status = 'rejected'` write, not a lock. Sibling rows already at `RSInvited` from before the leave are not retroactively rejected — they are processed normally by the worker. See §12 for follow-up scope. +- **Two concurrent `XGrpRelayInv` for the same rejected channel** — both lookups hit the same indexed row, both refuse. No race. +- **Duplicate `groups` rows for the same `relay_request_group_link`** — pre-existing (`createRelayRequestGroup` INSERTs unconditionally; no uniqueness on `relay_request_inv_id` or `relay_request_group_link`). Any `RSRejected` row blocks *future invitations from creating new rows that progress to acceptance* (the lookup uses `EXISTS … LIMIT 1`). Sibling rows already in `RSInvited` continue to be processed by the worker — see §12. +- **Operator-allow vs. concurrent invitation** — UPDATE-SELECT race resolves to either "still refused" or "slipped through with accept"; both match operator intent. +- **`getGroupRelayByGMId` failure on owner side** — propagates as `ChatErrorStore`; cannot happen in normal operation. +- **Multi-user relay binary** — `groups.user_id` scopes both lookup and write. `withUser` for the CLI. No cross-user pollution. +- **`sendRelayRejection` SMP failure** — wrapped in `catchAllErrors eToView` per §3 so a single SMP failure during refusal does not propagate to the agent receive loop. The owner falls back to silent-degradation (GroupRelay stuck at RSInvited), matching today's "relay unresponsive" mode. +- **Forward compat — mixed-version relays.** An old relay binary leaves a channel by writing `RSInactive`, not `RSRejected`, and does not enforce refusal at `xGrpRelayInv`. Mixed-version deployments (some relays new, some old) have asymmetric behavior: new relays refuse, old relays accept. Acceptable v1 limitation; document in `docs/protocol/channels-protocol.md`. Operator on an upgraded relay can `/leave` again under the new binary to re-establish refusal. +- **Forward compat (old owner)** — old owner's CONF handler lands in the `_ -> messageError "CONF from invited member must have x.grp.acpt"` catch-all (Subscriber.hs:773). GroupRelay stays at `RSInvited`; same end state as today's "relay never responds" mode. Documented in the protocol doc. + +## 11. Files changed + +| File | Change | +|---|---| +| `src/Simplex/Chat/Types/Shared.hs` | `RSRejected` variant + text encodings | +| `src/Simplex/Chat/Protocol.hs` | `XGrpRelayReject` constructor, tag, str enc/dec, JSON enc/dec | +| `src/Simplex/Chat/Store/Groups.hs` | `isRelayGroupRefused` helper | +| `src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs` | NEW. Partial index | +| `src/Simplex/Chat/Store/SQLite/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs` | NEW | +| `src/Simplex/Chat/Store/Postgres/Migrations.hs` | Register migration | +| `src/Simplex/Chat/Controller.hs` | `APIAllowRelayGroup` command; `CRRelayGroupAllowed` response | +| `src/Simplex/Chat/Library/Commands.hs` | Parser; handler; refusal write in `APILeaveGroup`; delete guard in `APIDeleteChat CTGroup` | +| `src/Simplex/Chat/Library/Subscriber.hs` | Gate in `xGrpRelayInv`; `XGrpRelayReject` arm in CONF handler; broaden three RSInactive filters to also match RSRejected (lines 936, 3571, 3641); tighten `xGrpMemDel` writer at 3132 to skip when row is `RSRejected` | +| `src/Simplex/Chat/View.hs` | `[rejected]` suffix in `viewGroupsList` | +| `simplex-chat.cabal` | Register new migration modules | +| `docs/protocol/channels-protocol.md` | Insert "Relay refusal" subsection | +| `apps/ios/SimpleXChat/ChatTypes.swift` | `rsRejected` case + text | +| `apps/ios/Shared/Views/NewChat/AddChannelView.swift` | Red dot + "rejected" in `relayStatusIndicator` | +| `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift` | "Status: rejected by relay operator" row | +| `tests/ChatTests/RelayRefused.hs` | NEW. Eight tests | +| Test list registration | Add the new module | + +`chat_schema.sql` is auto-regenerated by tests. + +## 12. Out of scope + +- Kotlin/Android/desktop UI port. +- New alerts, modals, banners, compose-bar changes. +- Refusal triggered by `xGrpMemDel` (owner removing relay). +- Pre-emptive blocking of unseen channels. +- Owner-side independent clear of `RSRejected`. +- `publicGroupId`-keyed refusal. +- Timing-uniform refusal. +- **Sibling-row worker race.** When a relay leaves a channel for which it has a sibling `groups` row in `RSInvited` (e.g., the owner re-sent `XGrpRelayInv` and `createRelayRequestGroup` created a second row), only the row whose ID `APILeaveGroup` targets is flipped to `RSRejected`; sibling `RSInvited` rows continue through the worker. Pre-existing behavior — `leaveChannelRelay` doesn't touch sibling rows today either. Cheapest future closure: in §6, also `UPDATE groups SET relay_request_failed = 1 WHERE user_id = ? AND relay_request_group_link = ? AND relay_own_status = 'invited'` in the same transaction (the worker filters on `relay_request_failed = 0` at Store/RelayRequests.hs:67). Deferred to a follow-up. +- **`XGrpRelayInv` re-delivery duplicates.** `createRelayRequestGroup` has no uniqueness on `relay_request_inv_id` or `relay_request_group_link`; an owner retry of `XGrpRelayInv` creates duplicate rows. Pre-existing; closure ties to the sibling-row item above. + +The mixed-version-relay asymmetry and the old-owner stuck-RSInvited UI degradation are documented in `docs/protocol/channels-protocol.md` alongside the new `### Relay refusal` subsection. diff --git a/plans/2026-05-14-fix-group-link-share-ios.md b/plans/2026-05-14-fix-group-link-share-ios.md new file mode 100644 index 0000000000..ba22b04dd6 --- /dev/null +++ b/plans/2026-05-14-fix-group-link-share-ios.md @@ -0,0 +1,141 @@ +# Share Channel Link — Filter Saved Messages (iOS) + +Companion to [#6958](https://github.com/simplex-chat/simplex-chat/pull/6958) (`nd/fix-group-link-share`, Android/Desktop). +Branch `nd/fix-group-link-share-ios`, base `master`. + +## 1. The bug + +On the iOS channel-link "Share via chat" picker, **Saved Messages** is offered as a destination. Tapping it produces `chat commandError Failed reading: empty` from the server. + +This is the iOS counterpart of bug #1 from PR #6958. Bug #2 from that PR (the "Share via chat" button rendering on plain groups) does **not** exist on iOS — `GroupLinkView.swift:110` already gates the button with `if groupInfo?.groupProfile.publicGroup != nil`, and plain groups have `publicGroup == nil`. + +## 2. Root cause + +`APIShareChatMsgContent` is parsed with `sendRefP` in `src/Simplex/Chat/Library/Commands.hs:5426`: + +```haskell +sendRefP = + (A.char '@' $> SRDirect <*> A.decimal) + <|> (A.char '#' $> SRGroup <*> A.decimal <*> optional gcScopeP <*> asGroupP) +``` + +The iOS client emits `*` for `ChatType.local` (Saved Messages) via the standard chat-type prefix. `sendRefP` has no `*` branch, attoparsec returns `Failed reading: empty`, the handler never runs. + +This is the correct server behaviour — sharing a channel link to one's own note folder is not a meaningful operation. The picker offered the destination by accident: `filterChatsToForwardTo` in `apps/ios/SimpleXChat/ChatUtils.swift:56` unconditionally inserts `ChatInfo.local` at index 0: + +```swift +public func filterChatsToForwardTo(chats: [C]) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } + if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats +} +``` + +`shareChannelPicker` (`apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift:1103`) builds a `ChatItemForwardingView`, which calls `filterChatsToForwardTo`. So the channel-link picker inherits the Saved-Messages-at-index-0 behaviour that the forward picker wants. + +## 3. Approaches considered + +| # | Approach | Note | +|---|----------|------| +| A | **Final** — parameterize the filter: add `includeLocal: Bool = true` to `filterChatsToForwardTo` and to `ChatItemForwardingView`; pass `includeLocal: false` from `shareChannelPicker`. | Default keeps existing call-sites untouched. Mirrors PR #6958's pattern — the filter decides, callers express intent. | +| B | Post-filter `.local` inside `ChatItemForwardingView` after the call to `filterChatsToForwardTo`. | Same line count, but duplicates the `.local` predicate at the consumer instead of expressing it at the producer. | +| C | Pass a closure filter to `ChatItemForwardingView`. | A closure encodes one bit as a function — strictly more machinery for the same outcome. | +| D | Mirror Kotlin literally: read a global `SharedContent.ChatLink` discriminator inside the filter. | iOS's `SharedContent` lives in the Share Extension target, not the main app — the Kotlin-style predicate doesn't translate. | + +Approach A wins on minimality (5 lines, three files), preserves all default behaviour, and matches the architectural pattern of PR #6958 (decision lives where the data is produced). + +## 4. Final implementation + +### 4.1 `apps/ios/SimpleXChat/ChatUtils.swift` — add `includeLocal` parameter + +```diff +-public func filterChatsToForwardTo(chats: [C]) -> [C] { ++public func filterChatsToForwardTo(chats: [C], includeLocal: Bool = true) -> [C] { + var filteredChats = chats.filter { c in + c.chatInfo.chatType != .local && canForwardToChat(c.chatInfo) + } +- if let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { ++ if includeLocal, let privateNotes = chats.first(where: { $0.chatInfo.chatType == .local }) { + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats + } +``` + +Default value preserves the contract for every existing caller (`ChatItemForwardingView.swift:26`, `SimpleX SE/ShareModel.swift:71-72`). The `if includeLocal, let ...` form Swift-natively short-circuits — no nested block needed. + +### 4.2 `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift` — thread the flag + +```diff + var isProhibited: ((Chat) -> Bool)? = nil + var onSelectChat: ((Chat) -> Void)? = nil ++ var includeLocal: Bool = true + + @State private var searchText: String = "" + @State private var alert: SomeAlert? +- private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats) ++ private var chatsToForwardTo: [Chat] { filterChatsToForwardTo(chats: ChatModel.shared.chats, includeLocal: includeLocal) } +``` + +`private let → private var` (computed) is required because Swift property initializers cannot read sibling instance properties. The computed form re-evaluates when `body` runs — in this view that is twice per render (lines 49 and 52), against a list of size `chats.count`. No meaningful cost; if a profile ever flagged it, switching to a custom `init(...)` that captures `includeLocal` once is a trivial follow-up. + +### 4.3 `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` — opt out from the channel-link picker + +```diff + let v = ChatItemForwardingView( + title: "Share channel", + isProhibited: { $0.prohibitedByPref(hasSimplexLink: true, isMediaOrFileAttachment: false, isVoice: false) }, +- onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) } ++ onSelectChat: { chat in shareChatLink(chat, sourceGroupInfo: groupInfo, composeState: composeState) }, ++ includeLocal: false + ) +``` + +One-line opt-out from the only iOS site that uses the channel-link share flow. + +### 4.4 What is *not* changed + +- **`GroupLinkView.swift:110`** — already gates "Share via chat" with `groupInfo?.groupProfile.publicGroup != nil`. Bug #2 from PR #6958 has no iOS analog. +- **Forward picker** (`ChatView.swift:279, 282`) — uses `ChatItemForwardingView`'s default `includeLocal: true`. Saved Messages still appears at index 0. +- **Share extension** (`SimpleX SE/ShareModel.swift:71-72`) — calls `filterChatsToForwardTo` directly with the default. Unchanged. +- **Haskell.** `sendRefP` and `APIShareChatMsgContent` stay at master. The client just stops offering destinations the server refuses. +- **Android/Desktop, all other share/forward paths.** + +## 5. Why this works + +The server is the source of truth for which destinations are valid for `APIShareChatMsgContent`: + +- Destinations: `@` (direct), `#` (group / scope). Local (`*`) is rejected as a parse failure, by construction. +- Sources: groups with `publicGroup` and `groupLink`. iOS already gates the source side correctly. + +The client's job is to offer choices the server will accept. The picker offered Local in error; this PR narrows the offer to match the server's grammar. The default-`true` parameter means every other caller keeps its current behaviour without modification. + +## 6. Behaviour changes — full inventory + +1. **Picking Saved Messages in the iOS share-channel-link picker is no longer possible.** This is the bug fix. +2. **Forward picker — unchanged.** Default `includeLocal: true`. Forward-to-Saved-Messages still works. +3. **Share extension picker — unchanged.** Default `includeLocal: true`. +4. **`GroupLinkView` button gate — unchanged.** Already correct on iOS. + +Nothing else changes. Verified by reading the diff against master line-by-line. + +## 7. Verification + +1. **Diff is six insertions, four deletions across three files** (`git diff --stat`): + - `apps/ios/SimpleXChat/ChatUtils.swift | 4 ++--` + - `apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift | 3 ++-` + - `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift | 3 ++-` +2. **iOS build** requires Xcode on macOS — not run in this environment. To run by reviewer. +3. **Manual on iOS once built:** + - Open a public channel → profile → "Share via chat" → picker shows direct + group destinations only, **no "Saved Messages" row**. + - Long-press a message → Forward → picker still shows Saved Messages at the top (regression check). + - Open a plain group → group-link management → no "Share via chat" button (already correct, regression check). + +## 8. Trade-offs and follow-ups + +1. **Computed `chatsToForwardTo` re-evaluates on body refresh** rather than caching at struct init. In practice, twice per render against a small list, with `ChatModel.shared.chats` already SwiftUI-observed. Switching to a custom `init(...)` that captures `includeLocal` and assigns `chatsToForwardTo` once is a one-step refactor if ever needed. +2. **The flag is binary, not content-typed.** Kotlin discriminates on `SharedContent` variant; iOS uses an explicit caller intent. If a future iOS site needed to skip Local for a non-share-channel reason, the same flag applies — no further changes needed. diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index a2bafe8a70..b55a08df26 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,32 @@ + + https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html + +

New in v6.5.2:

+
    +
  • allow deleting messages from channel history without time limit.
  • +
+

New in v6.5:

+

Public channels - speak freely!

+
    +
  • Reliability: many relays per channel.
  • +
  • Ownership: you can run your own relays.
  • +
  • Security: owners hold channel keys.
  • +
  • Privacy: for owners and subscribers.
  • +
+

Easier to invite your friends: we made connecting simpler for new users.

+

Safe web links:

+
    +
  • opt-in to send link previews.
  • +
  • use SOCKS proxy for previews (if enabled).
  • +
  • prevent hyperlink phishing.
  • +
  • remove link tracking.
  • +
+

Non-profit governance: to make SimpleX Network last.

+
+
https://simplex.chat/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index feee62af4a..e4532c49b5 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."8bd3193280da6b4decf790bb57b470780c2576ba" = "0zzrsdgsqdiff6ks7l4qsik9p5023f1n56iaf7x395l4ykf6bkqm"; + "https://github.com/simplex-chat/simplexmq.git"."f0b7a4be7325cb787297a881076299c5ffbe26e7" = "0a8a9l31l4a9nilcqg8h60mrxpqxpzzqxi58i60nw8h4vxqqlzcz"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 6c343164fc..e9a5660637 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 6.5.1.1 +version: 6.5.3.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -132,6 +132,8 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services else exposed-modules: @@ -287,6 +289,8 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at + Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index + Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services other-modules: Paths_simplex_chat @@ -576,6 +580,7 @@ test-suite simplex-chat-test API.Docs.Commands API.Docs.Events API.Docs.Generate + API.Docs.Generate.Python API.Docs.Generate.TypeScript API.Docs.Responses API.Docs.Syntax diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 1efa69fad4..22938dd48c 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -33,6 +33,7 @@ data AppSettings = AppSettings privacyAskToApproveRelays :: Maybe Bool, privacyAcceptImages :: Maybe Bool, privacyLinkPreviews :: Maybe Bool, + privacySanitizeLinks :: Maybe Bool, privacyShowChatPreviews :: Maybe Bool, privacySaveLastDraft :: Maybe Bool, privacyProtectScreen :: Maybe Bool, @@ -83,6 +84,7 @@ defaultAppSettings = privacyAskToApproveRelays = Just True, privacyAcceptImages = Just True, privacyLinkPreviews = Just True, + privacySanitizeLinks = Just False, privacyShowChatPreviews = Just True, privacySaveLastDraft = Just True, privacyProtectScreen = Just False, @@ -120,6 +122,7 @@ defaultParseAppSettings = privacyAskToApproveRelays = Nothing, privacyAcceptImages = Nothing, privacyLinkPreviews = Nothing, + privacySanitizeLinks = Nothing, privacyShowChatPreviews = Nothing, privacySaveLastDraft = Nothing, privacyProtectScreen = Nothing, @@ -157,6 +160,7 @@ combineAppSettings platformDefaults storedSettings = privacyAskToApproveRelays = p privacyAskToApproveRelays, privacyAcceptImages = p privacyAcceptImages, privacyLinkPreviews = p privacyLinkPreviews, + privacySanitizeLinks = p privacySanitizeLinks, privacyShowChatPreviews = p privacyShowChatPreviews, privacySaveLastDraft = p privacySaveLastDraft, privacyProtectScreen = p privacyProtectScreen, @@ -210,6 +214,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays <- p "privacyAskToApproveRelays" privacyAcceptImages <- p "privacyAcceptImages" privacyLinkPreviews <- p "privacyLinkPreviews" + privacySanitizeLinks <- p "privacySanitizeLinks" privacyShowChatPreviews <- p "privacyShowChatPreviews" privacySaveLastDraft <- p "privacySaveLastDraft" privacyProtectScreen <- p "privacyProtectScreen" @@ -244,6 +249,7 @@ instance FromJSON AppSettings where privacyAskToApproveRelays, privacyAcceptImages, privacyLinkPreviews, + privacySanitizeLinks, privacyShowChatPreviews, privacySaveLastDraft, privacyProtectScreen, diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 8e6fac5d10..402bfa6b10 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -408,6 +408,7 @@ data ChatCommand | SetUserChatRelays [CLINewRelay] | APITestChatRelay UserId ShortLinkContact | TestChatRelay ShortLinkContact + | APIAllowRelayGroup {groupId :: GroupId} | APIGetServerOperators | APISetServerOperators (NonEmpty ServerOperator) | SetServerOperators (NonEmpty ServerOperatorRoles) @@ -533,6 +534,7 @@ data ChatCommand | BlockForAll GroupName ContactName Bool | RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool} | LeaveGroup GroupName + | AllowRelayGroup GroupName | DeleteGroup GroupName | ClearGroup GroupName | ListMembers GroupName @@ -736,6 +738,7 @@ data ChatResponse | CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]} + | CRRelayGroupAllowed {user :: User, groupInfo :: GroupInfo} | CRGroupRelaysAdded {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]} | CRGroupRelaysAddFailed {user :: User, addRelayResults :: [AddRelayResult]} | CRGroupMembers {user :: User, group :: Group} @@ -947,6 +950,7 @@ data ChatEvent data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} + | TERelayRejected {user :: User, groupInfo :: GroupInfo, relayRejectionReason :: RelayRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} | TENewMemberContact {user :: User, contact :: Contact, groupInfo :: GroupInfo, member :: GroupMember} | TEContactVerificationReset {user :: User, contact :: Contact} diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index 822ee5efb9..a6ccc74247 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -161,7 +161,7 @@ instance TextEncoding DeliveryTaskStatus where data MessageDeliveryJob = MessageDeliveryJob { jobId :: Int64, jobScope :: DeliveryJobScope, - singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries + senderGMIds :: [GroupMemberId], body :: ByteString, cursorGMId_ :: Maybe GroupMemberId } diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 81c230e88d..f835445f0d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -762,11 +762,12 @@ processChatCommand vr nm = \case deletions <- case mode of CIDMInternal -> deleteDirectCIs user ct items CIDMInternalMark -> markDirectCIsDeleted user ct items =<< liftIO getCurrentTime + CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do assertDeletable items assertDirectAllowed user MDSnd ct XMsgDel_ let msgIds = itemsMsgIds items - events = map (\msgId -> XMsgDel msgId Nothing Nothing) msgIds + events = map (\msgId -> XMsgDel msgId Nothing Nothing False) msgIds forM_ (L.nonEmpty events) $ \events' -> sendDirectContactMessages user ct events' if featureAllowed SCFFullDelete forUser ct @@ -778,8 +779,9 @@ processChatCommand vr nm = \case -- TODO [knocking] check scope for all items? chatScopeInfo <- mapM (getChatScopeInfo vr user) scope deletions <- case mode of - CIDMInternal -> do - deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime + CIDMInternal + | publicGroupEditor gInfo (membership gInfo) -> throwChatError CEInvalidChatItemDelete + | otherwise -> deleteGroupCIs user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> do markGroupCIsDeleted user gInfo chatScopeInfo items Nothing =<< liftIO getCurrentTime CIDMBroadcast -> do @@ -787,9 +789,15 @@ processChatCommand vr nm = \case assertDeletable items assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier let msgIds = itemsMsgIds items - events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing $ toMsgScope gInfo <$> chatScopeInfo) msgIds + events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) False) msgIds + mapM_ (sendGroupMessages user gInfo Nothing False recipients) events + delGroupChatItems user gInfo chatScopeInfo items False + CIDMHistory -> do + unless (publicGroupEditor gInfo (membership gInfo)) $ throwChatError CEInvalidChatItemDelete + recipients <- getGroupRecipients vr user gInfo chatScopeInfo groupKnockingVersion + let msgIds = itemsMsgIds items + events = L.nonEmpty $ map (\msgId -> XMsgDel msgId Nothing (toMsgScope gInfo <$> chatScopeInfo) True) msgIds mapM_ (sendGroupMessages user gInfo Nothing False recipients) events - -- TODO delGroupChatItems sends deletion events too. Are they needed? delGroupChatItems user gInfo chatScopeInfo items False pure $ CRChatItemsDeleted user deletions True False CTLocal -> do @@ -831,6 +839,7 @@ processChatCommand vr nm = \case deletions <- case mode of CIDMInternal -> deleteGroupCIs user gInfo Nothing items Nothing =<< liftIO getCurrentTime CIDMInternalMark -> markGroupCIsDeleted user gInfo Nothing items Nothing =<< liftIO getCurrentTime + CIDMHistory -> throwChatError CEInvalidChatItemDelete CIDMBroadcast -> do ms <- withFastStore' $ \db -> getGroupModerators db vr user gInfo let recipients = filter memberCurrent ms @@ -1592,6 +1601,9 @@ processChatCommand vr nm = \case Right (Just (Just failure)) -> pure $ CRChatRelayTestResult user (Just relayProfile) (Just failure) TestChatRelay address -> withUser $ \User {userId} -> processChatCommand vr nm $ APITestChatRelay userId address + APIAllowRelayGroup groupId -> withUser $ \user -> do + gInfo' <- withStore $ \db -> allowRelayGroup db vr user groupId + pure $ CRRelayGroupAllowed user gInfo' GetUserChatRelays -> withUser $ \user -> do srvs <- withFastStore (`getUserServers` user) liftIO $ CRUserServers user <$> groupByOperator (onlyRelays srvs) @@ -2150,7 +2162,8 @@ processChatCommand vr nm = \case _ -> Nothing void $ createLinkOwnerMember db vr user gInfo' ctId_ (MemberId ownerId) ownerKey pure gInfo' - rs <- mapConcurrently (connectToRelay user gInfo') relays + rs <- withGroupLock "connectPreparedGroup" groupId $ + mapConcurrently (connectToRelay user gInfo') relays let relayFailed = \case (_, _, Left _) -> True; _ -> False (failed, succeeded) = partition relayFailed rs if null succeeded @@ -2939,9 +2952,13 @@ processChatCommand vr nm = \case toView $ CEvtNewChatItems user [AChatItem SCTGroup SMDSnd (GroupChat gInfo' scopeInfo) ci] -- TODO delete direct connections that were unused deleteGroupLinkIfExists user gInfo' + let relayRejected = useRelays' gInfo && isRelay membership -- member records are not deleted to keep history - withFastStore' $ \db -> updateGroupMemberStatus db userId membership GSMemLeft - pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}} + withFastStore' $ \db -> do + updateGroupMemberStatus db userId membership GSMemLeft + when relayRejected $ updateRelayOwnStatus_ db gInfo RSRejected + let relayOwnStatus' = if relayRejected then Just RSRejected else relayOwnStatus gInfo + pure $ CRLeftMemberUser user gInfo' {membership = membership {memberStatus = GSMemLeft}, relayOwnStatus = relayOwnStatus'} where -- Relay leaving channel: create delivery job for cursor-based sending and async connection cleanup. leaveChannelRelay gInfo = do @@ -2951,7 +2968,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) Nothing body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user gInfo = do @@ -2993,6 +3010,9 @@ processChatCommand vr nm = \case LeaveGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APILeaveGroup groupId + AllowRelayGroup gName -> withUser $ \user -> do + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + processChatCommand vr nm $ APIAllowRelayGroup groupId DeleteGroup gName -> withUser $ \user -> do groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand vr nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) @@ -3627,7 +3647,7 @@ processChatCommand vr nm = \case localRelayLinks = mapMaybe memberRelayLink activeRelayMembers newRelayLinks = filter (`notElem` localRelayLinks) currentRelayLinks forM_ newRelayLinks $ \rlnk -> void . tryAllErrors $ - connectToRelay user gInfo rlnk + connectToRelayAsync user gInfo rlnk forM_ localRelayMembers $ \m -> case memberRelayLink m of -- Remove relay if its link is no longer in the current link data. @@ -3639,7 +3659,6 @@ processChatCommand vr nm = \case deleteMemberConnection m deleteOrUpdateMemberRecord user gInfo m _ -> pure () - prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do -- 0) toggle disabled - PQSupportOff @@ -3822,7 +3841,7 @@ processChatCommand vr nm = \case assertDeletable gInfo items assertUserGroupRole gInfo GRModerator let msgMemIds = itemsMsgMemIds gInfo items - events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId $ toMsgScope gInfo <$> chatScopeInfo) msgMemIds + events = L.nonEmpty $ map (\(msgId, memId) -> XMsgDel msgId memId (toMsgScope gInfo <$> chatScopeInfo) False) msgMemIds mapM_ (sendGroupMessages_ user gInfo ms) events delGroupChatItems user gInfo chatScopeInfo items True where @@ -5041,6 +5060,8 @@ chatCommandP = "/xftp" $> GetUserProtoServers (AProtocolType SPXFTP), "/_relay test " *> (APITestChatRelay <$> A.decimal <* A.space <*> strP), "/relay test " *> (TestChatRelay <$> strP), + "/_relay allow #" *> (APIAllowRelayGroup <$> A.decimal), + "/group allow #" *> (AllowRelayGroup <$> displayNameP), "/relays " *> (SetUserChatRelays <$> chatRelaysP), "/relays" $> GetUserChatRelays, "/_operators" $> APIGetServerOperators, diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 2d2504ee83..576eb942a5 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1058,6 +1058,28 @@ acceptRelayJoinRequestAsync ownerMember' <- getGroupMemberById db vr user groupMemberId pure (gInfo', ownerMember') +rejectRelayInvitationAsync + :: User + -> Int64 + -> VersionRangeChat + -> GroupRelayInvitation + -> InvitationId + -> VersionRangeChat + -> Int64 + -> RelayRejectionReason + -> CM () +rejectRelayInvitationAsync user uclId vr groupRelayInv invId reqChatVRange initialDelay reason = do + (_gInfo, ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId reqChatVRange initialDelay GSMemInvited RSRejected + let GroupMember {groupMemberId} = ownerMember + msg = XGrpRelayReject reason + subMode <- chatReadVar subscriptionMode + chatVR <- chatVersionRange + let chatV = chatVR `peerConnChatVersion` reqChatVRange + connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV + withStore' $ \db -> + createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode + businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences = GroupProfile {displayName, fullName, description = Nothing, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} @@ -1144,12 +1166,16 @@ memberIntroEvt gInfo reMember = -- This doesn't create introduction records in db, compared to above methods. introduceInChannel :: VersionRangeChat -> User -> GroupInfo -> GroupMember -> CM () introduceInChannel _ _ _ GroupMember {activeConn = Nothing} = throwChatError $ CEInternalError "member connection not active" -introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn} = do +introduceInChannel vr user gInfo subscriber@GroupMember {activeConn = Just conn, indexInGroup = subscriberIdx} = do modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo void $ sendGroupMessage' user gInfo modMs $ XGrpMemNew (memberInfo gInfo subscriber) Nothing + withStore' $ \db -> + setMemberVectorNewRelations db subscriber [(indexInGroup m, (IDSubjectIntroduced, MRIntroduced)) | m <- modMs] let introEvts = map (memberIntroEvt gInfo) modMs forM_ (L.nonEmpty introEvts) $ \introEvts' -> sendGroupMemberMessages user gInfo conn introEvts' + withStore' $ \db -> + setMembersVectorsNewRelation db modMs subscriberIdx IDSubjectIntroduced MRIntroduced userProfileInGroup :: User -> GroupInfo -> Maybe Profile -> Profile userProfileInGroup user = userProfileInGroup' user . groupFeatureUserAllowed SGFSimplexLinks @@ -1320,6 +1346,18 @@ setGroupLinkDataAsync user gInfo gLink = do let (userLinkData, crClientData) = groupLinkData gInfo gLink groupRelays setAgentConnShortLinkAsync user conn userLinkData (Just crClientData) +connectToRelayAsync :: User -> GroupInfo -> ShortLinkContact -> CM () +connectToRelayAsync user gInfo relayLink = do + vr <- chatVersionRange + gVar <- asks random + relayMember@GroupMember {activeConn} <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink + case activeConn of + Just _ -> pure () + Nothing -> do + subMode <- chatReadVar subscriptionMode + newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink + withFastStore' $ \db -> createRelayMemberConnectionAsync db user gInfo relayMember relayLink newConnIds subMode + updatePublicGroupData :: User -> GroupInfo -> CM GroupInfo updatePublicGroupData user gInfo | useRelays' gInfo && memberRole' (membership gInfo) == GROwner = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 57dc737e2d..a661e53752 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,11 +28,13 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find) +import Data.List (find, foldl') import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import qualified Data.IntSet as IS import Data.Map.Strict (Map) import qualified Data.Map.Strict as M +import qualified Data.Set as S import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing, mapMaybe) import Data.Text (Text) import qualified Data.Text as T @@ -46,7 +48,7 @@ import Simplex.Chat.Controller import Simplex.Chat.Delivery import Simplex.Chat.Library.Internal import Simplex.Chat.Messages -import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, encodeBinaryBatch, encodeFwdElement) +import Simplex.Chat.Messages.Batch (batchDeliveryTasks1, batchProfiles, batchProfilesWithBody, encodeBinaryBatch, encodeFwdElement, maxBatchElementSize) import Simplex.Chat.Messages.CIContent import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.ProfileGenerator (generateRandomProfile) @@ -520,7 +522,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent _ ttl live _msgScope _ -> messageUpdate ct'' sharedMsgId mContent msg msgMeta ttl live - XMsgDel sharedMsgId _ _ -> messageDelete ct'' sharedMsgId msg msgMeta + XMsgDel sharedMsgId _ _ _ -> messageDelete ct'' sharedMsgId msg msgMeta XMsgReact sharedMsgId _ _ reaction add -> directMsgReaction ct'' sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile XFile fInv -> processFileInvitation' ct'' fInv msg msgMeta @@ -777,6 +779,21 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' $ \db -> setRelayLinkConfId db m confId relayLink void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink | otherwise -> messageError "x.grp.relay.acpt: only owner can add relay" + XGrpRelayReject reason + | memberRole' membership == GROwner && isRelay m -> do + -- GSMemLeft (not GSMemRejected): owner UI treats this identically to an explicit /leave from the relay; GSMemRejected has knocking-admission semantics. + (relay', m') <- withStore $ \db -> do + relay <- getGroupRelayByGMId db (groupMemberId' m) + relay' <- if relayStatus relay == RSInvited + then liftIO $ updateRelayStatusFromTo db relay RSInvited RSRejected + else pure relay + liftIO $ updateGroupMemberStatus db userId m GSMemLeft + pure (relay', m {memberStatus = GSMemLeft}) + -- complete the contact handshake so the relay receives INFO and cleans up its transient bookkeeping + allowAgentConnectionAsync user conn' confId XOk + toView $ CEvtGroupRelayUpdated user gInfo m' relay' + toViewTE $ TERelayRejected user gInfo reason + | otherwise -> messageError "x.grp.relay.reject: only owner should receive relay rejection" _ -> messageError "CONF from invited member must have x.grp.acpt" GCHostMember -> case chatMsgEvent of @@ -824,10 +841,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = when (memberStatus m == GSMemRejected) $ do deleteMemberConnection' m True withStore' $ \db -> deleteGroupMember db user m - XOk -> pure () + XOk -> + -- transient relay-reject row cleanup after the rejection handshake completes + when (memberCategory m == GCHostMember && not (relayServesGroup gInfo)) $ do + deleteMemberConnection' m True + withStore' $ \db -> do + deleteGroupMember db user m + deleteGroup db user gInfo _ -> messageError "INFO from member must have x.grp.mem.info, x.info or x.ok" pure () - CON _pqEnc -> unless (memberStatus m == GSMemRejected || memberStatus membership == GSMemRejected) $ do + CON _pqEnc -> unless rejected $ do -- TODO [knocking] send pending messages after accepting? -- possible improvement: check for each pending message, requires keeping track of connection state unless (connDisabled conn) $ sendPendingGroupMessages user gInfo m conn @@ -929,6 +952,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ (memberConn im) $ \imConn -> void $ sendDirectMemberMessage imConn (XGrpMemCon memberId) groupId _ -> messageWarning "sendXGrpMemCon: member category GCPreMember or GCPostMember is expected" + where + rejected = + memberStatus m `elem` ([GSMemRejected, GSMemLeft, GSMemRemoved, GSMemGroupDeleted] :: [GroupMemberStatus]) + || memberStatus membership == GSMemRejected + || not (relayServesGroup gInfo) MSG msgMeta _msgFlags msgBody -> do tags <- newTVarIO [] withAckMessage "group msg" agentConnId msgMeta True (Just tags) $ \eInfo -> do @@ -940,7 +968,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m) then let tasks - | relayOwnStatus gInfo' == Just RSInactive = filter relayRemovedNewTask newDeliveryTasks + | not (relayServesGroup gInfo') = filter relayRemovedNewTask newDeliveryTasks | otherwise = newDeliveryTasks in createDeliveryTasks gInfo' m' tasks else pure False @@ -1006,7 +1034,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = checkSendAsGroup asGroup_ $ memberCanSend (Just m'') msgScope $ groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ - XMsgDel sharedMsgId memberId_ scope_ -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ msg brokerTs + XMsgDel sharedMsgId memberId_ scope_ onlyHistory -> + groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ onlyHistory msg brokerTs XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs @@ -1015,6 +1044,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XInfo p -> fmap ctx <$> xInfoMember gInfo' m'' p msg brokerTs XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs + XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_ XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv @@ -1309,23 +1339,38 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = CFSetShortLink -> case (ucGroupId_, auData) of (Just groupId, UserContactLinkData UserContactData {relays = relayLinks}) -> do - (gInfo, gLink, relays, relaysChanged) <- withStore $ \db -> do + (gInfo, gLink, relays, relaysChanged, newlyActiveLinks) <- withStore $ \db -> do gInfo <- getGroupInfo db vr user groupId gLink <- getGroupLink db user gInfo relays <- liftIO $ getGroupRelays db gInfo - (relays', changed) <- liftIO $ foldrM (updateRelay db) ([], False) relays + (relays', changed, newlyActive) <- liftIO $ foldrM (updateRelay db) ([], False, []) relays liftIO $ setGroupInProgressDone db gInfo - pure (gInfo, gLink, relays', changed) + pure (gInfo, gLink, relays', changed, newlyActive) toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged + let GroupSummary {publicMemberCount} = groupSummary gInfo + -- Owner is counted in publicMemberCount; > 1 means at least one subscriber. + -- TODO [relays] multi-owner: with N owners, threshold should be > N (or use a + -- dedicated subscriber count). + when (fromMaybe 0 publicMemberCount > 1) $ + forM_ (L.nonEmpty newlyActiveLinks) $ \newlyActive -> do + allRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo + let recipients = + filter + (\GroupMember {memberStatus, relayLink} -> + memberStatus == GSMemConnected && relayLink `notElem` map Just newlyActiveLinks) + allRelayMembers + events = XGrpRelayNew <$> newlyActive + unless (null recipients) $ + void $ sendGroupMessages user gInfo Nothing False recipients events where - updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool) -> IO ([GroupRelay], Bool) - updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) = + updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool, [ShortLinkContact]) -> IO ([GroupRelay], Bool, [ShortLinkContact]) + updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed, newlyActive) = case relayLink of Just rLink | rLink `elem` relayLinks && relayStatus == RSAccepted -> do relay' <- updateRelayStatus db relay RSActive - pure (relay' : acc, True) - | rLink `elem` relayLinks -> pure (relay : acc, changed) + pure (relay' : acc, True, rLink : newlyActive) + | rLink `elem` relayLinks -> pure (relay : acc, changed, newlyActive) | relayStatus == RSActive -> do -- Relay link absent from link data — deactivate. -- RSAccepted relays are not deactivated: their own link data update @@ -1334,8 +1379,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- TODO the SMP server, but this owner won't receive a LINK callback for it -- TODO (LINK only fires in response to own setConnShortLink calls). relay' <- updateRelayStatus db relay RSInactive - pure (relay' : acc, True) - _ -> pure (relay : acc, changed) + pure (relay' : acc, True, newlyActive) + _ -> pure (relay : acc, changed, newlyActive) _ -> throwChatError $ CECommandError "LINK event expected for a group link only" _ -> throwChatError $ CECommandError "unexpected cmdFunction" MERR _ err -> do @@ -1512,10 +1557,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = mem <- acceptGroupJoinSendRejectAsync user uclId gInfo invId chatVRange p xContactId_ rjctReason toViewTE $ TERejectingGroupJoinRequestMember user gInfo mem rjctReason xGrpRelayInv :: InvitationId -> VersionRangeChat -> GroupRelayInvitation -> CM () - xGrpRelayInv invId chatVRange groupRelayInv = do + xGrpRelayInv invId chatVRange groupRelayInv@GroupRelayInvitation {groupLink} = do + rejected <- withStore' $ \db -> isRelayGroupRejected db user groupLink initialDelay <- asks $ initialInterval . relayRequestRetryInterval . config - (_gInfo, _ownerMember) <- withStore $ \db -> createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay - lift $ void $ getRelayRequestWorker True + if rejected + then rejectRelayInvitationAsync user uclId vr groupRelayInv invId chatVRange initialDelay RRRRejoinRejected + else do + (_gInfo, _ownerMember) <- withStore $ \db -> + createRelayRequestGroup db vr user groupRelayInv invId chatVRange initialDelay GSMemAccepted RSInvited + lift $ void $ getRelayRequestWorker True xGrpRelayTest :: InvitationId -> VersionRangeChat -> ByteString -> CM () xGrpRelayTest invId chatVRange challenge = do privKey_ <- withAgent $ \a -> getConnLinkPrivKey a (aConnId conn) @@ -2162,26 +2212,30 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtChatItemNotChanged user (AChatItem SCTGroup SMDRcv (GroupChat gInfo scopeInfo) ci) pure Nothing - groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) - groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ rcvMsg brokerTs = + groupMessageDelete :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMessageDelete gInfo@GroupInfo {membership} m_ sharedMsgId sndMemberId_ scope_ onlyHistory rcvMsg brokerTs = findItem >>= \case Right cci@(CChatItem _ ci@ChatItem {chatDir}) -> case (chatDir, m_) of (CIGroupRcv mem, Just m@GroupMember {memberId}) -> let msgMemberId = fromMaybe memberId sndMemberId_ + isAuthor = sameMemberId memberId mem in case sndMemberId_ of -- regular deletion Nothing - | sameMemberId memberId mem && rcvItemDeletable ci brokerTs -> + | isAuthor && onlyHistory && publicGroupEditor gInfo m -> + delete cci False Nothing $> Nothing + | isAuthor && not onlyHistory && rcvItemDeletable ci brokerTs -> delete cci False Nothing | otherwise -> messageError "x.msg.del: member attempted invalid message delete" $> Nothing -- moderation (not limited by time) Just _ - | sameMemberId memberId mem && msgMemberId == memberId -> + | isAuthor && msgMemberId == memberId -> delete cci False (Just m) | otherwise -> moderate m mem cci (CIChannelRcv, _) - | isNothing sndMemberId_ && isOwner -> delete cci True Nothing + | isNothing sndMemberId_ && isOwner -> + (if onlyHistory then ($> Nothing) else id) $ delete cci True Nothing | otherwise -> messageError "x.msg.del: invalid channel message delete" $> Nothing (CIGroupSnd, Just m) -> moderate m membership cci _ -> messageError "x.msg.del: invalid message deletion" $> Nothing @@ -2900,7 +2954,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpMemNew :: GroupInfo -> GroupMember -> MemberInfo -> Maybe MsgScope -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope) xGrpMemNew gInfo m memInfo@(MemberInfo memId memRole _ _ _) msgScope_ msg brokerTs = do - unless (useRelays' gInfo && isRelay m) $ checkHostRole m memRole + if useRelays' gInfo && isRelay m + then when (memRole > GRMember) $ throwChatError $ CEException "x.grp.mem.new: relay cannot introduce role above member in channel" + else checkHostRole m memRole if sameMemberId memId (membership gInfo) then pure Nothing else do @@ -3118,7 +3174,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False withStore' $ \db -> do updateGroupMemberStatus db userId membership GSMemRemoved - when (isJust $ relayOwnStatus gInfo) $ updateRelayOwnStatus_ db gInfo RSInactive + when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive let membership' = membership {memberStatus = GSMemRemoved} when withMessages $ deleteMessages gInfo membership' SMDSnd deleteMemberItem msg gInfo RGEUserDeleted @@ -3252,6 +3308,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let cd = CDGroupRcv g'' scopeInfo m' createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' + xGrpRelayNew :: GroupInfo -> GroupMember -> ShortLinkContact -> CM (Maybe DeliveryJobScope) + xGrpRelayNew gInfo GroupMember {memberRole} rl + | memberRole < GROwner = messageError "x.grp.relay.new with insufficient member permissions" $> Nothing + | otherwise = do + unless (isUserGrpFwdRelay gInfo) $ connectToRelayAsync user gInfo rl + pure $ Just DJSGroup {jobSpec = DJDeliveryJob {includePending = False}} + xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g@GroupInfo {groupId, groupProfile = gp} m mConn@Connection {connId = mConnId} connReq mContent_ msg brokerTs | not (groupFeatureMemberAllowed SGFDirectMessages m g) = messageError "x.grp.direct.inv: direct messages not allowed" @@ -3369,10 +3432,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMsgFileDescr sharedMsgId fileDescr -> void $ groupMessageFileDescription gInfo author_ sharedMsgId fileDescr XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ - XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs + XMsgDel sharedMsgId memId scope_ _ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ False rcvMsg msgTs XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs + XGrpRelayNew rl -> withAuthor XGrpRelayNew_ $ \author -> void $ xGrpRelayNew gInfo author rl XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs XGrpMemRole memId memRole -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole gInfo author memId memRole rcvMsg msgTs XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs @@ -3549,22 +3613,18 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do processDeliveryTask task@MessageDeliveryTask {jobScope} = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | not (relayServesGroup gInfo) -> do logWarn "delivery task worker: relay inactive" withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive" | otherwise -> withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do - let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + let (body, acceptedTasks, largeTasks) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body - forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed - forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed + forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" lift . void $ getDeliveryJobWorker True deliveryKey - where - singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId - singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) - | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' - | otherwise = Nothing -- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion DJRelayRemoved | workerScope /= DWSGroup -> @@ -3574,7 +3634,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + createMsgDeliveryJob db gInfo jobScope [senderGMId] body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -3593,6 +3653,27 @@ getDeliveryJobWorker hasWork deliveryKey = do getAgentWorker "delivery_job" hasWork a deliveryKey ws $ runDeliveryJobWorker a deliveryKey +-- TODO [relays] dissemination here is unsigned (relay-asserted profile). +-- Future: members sign an XMember on channel join, relay stores it per +-- member and forwards the signed XMember via this sidecar — enables +-- subscribers to verify member profiles out-of-band without trusting the relay. + +-- | Encode an XGrpMemNew for first-introduction dissemination as a direct +-- (non-forwarded) batch element. 'Left' when the encoded element wouldn't +-- fit a singleton batch (see 'maxBatchElementSize'). +encodeMemberNew :: VersionRangeChat -> GroupInfo -> GroupMember -> Either ChatError ByteString +encodeMemberNew vr gInfo member = case encodeChatMessage maxBatchElementSize chatMsg of + ECMEncoded bs -> Right bs + ECMLarge -> Left $ ChatError $ CEException $ "large profile element for member " <> show (groupMemberId' member) + where + chatMsg :: ChatMessage 'Json + chatMsg = + ChatMessage + { chatVRange = vr, + msgId = Nothing, + chatMsgEvent = XGrpMemNew (memberInfo gInfo member) Nothing + } + runDeliveryJobWorker :: AgentClient -> DeliveryWorkerKey -> Worker -> CM () runDeliveryJobWorker a deliveryKey Worker {doWork} = do delay <- asks $ deliveryWorkerDelay . config @@ -3619,7 +3700,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do processDeliveryJob job = case jobScopeImpliedSpec jobScope of DJDeliveryJob _includePending - | relayOwnStatus gInfo == Just RSInactive -> do + | not (relayServesGroup gInfo) -> do logWarn "delivery job worker: relay inactive" withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) "relay inactive" | otherwise -> do @@ -3634,7 +3715,10 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + singleSenderGMId_ = case senderGMIds of + [s] -> Just s + _ -> Nothing sendBodyToMembers :: CM () sendBodyToMembers -- channel @@ -3642,16 +3726,88 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- there's no member review in channels, so job spec includePending is ignored DJSGroup {} -> do bucketSize <- asks $ deliveryBucketSize . config - sendLoop bucketSize startingCursor + senders <- withStore' $ \db -> + fmap catMaybes . forM senderGMIds $ \sId -> + fmap eitherToMaybe . runExceptT $ do + sender <- getGroupMemberById db vr user sId + vec <- getMemberRelationsVector db sender + pure (sender, vec) + let missingSenders = length senderGMIds - length senders + when (missingSenders > 0) $ + logInfo $ "delivery job " <> tshow jobId <> ": " <> tshow missingSenders <> " senders missing; skipping their profile prepend" + -- Small profiles ride inline (extBody); the rest spill + -- into standalone batches that ship before the body. + (extBody, inBodySenders, overflowBatches, activeSenders) <- + if null senders + then pure (body, [], [], []) + else do + -- Skip role > GRMember (mirrors xGrpMemNew gate). + -- TODO [relays] public groups: revisit if mods/admins are introduced via this sidecar. + let (encoderErrs, validLabeled) = + partitionEithers + [ (\bs -> (s, bs)) <$> encodeMemberNew vr gInfo s + | (s, _) <- senders, memberRole' s <= GRMember + ] + (extBody', inBody, overflowLabeled, large1) = batchProfilesWithBody maxEncodedMsgLength body validLabeled + (overflowBatches', large2) = batchProfiles maxEncodedMsgLength overflowLabeled + packerErrs = [ChatError (CEInternalError $ "oversized profile element for member " <> show (groupMemberId' s)) | s <- large1 <> large2] + allErrs = encoderErrs <> packerErrs + unless (null allErrs) $ do + logInfo $ "delivery job " <> tshow jobId <> ": dropping " <> tshow (length allErrs) <> " oversized profile element(s)" + toView $ CEvtChatErrors allErrs + let active = inBody <> concatMap snd overflowBatches' + pure (extBody', inBody, overflowBatches', active) + -- Per-job constants — independent of the cursor page in sendLoop. + let senderVec = M.fromList [(groupMemberId' s, v) | (s, v) <- senders] + -- Body IDs: 0 = plain body, 1 = extBody, 2.. = overflow batches in order. + overflowWithIds = zip [2 :: Int ..] overflowBatches + sendLoop bucketSize startingCursor senderVec overflowWithIds inBodySenders extBody activeSenders where - sendLoop :: Int -> Maybe GroupMemberId -> CM () - sendLoop bucketSize cursorGMId_ = do + sendLoop :: Int -> Maybe GroupMemberId -> Map GroupMemberId ByteString -> [(Int, (ByteString, [GroupMember]))] -> [GroupMember] -> ByteString -> [GroupMember] -> CM () + sendLoop bucketSize cursorGMId_ senderVec overflowWithIds inBodySenders extBody activeSenders = do mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize unless (null mems) $ do - deliver body mems + let msgReqs = buildMsgReqs mems + unless (null msgReqs) $ void $ withAgent (`sendMessages` msgReqs) + -- Mark only (sender, recipient) pairs where the bit was MRNew — + -- skip recipients already MRIntroduced (steady-case savings). + let readyMems = [m | m <- mems, isJust (readyMemberConn m)] + markFor sender = do + vec <- M.lookup (groupMemberId' sender) senderVec + let ms = [(indexInGroup r, (IDSubjectIntroduced, MRIntroduced)) | r <- readyMems, getRelation (indexInGroup r) vec == MRNew] + if null ms then Nothing else Just (sender, ms) + senderMarks = mapMaybe markFor activeSenders + unless (null senderMarks) $ + withStore' $ \db -> + forM_ senderMarks $ \(sender, ms) -> + setMemberVectorNewRelations db sender ms let cursorGMId' = groupMemberId' $ last mems withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' - unless (length mems < bucketSize) $ sendLoop bucketSize (Just cursorGMId') + unless (length mems < bucketSize) $ + sendLoop bucketSize (Just cursorGMId') senderVec overflowWithIds inBodySenders extBody activeSenders + where + -- First recipient needing body i carries VRValue (Just i); rest use VRRef i. + -- First piece per connection: aConnId; rest: empty (agent convention). + buildMsgReqs :: [GroupMember] -> [MsgReq] + buildMsgReqs mems = reverse . snd $ foldl' addRecipient (IS.empty, []) mems + where + addRecipient acc r = case readyMemberConn r of + Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) + Nothing -> acc + addPiece conn (k, (issued, reqs)) (bid, msgBody) = + let vor + | IS.member bid issued = VRRef bid + | otherwise = VRValue (Just bid) msgBody + issued' = IS.insert bid issued + connId = if k == 0 then aConnId conn else B.empty + in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + recipientBodyPieces r = + [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, extBody) else (0, body)] + where + missing s = case M.lookup (groupMemberId' s) senderVec of + Just vec -> getRelation (indexInGroup r) vec == MRNew + Nothing -> True DJSMemberSupport scopeGMId -> do -- for member support scope we just load all recipients in one go, without cursor modMs <- withStore' $ \db -> getGroupModerators db vr user gInfo @@ -3670,8 +3826,8 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do -- fully connected group | otherwise = case singleSenderGMId_ of Nothing -> throwChatError $ CEInternalError "delivery job worker: singleSenderGMId is required when not using relays" - Just singleSenderGMId -> do - sender <- withStore $ \db -> getGroupMemberById db vr user singleSenderGMId + Just sId -> do + sender <- withStore $ \db -> getGroupMemberById db vr user sId ms <- buildMemberList sender unless (null ms) $ deliver body ms where diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 86ba441a57..9325de41eb 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -302,6 +302,8 @@ markdownP = mconcat <$> A.many' fragmentP isPunctuation' = \case '/' -> False ')' -> False + '_' -> False + '!' -> False c -> isPunctuation c isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"] -- matches what is likely to be a domain, not all valid domain names @@ -360,7 +362,7 @@ parseUri s = case U.parseURI U.laxURIParserOptions s of sanitizeUri :: Bool -> U.URI -> Maybe U.URI sanitizeUri safe uri@U.URI {uriAuthority, uriPath, uriQuery = U.Query originalQS} = let sanitizedQS - | safe = filter (not . isSafeBlacklisted . fst) originalQS + | safe = filter (\(n, _) -> isWhitelisted n || not (isSafeBlacklisted n)) originalQS | isNamePath = case originalQS of p@(n, _) : ps -> (if isWhitelisted n || not (isBlacklisted n) then (p :) else id) $ filter (isWhitelisted . fst) ps [] -> [] diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index a9e835a83e..ed65bd4af7 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -13,20 +13,29 @@ module Simplex.Chat.Messages.Batch encodeBinaryBatch, batchMessages, batchDeliveryTasks1, + batchProfilesWithBody, + batchProfiles, + maxBatchElementSize, ) where import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString as BS import qualified Data.ByteString.Char8 as B -import Data.Int (Int64) -import Data.List (foldl') +import Data.Char (ord) +import Data.Function (on) +import Data.Foldable (foldr') +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L +import Data.Ord (Down (..)) +import Data.Word (Word8) import Simplex.Chat.Controller (ChatError (..), ChatErrorType (..)) import Simplex.Chat.Delivery import Simplex.Chat.Messages import Simplex.Chat.Protocol -import Simplex.Chat.Types (VersionRangeChat) +import Data.Maybe (isJust) +import Simplex.Chat.Types (GroupMember (..), LocalProfile (..), VersionRangeChat) import Simplex.Messaging.Encoding (Large (..), smpEncode, smpEncodeList) data BatchMode = BMJson | BMBinary @@ -70,29 +79,29 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) let encoded = encodeBatch mode bodies in Right (MsgBatch encoded msgs) : batches --- | Batches delivery tasks into (batch, [taskIds], [largeTaskIds]). +-- | Batches delivery tasks into (batch, accepted, large). -- Always uses binary batch format for relay groups. -batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [Int64], [Int64]) +batchDeliveryTasks1 :: VersionRangeChat -> Int -> NonEmpty MessageDeliveryTask -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) . L.toList where - addToBatch :: ([ByteString], [Int64], [Int64], Int, Int) -> MessageDeliveryTask -> ([ByteString], [Int64], [Int64], Int, Int) - addToBatch (msgBodies, taskIds, largeTaskIds, len, n) task - -- too large: skip, record taskId in largeTaskIds - | msgLen > maxLen = (msgBodies, taskIds, taskId : largeTaskIds, len, n) + addToBatch :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> MessageDeliveryTask -> ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) + addToBatch (msgBodies, accepted, large, len, n) task + -- too large: skip, record in large + | msgLen > maxLen = (msgBodies, accepted, task : large, len, n) -- fits: include in batch -- batch overhead: '=' + count (2) + 2-byte length prefix per element - | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, taskId : taskIds, largeTaskIds, len', n + 1) + | len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages - | otherwise = (msgBodies, taskIds, largeTaskIds, len, n) + | otherwise = (msgBodies, accepted, large, len, n) where - MessageDeliveryTask {taskId, fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task + MessageDeliveryTask {fwdSender, brokerTs = fwdBrokerTs, verifiedMsg} = task msgBody = encodeFwdElement GrpMsgForward {fwdSender, fwdBrokerTs} verifiedMsg msgLen = B.length msgBody len' = len + msgLen - toResult :: ([ByteString], [Int64], [Int64], Int, Int) -> (ByteString, [Int64], [Int64]) - toResult (msgBodies, taskIds, largeTaskIds, _, _) = + toResult :: ([ByteString], [MessageDeliveryTask], [MessageDeliveryTask], Int, Int) -> (ByteString, [MessageDeliveryTask], [MessageDeliveryTask]) + toResult (msgBodies, accepted, large, _, _) = let encoded = encodeBinaryBatch (reverse msgBodies) - in (encoded, reverse taskIds, reverse largeTaskIds) + in (encoded, reverse accepted, reverse large) -- | Encode a batch element for relay groups: >[/]. encodeFwdElement :: GrpMsgForward -> VerifiedMsg 'Json -> ByteString @@ -118,3 +127,83 @@ batchLen _ _ 0 = 0 batchLen _ len 1 = len batchLen BMJson len n = len + n + 1 -- (n - 1) commas + 2 brackets batchLen BMBinary len n = len + n * 2 + 2 -- 2-byte length prefix per element + '=' + count + +-- | Largest element that fits a singleton 'encodeBinaryBatch' inside an +-- agent SMP message: '=' + count(1) + Word16 length prefix(2) = 4 bytes +-- of framing on top of the element. +maxBatchElementSize :: Int +maxBatchElementSize = maxEncodedMsgLength - 4 + +-- | Sort key for the profile packers. No-image profiles are processed +-- first so they pack densely; image-bearing profiles take any remaining +-- space or spill to overflow. +hasImage :: GroupMember -> Bool +hasImage GroupMember {memberProfile = LocalProfile {image}} = isJust image + +-- | Greedy-pack profile elements with 'body' (no-image members first) +-- while the result fits 'maxLen'. Returns (extBody, accepted, overflow, +-- large): the senders whose profile is now inline, the labeled elements +-- that did not fit, and the senders whose element doesn't fit even a +-- singleton batch (must be dropped — equivalent to 'batchMessages' +-- 'errLarge'). +-- +-- Precondition on 'body': must be either 'B.empty' or output of +-- 'encodeBinaryBatch' — the function reads byte 1 as the existing +-- element count and drops bytes 0-1 before reassembly. Passing +-- arbitrary bytes produces malformed output. +batchProfilesWithBody :: Int -> ByteString -> [(GroupMember, ByteString)] -> (ByteString, [GroupMember], [(GroupMember, ByteString)], [GroupMember]) +batchProfilesWithBody maxLen body labeled = + let (_, _, acceptedPairs, overflow, large) = + foldl' step initState (sortBy (compare `on` (hasImage . fst)) labeled) + in (buildBody acceptedPairs, map fst acceptedPairs, overflow, large) + where + initEmpty = B.null body + initLen = B.length body + initCount = if initEmpty then 0 else ord (B.index body 1) + -- (predicted total bytes, predicted count, accepted pairs, overflow, large) + initState = (initLen, initCount, [], [], []) + step (totalLen, count, acceptedPairs, overflow, large) (s, e) + | B.length e + 4 > maxLen = (totalLen, count, acceptedPairs, overflow, s : large) + | count >= 255 = full + | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) + | otherwise = full + where + full = (totalLen, count, acceptedPairs, (s, e) : overflow, large) + -- First element on an empty body costs '=' + count(1) + Word16(2) + element; + -- every subsequent element costs just Word16(2) + element. + candidateLen + | initEmpty && null acceptedPairs = 4 + B.length e + | otherwise = totalLen + 2 + B.length e + -- Assemble the final body once: existing tail (sans '=' + count) with + -- the accepted elements (each length-prefixed) inserted in front, and + -- a refreshed count byte. + buildBody [] = body + buildBody acceptedPairs = + let prefixedNew = B.concat [smpEncode (Large e) | (_, e) <- acceptedPairs] + newCount = initCount + length acceptedPairs + tail_ = if initEmpty then B.empty else B.drop 2 body + in B.concat [B.singleton '=', BS.singleton (fromIntegral newCount :: Word8), prefixedNew, tail_] + +-- | Pack labeled profile elements into one or more (batch, senders) +-- pairs, each bounded by 'maxLen', plus a list of senders whose element +-- doesn't fit even a singleton batch (must be dropped — equivalent to +-- 'batchMessages' 'errLarge'). No-image members first (matches +-- 'batchProfilesWithBody'). +batchProfiles :: Int -> [(GroupMember, ByteString)] -> ([(ByteString, [GroupMember])], [GroupMember]) +batchProfiles maxLen = + finish . foldr addToBatch ([], [], [], 0, 0, []) . sortBy (compare `on` (Down . hasImage . fst)) + where + addToBatch :: (GroupMember, ByteString) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) + addToBatch (s, e) acc@(batches, elems, members, len, n, large) + | B.length e + 4 > maxLen = (batches, elems, members, len, n, s : large) + -- batch overhead: '=' + count (2) + 2-byte length prefix per element + | n + 1 <= 255 && len + B.length e + (n + 1) * 2 + 2 <= maxLen = + (batches, e : elems, s : members, len + B.length e, n + 1, large) + -- doesn't fit current — flush and start new with this element alone + | otherwise = + (flush acc, [e], [s], B.length e, 1, large) + flush :: ([(ByteString, [GroupMember])], [ByteString], [GroupMember], Int, Int, [GroupMember]) -> [(ByteString, [GroupMember])] + flush (batches, _, _, _, 0, _) = batches + flush (batches, elems, members, _, _, _) = + (encodeBinaryBatch elems, members) : batches + finish acc@(_, _, _, _, _, large) = (flush acc, large) diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 2dc751d6bb..dcc34de6da 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -105,7 +105,7 @@ msgDirectionIntP = \case 1 -> Just MDSnd _ -> Nothing -data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark +data CIDeleteMode = CIDMBroadcast | CIDMInternal | CIDMInternalMark | CIDMHistory deriving (Show) instance StrEncoding CIDeleteMode where @@ -113,11 +113,13 @@ instance StrEncoding CIDeleteMode where CIDMBroadcast -> "broadcast" CIDMInternal -> "internal" CIDMInternalMark -> "internalMark" + CIDMHistory -> "history" strP = A.takeTill (== ' ') >>= \case "broadcast" -> pure CIDMBroadcast "internal" -> pure CIDMInternal "internalMark" -> pure CIDMInternalMark + "history" -> pure CIDMHistory _ -> fail "bad CIDeleteMode" instance ToJSON CIDeleteMode where @@ -132,6 +134,7 @@ ciDeleteModeToText = \case CIDMBroadcast -> "this item is deleted (broadcast)" CIDMInternal -> "this item is deleted (locally)" CIDMInternalMark -> "this item is deleted (locally)" + CIDMHistory -> "this item is deleted (from history)" -- This type is used both in API and in DB, so we use different JSON encodings for the database and for the API -- ! Nested sum types also have to use different encodings for database and API diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index b7e838c52b..902e919a7f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -423,7 +423,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XMsgNew :: MsgContainer -> ChatMsgEvent 'Json XMsgFileDescr :: {msgId :: SharedMsgId, fileDescr :: FileDescr} -> ChatMsgEvent 'Json XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, mentions :: Map MemberName MsgMention, ttl :: Maybe Int, live :: Maybe Bool, scope :: Maybe MsgScope, asGroup :: Maybe Bool} -> ChatMsgEvent 'Json - XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope} -> ChatMsgEvent 'Json + XMsgDel :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, onlyHistory :: Bool} -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, scope :: Maybe MsgScope, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json XFile :: FileInvitation -> ChatMsgEvent 'Json -- TODO discontinue @@ -443,6 +443,8 @@ data ChatMsgEvent (e :: MsgEncoding) where XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json + XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json + XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json @@ -492,6 +494,7 @@ isForwardedGroupMsg ev = case ev of XMsgReact {} -> True XFileCancel _ -> True XInfo _ -> True + XGrpRelayNew _ -> True XGrpMemNew {} -> True XGrpMemRole {} -> True XGrpMemRestrict {} -> True @@ -986,6 +989,8 @@ data CMEventTag (e :: MsgEncoding) where XGrpRelayInv_ :: CMEventTag 'Json XGrpRelayAcpt_ :: CMEventTag 'Json XGrpRelayTest_ :: CMEventTag 'Json + XGrpRelayNew_ :: CMEventTag 'Json + XGrpRelayReject_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json XGrpMemIntro_ :: CMEventTag 'Json XGrpMemInv_ :: CMEventTag 'Json @@ -1043,6 +1048,8 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XGrpRelayInv_ -> "x.grp.relay.inv" XGrpRelayAcpt_ -> "x.grp.relay.acpt" XGrpRelayTest_ -> "x.grp.relay.test" + XGrpRelayNew_ -> "x.grp.relay.new" + XGrpRelayReject_ -> "x.grp.relay.reject" XGrpMemNew_ -> "x.grp.mem.new" XGrpMemIntro_ -> "x.grp.mem.intro" XGrpMemInv_ -> "x.grp.mem.inv" @@ -1101,6 +1108,8 @@ instance StrEncoding ACMEventTag where "x.grp.relay.inv" -> XGrpRelayInv_ "x.grp.relay.acpt" -> XGrpRelayAcpt_ "x.grp.relay.test" -> XGrpRelayTest_ + "x.grp.relay.new" -> XGrpRelayNew_ + "x.grp.relay.reject" -> XGrpRelayReject_ "x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.intro" -> XGrpMemIntro_ "x.grp.mem.inv" -> XGrpMemInv_ @@ -1155,6 +1164,8 @@ toCMEventTag msg = case msg of XGrpRelayInv _ -> XGrpRelayInv_ XGrpRelayAcpt _ -> XGrpRelayAcpt_ XGrpRelayTest {} -> XGrpRelayTest_ + XGrpRelayNew _ -> XGrpRelayNew_ + XGrpRelayReject _ -> XGrpRelayReject_ XGrpMemNew {} -> XGrpMemNew_ XGrpMemIntro _ _ -> XGrpMemIntro_ XGrpMemInv _ _ -> XGrpMemInv_ @@ -1227,13 +1238,12 @@ requiresSignature = \case XGrpMemRole_ -> True XGrpMemRestrict_ -> True XGrpLeave_ -> True + XGrpRelayNew_ -> True XInfo_ -> True _ -> False --- TODO [relays] relay: vectors tracking which members received which other member profiles/keys. --- TODO - don't forward XGrpLeave/XInfo to members who haven't seen sender's profile/key. --- TODO - unverifiedAllowed is a temporary workaround postponing targeted event forwarding. - +-- TODO [relays] can be tightened — sender keys are now disseminated via +-- TODO prepended XGrpMemNew before forwarded XInfo/XGrpLeave reach the recipient. -- Allow signed but unverified XGrpLeave/XInfo between subscribers when sender's key is unknown. -- Owner keys are always known, so subscribers are required to verify from owners. -- Likewise, subscriber keys are always known to owners, so owners are required to verify from subscribers. @@ -1281,7 +1291,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do scope <- opt "scope" asGroup <- opt "asGroup" pure XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} - XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" + XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> (fromMaybe False <$> opt "onlyHistory") XMsgDeleted_ -> pure XMsgDeleted XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> opt "scope" <*> p "reaction" <*> p "add" XFile_ -> XFile <$> p "file" @@ -1311,6 +1321,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do B64UrlByteString challenge <- p "challenge" sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature" pure $ XGrpRelayTest challenge sig_ + XGrpRelayNew_ -> XGrpRelayNew <$> p "relayLink" + XGrpRelayReject_ -> XGrpRelayReject <$> p "reason" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" <*> opt "scope" XGrpMemIntro_ -> XGrpMemIntro <$> p "memberInfo" <*> opt "memberRestrictions" XGrpMemInv_ -> XGrpMemInv <$> p "memberId" <*> p "memberIntro" @@ -1358,7 +1370,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en _ -> JM.empty XMsgFileDescr msgId' fileDescr -> o ["msgId" .= msgId', "fileDescr" .= fileDescr] XMsgUpdate {msgId = msgId', content, mentions, ttl, live, scope, asGroup} -> o $ ("asGroup" .=? asGroup) $ ("ttl" .=? ttl) $ ("live" .=? live) $ ("scope" .=? scope) $ ("mentions" .=? nonEmptyMap mentions) ["msgId" .= msgId', "content" .= content] - XMsgDel msgId' memberId scope -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId'] + XMsgDel msgId' memberId scope onlyHistory -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) $ ("onlyHistory" .=? justTrue onlyHistory) ["msgId" .= msgId'] XMsgDeleted -> JM.empty XMsgReact msgId' memberId scope reaction add -> o $ ("memberId" .=? memberId) $ ("scope" .=? scope) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] XFile fileInv -> o ["file" .= fileInv] @@ -1380,6 +1392,8 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en XGrpRelayTest challenge sig_ -> o $ ("signature" .=? (B64UrlByteString <$> sig_)) ["challenge" .= B64UrlByteString challenge] + XGrpRelayNew relayLink -> o ["relayLink" .= relayLink] + XGrpRelayReject reason -> o ["reason" .= reason] XGrpMemNew memInfo scope -> o $ ("scope" .=? scope) ["memberInfo" .= memInfo] XGrpMemIntro memInfo memRestrictions -> o $ ("memberRestrictions" .=? memRestrictions) ["memberInfo" .= memInfo] XGrpMemInv memId memIntro -> o ["memberId" .= memId, "memberIntro" .= memIntro] diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..75345e5e86 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -34,6 +34,7 @@ import Data.ByteString.Char8 (ByteString) import Data.Int (Int64) import qualified Data.List.NonEmpty as L import Data.Text (Text) +import qualified Data.Text as T import Data.Time.Clock (UTCTime, getCurrentTime) import Simplex.Chat.Delivery import Simplex.Chat.Protocol hiding (Binary) @@ -45,6 +46,7 @@ import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding (smpDecode) import Simplex.Messaging.Util (eitherToMaybe, firstRow') +import Text.Read (readMaybe) #if defined(dbPostgres) import Database.PostgreSQL.Simple (In (..), Only (..), (:.) (..)) import Database.PostgreSQL.Simple.SqlQQ (sql) @@ -245,8 +247,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds body = do currentTs <- getCurrentTime DB.execute db @@ -254,12 +256,17 @@ createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo + -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. + senderColumn :: Maybe Text + senderColumn + | null senderGMIds = Nothing + | otherwise = Just $ T.intercalate "," $ map (T.pack . show) senderGMIds getPendingDeliveryJobScopes :: DB.Connection -> IO [DeliveryWorkerKey] getPendingDeliveryJobScopes db = @@ -272,7 +279,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -302,17 +309,26 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = - case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} - Nothing -> Left $ SEInvalidDeliveryJob jobId' + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow + -- NULL or empty string means []; otherwise the value must parse + -- as a comma-separated decimal Int64 list. An unparseable + -- segment surfaces as job error rather than silent degradation. + senderGMIds <- case senderGMIdsText_ of + Nothing -> Right [] + Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + parseSenderGMIds :: Text -> Maybe [GroupMemberId] + parseSenderGMIds t + | T.null t = Just [] + | otherwise = traverse (readMaybe . T.unpack) (T.splitOn "," t) markJobFailed :: Int64 -> IO () markJobFailed jobId = DB.execute db "UPDATE delivery_jobs SET failed = 1 where delivery_job_id = ?" (Only jobId) diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index cca929d950..22e9c89b79 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -95,6 +95,8 @@ module Simplex.Chat.Store.Groups createRelayRequestGroup, updateRelayOwnStatusFromTo, updateRelayOwnStatus_, + isRelayGroupRejected, + allowRelayGroup, getRelayServedGroups, getRelayInactiveGroups, createNewContactMemberAsync, @@ -1363,11 +1365,11 @@ createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {grou db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, fromInvitedBy userContactId IBUser, groupMemberId' membership) + ( (groupId, indexInGroup, MemberId memId, GRRelay, GCInviteeMember, GSMemInvited, Binary B.empty, fromInvitedBy userContactId IBUser, groupMemberId' membership) :. (userId, localDisplayName, memProfileId, currentTs, currentTs) ) liftIO $ insertedRowId db @@ -1381,7 +1383,12 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { maybeFirstRow (toContactMember vr user) $ DB.query db - (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ?") +#if defined(dbPostgres) + (groupMemberQuery <> " WHERE m.group_id = ? AND m.relay_link = ? AND is_current_member(m.member_status)") +#else + -- skips GSMemLeft historical rows so re-add allocates a fresh row instead of resurrecting + (groupMemberQuery <> " JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.relay_link = ? AND sp.current_member = 1") +#endif (groupId, relayLink) createRelayMember = do currentTs <- liftIO getCurrentTime @@ -1395,12 +1402,12 @@ getCreateRelayForMember db vr gVar user@User {userId, userContactId} GroupInfo { db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, fromInvitedBy userContactId IBUnknown) + ( (groupId, indexInGroup, memberId, GRRelay, GCHostMember, GSMemAccepted, Binary B.empty, fromInvitedBy userContactId IBUnknown) :. (userId, localDisplayName, profileId, currentTs, currentTs, relayLink) ) insertedRowId db @@ -1518,8 +1525,8 @@ setGroupInProgressDone db GroupInfo {groupId} = do "UPDATE groups SET creating_in_progress = 0, updated_at = ? WHERE group_id = ?" (currentTs, groupId) -createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> ExceptT StoreError IO (GroupInfo, GroupMember) -createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay = do +createRelayRequestGroup :: DB.Connection -> VersionRangeChat -> User -> GroupRelayInvitation -> InvitationId -> VersionRangeChat -> Int64 -> GroupMemberStatus -> RelayStatus -> ExceptT StoreError IO (GroupInfo, GroupMember) +createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMember, fromMemberProfile, relayMemberId, groupLink} invId reqChatVRange initialDelay memberStatus relayStatus = do currentTs <- liftIO getCurrentTime -- Create group with placeholder profile let Profile {displayName = fromMemberLDN} = fromMemberProfile @@ -1533,13 +1540,13 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe groupPreferences = Nothing, memberAdmission = Nothing } - (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just RSInvited) Nothing currentTs + (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing currentTs -- Store relay request data for recovery liftIO $ setRelayRequestData_ groupId currentTs ownerMemberId <- insertOwner_ currentTs groupId let relayMember = MemberIdRole relayMemberId GRRelay -- TODO [member keys] should relays use member keys? - _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember GSMemAccepted IBUnknown Nothing Nothing currentTs vr + _membership <- createContactMemberInv_ db user groupId (Just ownerMemberId) user relayMember GCUserMember memberStatus IBUnknown Nothing Nothing currentTs vr ownerMember <- getGroupMember db vr user groupId ownerMemberId g <- getGroupInfo db vr user groupId pure (g, ownerMember) @@ -1568,12 +1575,12 @@ createRelayRequestGroup db vr user@User {userId} GroupRelayInvitation {fromMembe db [sql| INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, GSMemAccepted) + ( (groupId, indexInGroup, memberId, memberRole, GCHostMember, memberStatus, Binary B.empty) :. (userId, localDisplayName, Nothing :: (Maybe Int64), profileId, currentTs, currentTs) :. (minV, maxV) ) @@ -1591,6 +1598,41 @@ updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId) +-- Flip every RSRejected row sharing the targeted group's relay_request_group_link +-- to RSInactive in one statement; returns the refreshed GroupInfo for the targeted groupId. +allowRelayGroup :: DB.Connection -> VersionRangeChat -> User -> GroupId -> ExceptT StoreError IO GroupInfo +allowRelayGroup db vr user@User {userId} groupId = do + currentTs <- liftIO getCurrentTime + liftIO $ + DB.execute + db + [sql| + UPDATE groups + SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? + WHERE user_id = ? + AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?) + AND relay_own_status = ? + |] + (RSInactive, currentTs, currentTs, userId, groupId, RSRejected) + getGroupInfo db vr user groupId + +isRelayGroupRejected :: DB.Connection -> User -> ShortLinkContact -> IO Bool +isRelayGroupRejected db User {userId} groupLink = + fromMaybe False <$> maybeFirstRow fromOnly ( + DB.query + db + [sql| + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + |] + (userId, groupLink, RSRejected) + ) + getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo] getRelayServedGroups db vr User {userId, userContactId} = do map (toGroupInfo vr userContactId []) @@ -1839,12 +1881,12 @@ updatePublicMemberCount db vr user GroupInfo {groupId} = do relayCount <- fromMaybe 0 <$> maybeFirstRow fromOnly (DB.query db - [sql| - SELECT COUNT(1) FROM group_members - WHERE group_id = ? AND member_role = ? - AND member_status IN (?,?,?,?,?,?,?) - |] - (groupId, GRRelay, GSMemIntroduced, GSMemIntroInvited, GSMemAccepted, GSMemAnnounced, GSMemConnected, GSMemComplete, GSMemCreator)) +#if defined(dbPostgres) + "SELECT COUNT(1) FROM group_members WHERE group_id = ? AND member_role = ? AND is_current_member(member_status)" +#else + "SELECT COUNT(1) FROM group_members m JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.member_role = ? AND sp.current_member = 1" +#endif + (groupId, GRRelay)) let publicCount = max 0 (totalCount - relayCount) :: Int64 currentTs <- getCurrentTime DB.execute db "UPDATE groups SET public_member_count = ?, updated_at = ? WHERE group_id = ?" (publicCount, currentTs, groupId) @@ -2120,7 +2162,7 @@ updateGroupMemberRole db User {userId} GroupMember {groupMemberId} memRole = setMemberVectorNewRelations :: DB.Connection -> GroupMember -> [(Int64, (IntroductionDirection, MemberRelation))] -> IO () setMemberVectorNewRelations db GroupMember {groupMemberId} relations = do - v_ <- maybeFirstRow fromOnly $ + v_ <- fmap join . maybeFirstRow fromOnly $ DB.query db ( "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" @@ -2184,7 +2226,7 @@ setMemberVectorRelationConnected db GroupMember {groupMemberId} GroupMember {ind getMemberRelationsVector :: DB.Connection -> GroupMember -> ExceptT StoreError IO ByteString getMemberRelationsVector db GroupMember {groupMemberId} = - ExceptT . firstRow fromOnly (SEGroupMemberNotFound groupMemberId) $ + ExceptT . firstRow (fromMaybe B.empty . fromOnly) (SEGroupMemberNotFound groupMemberId) $ DB.query db "SELECT member_relations_vector FROM group_members WHERE group_member_id = ?" diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 608e3637c6..10368e2e30 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -30,6 +30,8 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders import Simplex.Chat.Store.Postgres.Migrations.M20260520_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) @@ -60,7 +62,9 @@ schemaMigrations = ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), - ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at) + ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) ] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..217b56d2fa --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,21 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260514_relay_request_group_link_index :: Text +m20260514_relay_request_group_link_index = + [r| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Text +down_m20260514_relay_request_group_link_index = + [r| +DROP INDEX idx_groups_relay_request_group_link; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..d082587391 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,56 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.Postgres.Migrations.M20260515_delivery_job_senders where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260515_delivery_job_senders :: Text +m20260515_delivery_job_senders = + [r| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = single_sender_group_member_id::text +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Text +down_m20260515_delivery_job_senders = + [r| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND position(',' in sender_group_member_ids) = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = sender_group_member_ids::bigint + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN position(',' in sender_group_member_ids) > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE sender_group_member_ids::bigint + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index cfa77e2b51..35388141bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -635,14 +635,14 @@ CREATE TABLE test_chat_schema.delivery_jobs ( job_scope_spec_tag text, job_scope_include_pending smallint, job_scope_support_gm_id bigint, - single_sender_group_member_id bigint, body bytea, cursor_group_member_id bigint, job_status text NOT NULL, job_err_reason text, failed smallint DEFAULT 0, created_at timestamp with time zone DEFAULT now() NOT NULL, - updated_at timestamp with time zone DEFAULT now() NOT NULL + updated_at timestamp with time zone DEFAULT now() NOT NULL, + sender_group_member_ids text ); @@ -2204,10 +2204,6 @@ CREATE INDEX idx_delivery_jobs_next ON test_chat_schema.delivery_jobs USING btre -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON test_chat_schema.delivery_jobs USING btree (single_sender_group_member_id); - - - CREATE INDEX idx_delivery_tasks_created_at ON test_chat_schema.delivery_tasks USING btree (created_at); @@ -2360,6 +2356,10 @@ CREATE INDEX idx_groups_inv_queue_info ON test_chat_schema.groups USING btree (i +CREATE INDEX idx_groups_relay_request_group_link ON test_chat_schema.groups USING btree (user_id, relay_request_group_link) WHERE (relay_request_group_link IS NOT NULL); + + + CREATE INDEX idx_groups_summary_current_members_count ON test_chat_schema.groups USING btree (summary_current_members_count); @@ -2832,11 +2832,6 @@ ALTER TABLE ONLY test_chat_schema.delivery_jobs -ALTER TABLE ONLY test_chat_schema.delivery_jobs - ADD CONSTRAINT delivery_jobs_single_sender_group_member_id_fkey FOREIGN KEY (single_sender_group_member_id) REFERENCES test_chat_schema.group_members(group_member_id) ON DELETE CASCADE; - - - ALTER TABLE ONLY test_chat_schema.delivery_tasks ADD CONSTRAINT delivery_tasks_group_id_fkey FOREIGN KEY (group_id) REFERENCES test_chat_schema.groups(group_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index dab6cf4d7d..2674705181 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -153,6 +153,8 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at +import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index +import Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders import Simplex.Chat.Store.SQLite.Migrations.M20260520_client_services import Simplex.Messaging.Agent.Store.Shared (Migration (..)) @@ -307,6 +309,8 @@ schemaMigrations = ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), ("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries), ("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at), + ("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index), + ("20260515_delivery_job_senders", m20260515_delivery_job_senders, Just down_m20260515_delivery_job_senders), ("20260520_client_services", m20260520_client_services, Just down_m20260520_client_services) ] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs new file mode 100644 index 0000000000..ef2bc8ccd0 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260514_relay_request_group_link_index.hs @@ -0,0 +1,20 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260514_relay_request_group_link_index :: Query +m20260514_relay_request_group_link_index = + [sql| +CREATE INDEX idx_groups_relay_request_group_link + ON groups(user_id, relay_request_group_link) + WHERE relay_request_group_link IS NOT NULL; +|] + +down_m20260514_relay_request_group_link_index :: Query +down_m20260514_relay_request_group_link_index = + [sql| +DROP INDEX idx_groups_relay_request_group_link; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs new file mode 100644 index 0000000000..67a9ae31e8 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260515_delivery_job_senders.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- delivery_jobs.sender_group_member_ids: comma-separated decimal GroupMemberIds. +-- NULL means [] (sender-less jobs, e.g. DJRelayRemoved). One column carries +-- single- and multi-sender jobs uniformly; the per-job introduction bits live +-- in group_members.member_relations_vector (MRIntroduced). +module Simplex.Chat.Store.SQLite.Migrations.M20260515_delivery_job_senders where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260515_delivery_job_senders :: Query +m20260515_delivery_job_senders = + [sql| +DROP INDEX idx_delivery_jobs_single_sender_group_member_id; + +ALTER TABLE delivery_jobs ADD COLUMN sender_group_member_ids TEXT; + +UPDATE delivery_jobs +SET sender_group_member_ids = CAST(single_sender_group_member_id AS TEXT) +WHERE single_sender_group_member_id IS NOT NULL; + +ALTER TABLE delivery_jobs DROP COLUMN single_sender_group_member_id; +|] + +down_m20260515_delivery_job_senders :: Query +down_m20260515_delivery_job_senders = + [sql| +-- Pre-up the FK was ON DELETE CASCADE, so orphan delivery_jobs cannot +-- exist. After up the FK was dropped and orphans may accumulate. Drop +-- them here, matching pre-up semantics, before re-adding the FK column. +DELETE FROM delivery_jobs +WHERE sender_group_member_ids IS NOT NULL + AND length(sender_group_member_ids) > 0 + AND instr(sender_group_member_ids, ',') = 0 + AND NOT EXISTS ( + SELECT 1 FROM group_members + WHERE group_member_id = CAST(sender_group_member_ids AS INTEGER) + ); + +ALTER TABLE delivery_jobs ADD COLUMN single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; + +UPDATE delivery_jobs +SET single_sender_group_member_id = + CASE + WHEN sender_group_member_ids IS NULL THEN NULL + WHEN instr(sender_group_member_ids, ',') > 0 THEN NULL + WHEN length(sender_group_member_ids) = 0 THEN NULL + ELSE CAST(sender_group_member_ids AS INTEGER) + END; + +ALTER TABLE delivery_jobs DROP COLUMN sender_group_member_ids; + +CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs(single_sender_group_member_id); +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index fdcac9134a..8c12361396 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1271,6 +1271,10 @@ Query: UPDATE connections SET smp_agent_version = ? WHERE conn_id = ? Plan: SEARCH connections USING PRIMARY KEY (conn_id=?) +Query: UPDATE connections SET smp_agent_version = ?, pq_support = ?, enable_ntfs = ? WHERE conn_id = ? +Plan: +SEARCH connections USING PRIMARY KEY (conn_id=?) + Query: UPDATE messages SET msg_body = x'' WHERE conn_id = ? AND internal_id = ? Plan: SEARCH messages USING PRIMARY KEY (conn_id=? AND internal_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index ca52c58fd4..598ee920dd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -31,7 +31,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -84,7 +83,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -285,7 +283,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -320,7 +317,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -355,7 +351,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -522,49 +517,13 @@ SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, user_id, local_display_name, contact_id, contact_profile_id, created_at, updated_at, peer_chat_min_version, peer_chat_max_version) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) -SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) -SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) -SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) -SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) -SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) -SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) -SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) -SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) -SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) -SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) -SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) -SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) -SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) -SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) -SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) - -Query: - INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, - user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link - ) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) - -Plan: -SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -598,7 +557,40 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) +SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) +SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) +SEARCH received_probes USING COVERING INDEX idx_received_probes_group_member_id (group_member_id=?) +SEARCH sent_probe_hashes USING COVERING INDEX idx_sent_probe_hashes_group_member_id (group_member_id=?) +SEARCH sent_probes USING COVERING INDEX idx_sent_probes_group_member_id (group_member_id=?) +SEARCH group_snd_item_statuses USING COVERING INDEX idx_group_snd_item_statuses_group_member_id (group_member_id=?) +SEARCH chat_item_moderations USING COVERING INDEX idx_chat_item_moderations_moderator_member_id (moderator_member_id=?) +SEARCH chat_item_reactions USING COVERING INDEX idx_chat_item_reactions_group_member_id (group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_group_member_id (group_scope_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_item_deleted_by_group_member_id (item_deleted_by_group_member_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_member_id (group_member_id=?) +SEARCH pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id (group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_forwarded_by_group_member_id (forwarded_by_group_member_id=?) +SEARCH messages USING COVERING INDEX idx_messages_author_group_member_id (author_group_member_id=?) +SEARCH connections USING COVERING INDEX idx_connections_group_member_id (group_member_id=?) +SEARCH rcv_files USING COVERING INDEX idx_rcv_files_group_member_id (group_member_id=?) +SEARCH snd_files USING COVERING INDEX idx_snd_files_group_member_id (group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_to_group_member_id (to_group_member_id=?) +SEARCH group_member_intros USING COVERING INDEX idx_group_member_intros_re_group_member_id (re_group_member_id=?) +SEARCH group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id (invited_by_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_grp_direct_inv_from_group_member_id (grp_direct_inv_from_group_member_id=?) +SEARCH contacts USING COVERING INDEX idx_contacts_contact_group_member_id (contact_group_member_id=?) + +Query: + INSERT INTO group_members + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, + user_id, local_display_name, contact_profile_id, created_at, updated_at, relay_link + ) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: +SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -633,7 +625,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -685,7 +676,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + sender_group_member_ids, body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -1156,13 +1147,12 @@ SEARCH group_relays USING COVERING INDEX idx_group_relays_chat_relay_id (chat_re Query: INSERT INTO group_members - ( group_id, index_in_group, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, + ( group_id, index_in_group, member_id, member_role, member_category, member_status, member_relations_vector, invited_by, invited_by_group_member_id, user_id, local_display_name, contact_profile_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1198,7 +1188,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1405,14 +1394,6 @@ SEARCH users USING INTEGER PRIMARY KEY (rowid=?) INDEX 2 SEARCH users USING INDEX sqlite_autoindex_users_1 (contact_id=?) -Query: - SELECT COUNT(1) FROM group_members - WHERE group_id = ? AND member_role = ? - AND member_status IN (?,?,?,?,?,?,?) - -Plan: -SEARCH group_members USING INDEX idx_group_members_group_id_index_in_group (group_id=?) - Query: SELECT agent_conn_id FROM ( SELECT @@ -1837,7 +1818,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -1872,7 +1852,6 @@ Query: Plan: SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -3346,6 +3325,20 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SCAN groups +Query: + SELECT EXISTS ( + SELECT 1 FROM groups + WHERE user_id = ? + AND relay_request_group_link = ? + AND relay_own_status = ? + LIMIT 1 + ) + +Plan: +SCAN CONSTANT ROW +SCALAR SUBQUERY 1 +SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) + Query: SELECT agent_conn_id FROM connections @@ -3883,6 +3876,15 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) SEARCH cs USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + +Plan: +SCAN s +SEARCH r USING AUTOMATIC PARTIAL COVERING INDEX (local_display_name=?) + Query: SELECT smp_server_id, host, port, key_hash, basic_auth, preset, tested, enabled FROM protocol_servers @@ -4052,6 +4054,18 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? + WHERE user_id = ? + AND relay_request_group_link = (SELECT relay_request_group_link FROM groups WHERE group_id = ?) + AND relay_own_status = ? + +Plan: +SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) +SCALAR SUBQUERY 1 +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri = ?, via_group_link_uri_hash = ? @@ -4642,6 +4656,15 @@ Query: Plan: +Query: + INSERT INTO connections ( + user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, + group_member_id, via_short_link_contact, custom_user_profile_id, via_group_link, + created_at, updated_at, to_subscribe + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + +Plan: + Query: INSERT INTO connections ( user_id, agent_conn_id, conn_status, conn_type, contact_conn_initiated, @@ -4663,7 +4686,7 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at + sender_group_member_ids, body, job_status, created_at, updated_at ) VALUES (?,?,?,?,?,?,?,?,?,?) Plan: @@ -4927,6 +4950,16 @@ Query: Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE connections + SET via_contact_uri = ?, via_contact_uri_hash = ?, group_link_id = ?, + conn_chat_version = ?, pq_support = ?, pq_encryption = ?, + updated_at = ? + WHERE user_id = ? AND connection_id = ? + +Plan: +SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE connections_sync SET should_sync = 0, last_sync_ts = ? @@ -5413,6 +5446,26 @@ SCAN chat_items USING COVERING INDEX idx_chat_items_group_member_id SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN +Query: + SELECT + m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, + m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, + c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, + c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, + c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) + LEFT JOIN connections c ON c.group_member_id = m.group_member_id + JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.relay_link = ? AND sp.current_member = 1 +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH sp USING INDEX sqlite_autoindex_group_member_status_predicates_1 (member_status=?) +SEARCH p USING INTEGER PRIMARY KEY (rowid=?) +SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN + Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -5508,25 +5561,6 @@ SEARCH m USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id= SEARCH p USING INTEGER PRIMARY KEY (rowid=?) SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN -Query: - SELECT - m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences, - m.created_at, m.updated_at, - m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.group_member_id = m.group_member_id - WHERE m.group_id = ? AND m.relay_link = ? -Plan: -SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INDEX idx_connections_group_member_id (group_member_id=?) LEFT-JOIN - Query: SELECT m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, @@ -6273,7 +6307,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_id = ? Plan: SEARCH group_members USING COVERING INDEX idx_group_members_group_id (user_id=? AND group_id=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6303,7 +6336,6 @@ Query: DELETE FROM group_members WHERE user_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) SEARCH group_relays USING COVERING INDEX idx_group_relays_group_member_id (group_member_id=?) -SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_single_sender_group_member_id (single_sender_group_member_id=?) SEARCH delivery_jobs USING COVERING INDEX idx_delivery_jobs_job_scope_support_gm_id (job_scope_support_gm_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_sender_group_member_id (sender_group_member_id=?) SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_job_scope_support_gm_id (job_scope_support_gm_id=?) @@ -6582,6 +6614,10 @@ Query: SELECT 1 FROM settings WHERE user_id = ? LIMIT 1 Plan: SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) +Query: SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL +Plan: +SCAN groups + Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) @@ -6598,6 +6634,11 @@ Query: SELECT COUNT(1) FROM group_members WHERE member_role = 'owner' AND member Plan: SCAN group_members +Query: SELECT COUNT(1) FROM group_members m JOIN group_member_status_predicates sp ON m.member_status = sp.member_status WHERE m.group_id = ? AND m.member_role = ? AND sp.current_member = 1 +Plan: +SEARCH m USING INDEX idx_group_members_group_id_index_in_group (group_id=?) +SEARCH sp USING INDEX sqlite_autoindex_group_member_status_predicates_1 (member_status=?) + Query: SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0 Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) @@ -6792,6 +6833,10 @@ Query: SELECT group_id, conn_full_link_to_connect FROM groups WHERE user_id = ? Plan: SEARCH groups USING INDEX sqlite_autoindex_groups_2 (user_id=?) +Query: SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id +Plan: +SCAN groups + Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND contact_profile_id = ? AND group_member_id != ? LIMIT 1 Plan: SEARCH group_members USING INDEX idx_group_members_user_id (user_id=?) @@ -6836,6 +6881,10 @@ Query: SELECT member_status FROM group_members WHERE local_display_name = ? Plan: SCAN group_members +Query: SELECT member_status FROM group_members WHERE member_role = 'relay' +Plan: +SCAN group_members + Query: SELECT member_xcontact_id, member_welcome_shared_msg_id FROM group_members WHERE user_id = ? AND group_id = ? AND group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6856,6 +6905,10 @@ Query: SELECT relay_own_status FROM groups WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT relay_status FROM group_relays +Plan: +SCAN group_relays + Query: SELECT relay_status FROM group_relays WHERE group_relay_id = ? Plan: SEARCH group_relays USING INTEGER PRIMARY KEY (rowid=?) @@ -6952,6 +7005,10 @@ Query: UPDATE connections_sync SET should_sync = 1 WHERE connections_sync_id = 1 Plan: SEARCH connections_sync USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE contact_profiles SET image = ? WHERE display_name = ? +Plan: +SEARCH contact_profiles USING INDEX contact_profiles_index (display_name=?) + Query: UPDATE contact_requests SET business_group_id = ? WHERE contact_request_id = ? Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d2595f88ee..fb72eecfc0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -735,7 +735,6 @@ CREATE TABLE delivery_jobs( job_scope_spec_tag TEXT, job_scope_include_pending INTEGER, job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, - single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, body BLOB, cursor_group_member_id INTEGER, job_status TEXT NOT NULL, @@ -743,6 +742,8 @@ CREATE TABLE delivery_jobs( failed INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) + , + sender_group_member_ids TEXT ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, @@ -1219,9 +1220,6 @@ CREATE INDEX idx_delivery_jobs_group_id ON delivery_jobs(group_id); CREATE INDEX idx_delivery_jobs_job_scope_support_gm_id ON delivery_jobs( job_scope_support_gm_id ); -CREATE INDEX idx_delivery_jobs_single_sender_group_member_id ON delivery_jobs( - single_sender_group_member_id -); CREATE INDEX idx_delivery_jobs_next ON delivery_jobs( group_id, worker_scope, @@ -1296,6 +1294,12 @@ CREATE INDEX idx_chat_items_groups_item_viewed ON chat_items( item_viewed, item_ts ); +CREATE INDEX idx_groups_relay_request_group_link +ON groups( + user_id, + relay_request_group_link +) +WHERE relay_request_group_link IS NOT NULL; CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 1bd97e3029..145f3343f3 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -496,6 +496,15 @@ data GroupInfo = GroupInfo useRelays' :: GroupInfo -> Bool useRelays' GroupInfo {useRelays} = isTrue useRelays +relayServesGroup :: GroupInfo -> Bool +relayServesGroup GroupInfo {relayOwnStatus} = case relayOwnStatus of + Just RSInactive -> False + Just RSRejected -> False + _ -> True + +publicGroupEditor :: GroupInfo -> GroupMember -> Bool +publicGroupEditor gInfo mem = useRelays' gInfo && memberRole' mem >= GRModerator + groupId' :: GroupInfo -> GroupId groupId' GroupInfo {groupId} = groupId @@ -918,6 +927,26 @@ instance ToJSON GroupRejectionReason where toJSON = strToJSON toEncoding = strToJEncoding +data RelayRejectionReason + = RRRRejoinRejected + | RRRUnknown {text :: Text} + deriving (Eq, Show) + +instance StrEncoding RelayRejectionReason where + strEncode = \case + RRRRejoinRejected -> "rejoin_rejected" + RRRUnknown text -> encodeUtf8 text + strP = + "rejoin_rejected" $> RRRRejoinRejected + <|> RRRUnknown . safeDecodeUtf8 <$> A.takeByteString + +instance FromJSON RelayRejectionReason where + parseJSON = strParseJSON "RelayRejectionReason" + +instance ToJSON RelayRejectionReason where + toJSON = strToJSON + toEncoding = strToJEncoding + data MemberIdRole = MemberIdRole { memberId :: MemberId, memberRole :: GroupMemberRole diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs index e0630e2e42..c71f7ce37a 100644 --- a/src/Simplex/Chat/Types/Shared.hs +++ b/src/Simplex/Chat/Types/Shared.hs @@ -84,6 +84,7 @@ data RelayStatus | RSAccepted | RSActive | RSInactive + | RSRejected deriving (Eq, Show) relayStatusText :: RelayStatus -> Text @@ -93,6 +94,7 @@ relayStatusText = \case RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" instance TextEncoding RelayStatus where textEncode = \case @@ -101,12 +103,14 @@ instance TextEncoding RelayStatus where RSAccepted -> "accepted" RSActive -> "active" RSInactive -> "inactive" + RSRejected -> "rejected" textDecode = \case "new" -> Just RSNew "invited" -> Just RSInvited "accepted" -> Just RSAccepted "active" -> Just RSActive "inactive" -> Just RSInactive + "rejected" -> Just RSRejected _ -> Nothing instance FromField RelayStatus where fromField = fromTextField_ textDecode @@ -115,6 +119,7 @@ instance ToField RelayStatus where toField = toField . textEncode $(JQ.deriveJSON (enumJSON $ dropPrefix "RS") ''RelayStatus) + data MsgSigStatus = MSSVerified | MSSSignedNoKey deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index c786ac8b53..725642b6e3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -184,6 +184,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results + CRRelayGroupAllowed u g -> ttyUser u [ttyFullGroup g <> ": relay rejection cleared"] CRGroupMembers u g -> ttyUser u $ viewGroupMembers g CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] @@ -222,7 +223,14 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRUserDeletedMembers u g members wm signed -> case members of [m] -> ttyUser u [ttyGroup' g <> ": you removed " <> ttyMember m <> " from the group" <> withMessages wm <> signedStr signed] mems' -> ttyUser u [ttyGroup' g <> ": you removed " <> sShow (length mems') <> " members from the group" <> withMessages wm <> signedStr signed] - CRLeftMemberUser u g -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g + CRLeftMemberUser u g + | relayOwnStatus g == Just RSRejected -> + ttyUser u + [ ttyGroup' g <> ": you left the group (future invitations will be rejected)", + "use " <> highlight ("/group allow #" <> viewGroupName g) <> " to allow future invitations", + "use " <> highlight ("/d #" <> viewGroupName g) <> " to delete the group (also clears the rejection)" + ] + | otherwise -> ttyUser u $ [ttyGroup' g <> ": you left the group"] <> groupPreserved g CRGroupDeletedUser u g signed -> ttyUser u [ttyGroup' g <> ": you deleted the group" <> signedStr signed] CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc CRChatMsgContent u mc -> ttyUser u $ ttyMsgContent mc <> viewMsgTestInfo testView mc @@ -542,6 +550,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TERelayRejected u g reason -> ttyUser u [ttyGroup' g <> ": relay rejected, reason: " <> sShow reason] TENewMemberContact u _ g m -> ttyUser u ["contact for member " <> ttyGroup' g <> " " <> ttyMember m <> " is created"] TEContactVerificationReset u ct -> ttyUser u $ viewContactVerificationReset ct TEGroupMemberVerificationReset u g m -> ttyUser u $ viewGroupMemberVerificationReset g m @@ -1319,7 +1328,7 @@ viewJoinedGroupMemberConnecting g@GroupInfo {groupId} host m@GroupMember {groupM [ (ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting and pending review...), ") <> ("use " <> highlight ("/_accept member #" <> show groupId <> " " <> show groupMemberId <> " ") <> " to accept member") ] - _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group"] + _ | useRelays' g -> [ttyGroup' g <> ": " <> ttyMember host <> " introduced " <> ttyFullMember m <> " in the channel"] | otherwise -> [ttyGroup' g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"] viewConnectedToGroupMember :: GroupInfo -> GroupMember -> [StyledString] @@ -1437,11 +1446,14 @@ viewGroupsList gs = map groupSS $ sortOn ldn_ gs where ldn_ :: GroupInfo -> Text ldn_ GroupInfo {localDisplayName} = T.toLower localDisplayName - groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}} = + groupSS g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}, groupSummary = GroupSummary {currentMembers}, relayOwnStatus} = case memberStatus membership of GSMemInvited -> groupInvitation' g - s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g + s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> rejectionSuffix <> alias g where + rejectionSuffix = case relayOwnStatus of + Just RSRejected -> " [rejected]" + _ -> "" viewMemberStatus = \case GSMemRejected -> delete "you are rejected" GSMemRemoved -> delete "you are removed" diff --git a/tests/APIDocs.hs b/tests/APIDocs.hs index aa9cc8459f..e0c242dd00 100644 --- a/tests/APIDocs.hs +++ b/tests/APIDocs.hs @@ -8,6 +8,7 @@ module APIDocs where import API.Docs.Commands import API.Docs.Events import API.Docs.Generate +import qualified API.Docs.Generate.Python as Py import qualified API.Docs.Generate.TypeScript as TS import API.Docs.Responses import API.Docs.Types @@ -42,6 +43,11 @@ apiDocsTest = do it "generate typescript responses code" $ testGenerate TS.responsesCodeFile TS.responsesCodeText it "generate typescript events code" $ testGenerate TS.eventsCodeFile TS.eventsCodeText it "generate typescript types code" $ testGenerate TS.typesCodeFile TS.typesCodeText + describe "Python" $ do + it "generate python commands code" $ testGenerate Py.commandsCodeFile Py.commandsCodeText + it "generate python responses code" $ testGenerate Py.responsesCodeFile Py.responsesCodeText + it "generate python events code" $ testGenerate Py.eventsCodeFile Py.eventsCodeText + it "generate python types code" $ testGenerate Py.typesCodeFile Py.typesCodeText documentedCmds :: [String] documentedCmds = concatMap (map consName' . commands) chatCommandsDocs diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 828c44e9a4..140739b4f4 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -7,7 +7,6 @@ module Bots.DirectoryTests where import ChatClient -import ChatTests.ChatRelays (withRelay) import ChatTests.DBUtils import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils @@ -1998,7 +1997,7 @@ testRegisterChannelViaCard ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] -- owner sends a message to trigger member introduction bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." @@ -2097,7 +2096,7 @@ testDeleteChannelRegistration ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2141,7 +2140,7 @@ testReregistrationAlreadyListed ps = [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" @@ -2200,7 +2199,7 @@ testLinkCheckUpdatesCount ps = do [ do relay <## "'SimpleX Directory': accepting request to join group #news..." relay <## "#news: 'SimpleX Directory' joined the group", - bob <## "#news: relay added 'SimpleX Directory_1' to the group" + bob <## "#news: relay introduced 'SimpleX Directory_1' in the channel" ] bob <# "'SimpleX Directory'> Joined the channel news. Registration is pending approval — it may take up to 48 hours." superUser <# "'SimpleX Directory'> bob submitted the channel ID 1:" diff --git a/tests/ChatTests/ChatRelays.hs b/tests/ChatTests/ChatRelays.hs index 58fe1074ef..4b09347dcf 100644 --- a/tests/ChatTests/ChatRelays.hs +++ b/tests/ChatTests/ChatRelays.hs @@ -325,9 +325,6 @@ testShareChannelChannel ps = getTermLine2 :: TestCC -> IO (String, String) getTermLine2 c = (,) <$> getTermLine c <*> getTermLine c -withRelay :: HasCallStack => TestParams -> (TestCC -> IO ()) -> IO () -withRelay ps = withNewTestChatOpts ps relayTestOpts "relay" relayProfile - -- Create a public group with relay=1, wait for relay to join createChannelWithRelay :: HasCallStack => String -> TestCC -> TestCC -> IO () createChannelWithRelay gName owner relay = do diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index c9e60cd66e..82bf20e6cf 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,8 +18,9 @@ import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) -import Data.Maybe (fromMaybe, maybeToList) +import Data.ByteString (ByteString) import qualified Data.ByteString.Char8 as B +import Data.Maybe (fromMaybe, listToMaybe, maybeToList) import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M @@ -32,7 +33,7 @@ import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgMention (..), MsgContent (..), msgContentText) import Simplex.Chat.Types -import Simplex.Chat.Types.MemberRelations (MemberRelation (..), setRelation) +import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -251,6 +252,13 @@ chatGroupTests = do describe "multiple relays" $ do it "2 relays: should deliver messages to members" testChannels2RelaysDeliver it "should share same incognito profile with all relays" testChannels2RelaysIncognito + describe "deliver member profiles via relay" $ do + it "late joiner (no prior history) learns sender on first forward" testChannelLateJoinerReceivesProfile + it "2 relays: deduplicate member announcement" testChannel2RelaysDeduplicateProfile + it "multi senders disseminate independently" testChannelMultiSendersIndependent + it "large profile fits in body" testChannelLargeProfileFits + it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate @@ -272,9 +280,15 @@ chatGroupTests = do it "should add relay to existing channel" testChannelAddRelay it "should remove relay from channel" testChannelRemoveRelay it "should remove left relay from channel" testChannelRemoveLeftRelay + describe "relay rejection" $ do + it "relay rejects fresh invitation after leaving the same channel" testRelayRejectAfterLeave + it "operator allow clears rejection and relay accepts again" testRelayAllowAcceptsAgain + it "rejection on channel A does not affect unrelated channel B" testRelayDoesNotRejectUnrelatedChannel + it "concurrent fresh invitations both rejected" testRelayRejectRaceConcurrentInvitations describe "channel message operations" $ do it "should update channel message" testChannelMessageUpdate it "should delete channel message" testChannelMessageDelete + it "should delete channel message from history" testChannelMessageDeleteFromHistory it "should send and receive channel message file" testChannelMessageFile it "should cancel channel message file" testChannelMessageFileCancel it "should quote channel message" testChannelMessageQuote @@ -8457,7 +8471,7 @@ testSupportPreferenceGroup = testSupportPreferenceChannel :: HasCallStack => TestParams -> IO () testSupportPreferenceChannel ps = withNewTestChat ps "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile $ \relay -> withNewTestChat ps "bob" bobProfile $ \bob -> withNewTestChat ps "cath" cathProfile $ \cath -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay @@ -8540,10 +8554,11 @@ testChannels1RelayDeliver ps = -- alice knows cath via XGrpMemNew announcement from relay alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + -- dan/eve learn cath via prepended XGrpMemNew before the forwarded reaction + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" @@ -8683,7 +8698,7 @@ memberJoinChannel' gName gId relaySfx ownerSfx memberRelaySfx relays owners shor relay <## ("#" <> gName <> ": " <> sfxMName relaySfx <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> sfxName ownerSfx <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> sfxName ownerSfx <> " in the channel")] | owner <- owners ] @@ -8715,11 +8730,29 @@ memberJoinChannelIncognito gName relays owners shortLink fullLink member = do relay <## ("#" <> gName <> ": " <> memIncognito <> " joined the group") | relay <- relays ] - <> [ owner <### [EndsWith ("added " <> memIncognito <> " to the group")] + <> [ owner <### [EndsWith ("introduced " <> memIncognito <> " in the channel")] | owner <- owners ] pure memIncognito +-- | Assert that sender's member_relations_vector has 'MRIntroduced' at +-- the recipient's index, looked up by display name on the same DB. +memberIntroducedTo :: HasCallStack => TestCC -> T.Text -> T.Text -> IO () +memberIntroducedTo cc senderName recipientName = do + rows <- withCCTransaction cc $ \db -> + DB.query + db + [sql| + SELECT s.member_relations_vector, r.index_in_group + FROM group_members s, group_members r + WHERE s.local_display_name = ? AND r.local_display_name = ? + |] + (senderName, recipientName) :: + IO [(Maybe ByteString, Int64)] + case rows of + [(mv, idx)] -> getRelation idx (fromMaybe B.empty mv) `shouldBe` MRIntroduced + _ -> expectationFailure $ "memberIntroducedTo: expected exactly one row for " <> show (senderName, recipientName) <> ", got " <> show (length rows) + testChannels1RelayDeliverLoop :: HasCallStack => Int -> TestParams -> IO () testChannels1RelayDeliverLoop deliveryBucketSize ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8739,10 +8772,10 @@ testChannels1RelayDeliverLoop deliveryBucketSize ps = bob <## " + 👍" alice <# "#team cath> > hi" alice <## " + 👍" - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hi" dan <## " + 👍" - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hi" eve <## " + 👍" where @@ -8781,14 +8814,14 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team dan> 6 [>>]" ] cath - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", WithTime "#team dan> 6 [>>]" ] dan - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", + <### [ "#team: bob introduced cath (Catherine) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8796,8 +8829,8 @@ testChannelsSenderDeduplicateOwn ps = do WithTime "#team cath> 5 [>>]" ] eve - <### [ "#team: bob forwarded a message from an unknown member, creating unknown member record cath", - "#team: bob forwarded a message from an unknown member, creating unknown member record dan", + <### [ "#team: bob introduced cath (Catherine) in the channel", + "#team: bob introduced dan (Daniel) in the channel", WithTime "#team> 1 [>>]", WithTime "#team> 2 [>>]", WithTime "#team> 3 [>>]", @@ -8808,6 +8841,231 @@ testChannelsSenderDeduplicateOwn ps = do where cfg = testCfg {deliveryWorkerDelay = 250000} +testChannelLateJoinerReceivesProfile :: HasCallStack => TestParams -> IO () +testChannelLateJoinerReceivesProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- first forward: dan learns cath via prepended XGrpMemNew. + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + -- second forward: dan's bit is set, no prepend, no view event. + cath #> "#team hi again" + bob <# "#team cath> hi again" + alice <# "#team cath> hi again [>>]" + dan <# "#team cath> hi again [>>]" + + memberIntroducedTo bob "cath" "alice" + memberIntroducedTo bob "cath" "dan" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team renamed" + bob <# "#team kate> renamed" + alice <# "#team kate> renamed [>>]" + dan <# "#team kate> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "alice" + memberIntroducedTo bob "kate" "dan" + +testChannel2RelaysDeduplicateProfile :: HasCallStack => TestParams -> IO () +testChannel2RelaysDeduplicateProfile ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + (shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan + memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve + + -- first forward: both relays prepend XGrpMemNew(dan) for eve; + -- second hits xGrpMemNew's "already created via another relay" branch. + dan #> "#team hi" + bob <# "#team dan> hi" + cath <# "#team dan> hi" + alice <# "#team dan> hi [>>]" + eve .<## " introduced dan (Daniel) in the channel" + eve <# "#team dan> hi [>>]" + + -- second forward: eve's bit is set on both relays, no prepend. + dan #> "#team hi again" + bob <# "#team dan> hi again" + cath <# "#team dan> hi again" + alice <# "#team dan> hi again [>>]" + eve <# "#team dan> hi again [>>]" + + -- both relays independently mark eve in dan's vector; + -- alice's bit was set at join via introduceInChannel and stays set. + memberIntroducedTo bob "dan" "alice" + memberIntroducedTo bob "dan" "eve" + memberIntroducedTo cath "dan" "alice" + memberIntroducedTo cath "dan" "eve" + + -- profile update: rename piggybacks on next send; no re-prepend, bits stay set. + dan ##> "/p dean Dean" + dan <## "user profile is changed to dean (Dean) (your 0 contacts are notified)" + + dan #> "#team renamed" + bob <# "#team dean> renamed" + cath <# "#team dean> renamed" + alice <# "#team dean> renamed [>>]" + eve <# "#team dean> renamed [>>]" + threadDelay 500000 + memberIntroducedTo bob "dean" "alice" + memberIntroducedTo bob "dean" "eve" + memberIntroducedTo cath "dean" "alice" + memberIntroducedTo cath "dean" "eve" + +testChannelLargeProfileFits :: HasCallStack => TestParams -> IO () +testChannelLargeProfileFits ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + -- ~14000 chars: profile fits in a singleton batch AND packs + -- inline with the forwarded body (exercises the in-body path). + let bigImage = T.pack ("data:image/png;base64," <> replicate 14000 'A') + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + +testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () +testChannelMultipleLargeProfiles ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfgOpts ps cfg relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- ~14500 chars each: one rides inline with the body, + -- the other spills into a standalone overflow batch. + let cathImage = T.pack ("data:image/png;base64," <> replicate 14500 'A') + danImage = T.pack ("data:image/png;base64," <> replicate 14500 'B') + withCCTransaction bob $ \db -> do + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (cathImage, "cath" :: T.Text) + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (danImage, "dan" :: T.Text) + + -- deliveryWorkerDelay=250ms lets the relay coalesce cath's and + -- dan's sends into one multi-sender job. + cath #> "#team from cath" + bob <# "#team cath> from cath" + dan #> "#team from dan" + bob <# "#team dan> from dan" + + alice + <### [ WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + cath + <### [ "#team: bob introduced dan (Daniel) in the channel", + WithTime "#team dan> from dan [>>]" + ] + dan + <### [ "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]" + ] + eve + <### [ "#team: bob introduced dan (Daniel) in the channel", + "#team: bob introduced cath (Catherine) in the channel", + WithTime "#team cath> from cath [>>]", + WithTime "#team dan> from dan [>>]" + ] + + memberIntroducedTo bob "cath" "eve" + memberIntroducedTo bob "dan" "eve" + where + cfg = testCfg {deliveryWorkerDelay = 250000} + +-- Asserted via SQL on the relay's DB rather than terminal output: the +-- "updated profile" chat item rendering on relays/owners is order-sensitive. +testChannelProfileUpdateNoRePrepend :: HasCallStack => TestParams -> IO () +testChannelProfileUpdateNoRePrepend ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + + cath #> "#team hi" + bob <# "#team cath> hi" + alice <# "#team cath> hi [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> hi [>>]" + + memberIntroducedTo bob "cath" "dan" + + -- /p only delivers XInfo to direct contacts; for group members it + -- piggybacks on the next group send via shouldSendProfileUpdate. + cath ##> "/p kate Kate" + cath <## "user profile is changed to kate (Kate) (your 0 contacts are notified)" + + cath #> "#team hi again" + bob <# "#team kate> hi again" + alice <# "#team kate> hi again [>>]" + dan <# "#team kate> hi again [>>]" + threadDelay 500000 + memberIntroducedTo bob "kate" "dan" + +testChannelMultiSendersIndependent :: HasCallStack => TestParams -> IO () +testChannelMultiSendersIndependent ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + + -- cath posts: dan and eve learn cath via prepended XGrpMemNew + cath #> "#team from cath" + bob <# "#team cath> from cath" + alice <# "#team cath> from cath [>>]" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> from cath [>>]" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> from cath [>>]" + + -- dan posts: cath and eve learn dan independently of cath's vector + dan #> "#team from dan" + bob <# "#team dan> from dan" + alice <# "#team dan> from dan [>>]" + cath <## "#team: bob introduced dan (Daniel) in the channel" + cath <# "#team dan> from dan [>>]" + eve <## "#team: bob introduced dan (Daniel) in the channel" + eve <# "#team dan> from dan [>>]" + + -- second post from cath: all recipients have cath marked, no prepend + cath #> "#team again from cath" + bob <# "#team cath> again from cath" + alice <# "#team cath> again from cath [>>]" + dan <# "#team cath> again from cath [>>]" + eve <# "#team cath> again from cath [>>]" + testChannels2RelaysDeliver :: HasCallStack => TestParams -> IO () testChannels2RelaysDeliver ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -8830,10 +9088,10 @@ testChannels2RelaysDeliver ps = cath <## " + 👍" alice <# "#team dan> > hi" alice <## " + 👍" - eve .<## " forwarded a message from an unknown member, creating unknown member record dan" + eve .<## " introduced dan (Daniel) in the channel" eve <# "#team dan> > hi" eve <## " + 👍" - frank .<## " forwarded a message from an unknown member, creating unknown member record dan" + frank .<## " introduced dan (Daniel) in the channel" frank <# "#team dan> > hi" frank <## " + 👍" @@ -8868,10 +9126,10 @@ testChannels2RelaysIncognito ps = cath <## " + 👍" alice <# ("#team " <> danIncognito <> "> > hi") alice <## " + 👍" - eve .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + eve .<## (" introduced " <> danIncognito <> " in the channel") eve <# ("#team " <> danIncognito <> "> > hi") eve <## " + 👍" - frank .<## (" forwarded a message from an unknown member, creating unknown member record " <> danIncognito) + frank .<## (" introduced " <> danIncognito <> " in the channel") frank <# ("#team " <> danIncognito <> "> > hi") frank <## " + 👍" @@ -9084,10 +9342,10 @@ testChannelChangeRoleSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9139,10 +9397,10 @@ testChannelBlockMemberSigned ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9202,10 +9460,10 @@ testChannelRemoveMemberSigned ps = concurrentlyN_ [ alice <# "#team eve> hello from eve [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + dan <## "#team: bob introduced eve (Eve) in the channel" dan <# "#team eve> hello from eve [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record eve" + cath <## "#team: bob introduced eve (Eve) in the channel" cath <# "#team eve> hello from eve [>>]" ] @@ -9370,10 +9628,10 @@ testChannelSubscriberLeave ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9424,7 +9682,9 @@ testChannelSubscriberLeave ps = dan <## "use /d #team to delete the group" bob <## "#team: dan left the group (signed)" alice <## "#team: dan left the group (signed)" - -- eve doesn't know dan - no unknown member record created (skipped for XGrpLeave) + -- dan never sent before leaving, so dan's profile is disseminated to eve + -- via prepended XGrpMemNew before the forwarded XGrpLeave + eve <## "#team: bob introduced dan (Daniel) in the channel" alice #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) bob #$> ("/_get chat #1 count=1", chat, [(0, "left (signed)")]) dan #$> ("/_get chat #1 count=1", chat, [(1, "left (signed)")]) @@ -9443,8 +9703,10 @@ testChannelSubscriberLeave ps = checkMemberStatus alice "dan" (Just "left") checkMemberStatus bob "dan" (Just "left") checkMemberStatus dan "dan" (Just "left") - -- eve doesn't know dan - no member record (XGrpLeave skips unknown member creation) - checkMemberStatus eve "dan" Nothing + -- eve learned dan via prepended XGrpMemNew before the forwarded XGrpLeave, + -- so eve now has a record for dan with status "left" + checkMemberStatus eve "dan" (Just "left") + -- cath left earlier and was excluded from the forward; no record on cath checkMemberStatus cath "dan" Nothing where checkMemberStatus :: HasCallStack => TestCC -> T.Text -> Maybe T.Text -> IO () @@ -9473,8 +9735,9 @@ testChannelRelayLeave ps = -- relay1 (bob) leaves threadDelay 100000 bob ##> "/leave #team" - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" concurrentlyN_ [ alice <## "#team: bob left the group (signed)", -- cath: not notified (relays not connected, owner doesn't forward) @@ -9496,8 +9759,9 @@ testChannelRelayLeave ps = -- relay2 (cath) leaves threadDelay 100000 cath ##> "/leave #team" - cath <## "#team: you left the group" - cath <## "use /d #team to delete the group" + cath <## "#team: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team to allow future invitations" + cath <## "use /d #team to delete the group (also clears the rejection)" concurrentlyN_ [ alice <## "#team: cath left the group (signed)", dan <## "#team: cath left the group (signed)", @@ -9619,10 +9883,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team cath> hello from cath [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello from cath [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello from cath [>>]" ] @@ -9665,10 +9929,10 @@ testChannelSubscriberProfileUpdate ps = concurrentlyN_ [ alice <# "#team dave> hello from dave [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + eve <## "#team: bob introduced dave in the channel" eve <# "#team dave> hello from dave [>>]", do - cath <## "#team: bob forwarded a message from an unknown member, creating unknown member record dave" + cath <## "#team: bob introduced dave in the channel" cath <# "#team dave> hello from dave [>>]" ] -- no profile update items in main scope (dan has no support chat, item not created) @@ -9734,9 +9998,6 @@ testChannelAddRelay ps = threadDelay 100000 -- existing subscriber discovers and connects to new relay - dan ##> "/_get group link data #1" - dan <## "group ID: 1" - void $ getTermLine dan -- subscribers: N concurrentlyN_ [ do dan <## "#team: joining the group (connecting to relay cath)..." @@ -9871,8 +10132,9 @@ testChannelRemoveLeftRelay ps = bob ##> "/l team" concurrentlyN_ [ do - bob <## "#team: you left the group" - bob <## "use /d #team to delete the group", + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)", alice <## "#team: bob left the group (signed)", dan <## "#team: bob left the group (signed)" ] @@ -9900,8 +10162,9 @@ testChannelRemoveLeftRelay ps = cath ##> "/l team" concurrentlyN_ [ do - cath <## "#team: you left the group" - cath <## "use /d #team to delete the group", + cath <## "#team: you left the group (future invitations will be rejected)" + cath <## "use /group allow #team to allow future invitations" + cath <## "use /d #team to delete the group (also clears the rejection)", alice <## "#team: cath left the group (signed)", dan <## "#team: cath left the group (signed)" ] @@ -9923,6 +10186,271 @@ testChannelRemoveLeftRelay ps = DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text] danMembers2 `shouldMatchList` [Only "dan", Only "alice"] +queryRelayOwnStatus :: TestCC -> Int64 -> IO (Maybe T.Text) +queryRelayOwnStatus cc gId = do + rows <- withCCTransaction cc $ \db -> + DB.query db "SELECT relay_own_status FROM groups WHERE group_id = ?" (Only gId) + :: IO [Only (Maybe T.Text)] + pure $ case rows of + [Only s] -> s + _ -> Nothing + +listRelayOwnStatuses :: TestCC -> IO [(Int64, T.Text)] +listRelayOwnStatuses cc = + withCCTransaction cc $ \db -> + DB.query_ + db + "SELECT group_id, relay_own_status FROM groups WHERE relay_own_status IS NOT NULL ORDER BY group_id" + :: IO [(Int64, T.Text)] + +checkRelayGroupCount :: TestCC -> Int -> IO () +checkRelayGroupCount cc expected = do + rows <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT COUNT(*) FROM groups WHERE relay_own_status IS NOT NULL" :: IO [Only Int] + let n = case rows of + [Only c] -> c + _ -> 0 + n `shouldBe` expected + +testRelayRejectAfterLeave :: HasCallStack => TestParams -> IO () +testRelayRejectAfterLeave ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages via the active relay + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + -- relay leaves the channel: subscriber gets the signed leave notice via bob's + -- DJRelayRemoved job, then has no relay to forward subsequent messages. + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + bobLeaveStatus <- queryRelayOwnStatus bob 1 + bobLeaveStatus `shouldBe` Just "rejected" + + -- with no active relay, owner's messages don't reach the subscriber + alice #> "#team after leave" + (cath "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + -- owner re-adds bob as relay + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + + -- bob's xGrpRelayInv finds the 'rejected' row for this link and sends XGrpRelayReject. + -- alice's CONF handler emits TERelayRejected; the relay row flips to 'rejected'. + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + + -- assert alice's fresh GroupRelay row is marked 'rejected' and the relay + -- GroupMember is GSMemLeft so the owner UI treats it as gone + aliceRelayStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT relay_status FROM group_relays" :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayStatuses `shouldBe` ["rejected"] + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["left"] + + -- subscriber still doesn't receive after the failed re-invitation + alice #> "#team after rejection" + (cath TestParams -> IO () +testRelayAllowAcceptsAgain ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + -- with no relay, subscriber doesn't receive + alice #> "#team during downtime" + (cath "/group allow #team" + bob <## "#team: relay rejection cleared" + bobClearStatus <- queryRelayOwnStatus bob 1 + bobClearStatus `shouldBe` Just "inactive" + + -- owner can now re-add and bob accepts as relay (the rejection has been cleared) + alice ##> "/rm #team bob" + alice <## "#team: you removed bob from the group (signed)" + threadDelay 100000 + + alice ##> "/_add relays #1 1" + concurrentlyN_ + [ do + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: group link relays updated, current relays:" + alice .<##. (" - relay id", ": active") + alice <## "group link:" + void $ getTermLine alice, + bob <## "#team_1: you joined the group as relay" + ] + threadDelay 100000 + + -- subscriber syncs against link data and reconnects to the new relay + cath ##> "/_get group link data #1" + cath <## "group ID: 1" + void $ getTermLine cath + concurrentlyN_ + [ do + cath <## "#team: joining the group (connecting to relay bob)..." + cath <## "#team: you joined the group (connected to relay bob)", + do + bob <## "cath_1 (Catherine): accepting request to join group #team_1..." + bob <## "#team_1: cath_1 joined the group" + ] + threadDelay 100000 + + -- delivery resumes through the freshly accepted relay + alice #> "#team after allow" + bob <# "#team_1> after allow" + cath <# "#team> after allow [>>]" + + -- after re-acceptance, the relay GroupMember is not in the rejected/left state + aliceRelayMemStatuses <- withCCTransaction alice $ \db -> + DB.query_ db "SELECT member_status FROM group_members WHERE member_role = 'relay'" + :: IO [Only T.Text] + map (\(Only s) -> s) aliceRelayMemStatuses `shouldBe` ["connected"] + +testRelayDoesNotRejectUnrelatedChannel :: HasCallStack => TestParams -> IO () +testRelayDoesNotRejectUnrelatedChannel ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + _ <- prepareChannel1Relay "teama" alice bob + threadDelay 100000 + + bob ##> "/leave #teama" + bob <## "#teama: you left the group (future invitations will be rejected)" + bob <## "use /group allow #teama to allow future invitations" + bob <## "use /d #teama to delete the group (also clears the rejection)" + alice <## "#teama: bob left the group (signed)" + threadDelay 100000 + + bobAStatus <- queryRelayOwnStatus bob 1 + bobAStatus `shouldBe` Just "rejected" + + -- alice creates a second channel reusing the same bob relay config. + -- bob's xGrpRelayInv for teamb's link finds no rejection and accepts normally. + (shortLinkB, fullLinkB) <- prepareChannel' 2 "teamb" alice bob + memberJoinChannel "teamb" [bob] [alice] shortLinkB fullLinkB cath + threadDelay 100000 + + -- subscriber on teamb receives forwarded messages, proving bob accepts teamb + -- even though teama remains rejected on bob's side. + alice #> "#teamb hello" + bob <# "#teamb> hello" + cath <# "#teamb> hello [>>]" + + bobBStatus <- queryRelayOwnStatus bob 2 + bobBStatus `shouldNotBe` Just "rejected" + bobBStatus `shouldNotBe` Nothing + +testRelayRejectRaceConcurrentInvitations :: HasCallStack => TestParams -> IO () +testRelayRejectRaceConcurrentInvitations ps = + -- After rejection, multiple sequential re-invitations must all reject with + -- consistent state (each transient row created with RSRejected and cleaned + -- up by its own INFO). + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + threadDelay 100000 + + -- baseline: subscriber receives forwarded messages + alice #> "#team hello" + bob <# "#team> hello" + cath <# "#team> hello [>>]" + + bob ##> "/leave #team" + bob <## "#team: you left the group (future invitations will be rejected)" + bob <## "use /group allow #team to allow future invitations" + bob <## "use /d #team to delete the group (also clears the rejection)" + concurrentlyN_ + [ alice <## "#team: bob left the group (signed)", + cath <## "#team: bob left the group (signed)" + ] + threadDelay 100000 + + -- first rejection + alice ##> "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + threadDelay 1000000 + checkRelayGroupCount bob 1 + + -- subscriber doesn't receive between rejections (no active relay) + alice #> "#team between rejections" + (cath "/rm #team bob" + alice .<##. ("#team: you removed bob from the group", "") + threadDelay 100000 + alice ##> "/_add relays #1 1" + alice <## "#team: group relays:" + alice .<##. (" - relay id", ": invited") + alice <## "#team: relay rejected, reason: RRRRejoinRejected" + + -- subscriber still doesn't receive after the second rejection + alice #> "#team after second rejection" + (cath TestParams -> IO () testChannelCreateDeletedRelay ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -9953,7 +10481,7 @@ testChannelCreateDeletedRelay ps = testChannelSupportScope :: HasCallStack => TestParams -> IO () testChannelSupportScope ps = withNewTestChat ps "alice" aliceProfile $ \alice -> - withNewTestChatOpts ps relayTestOpts "relay" relayProfile $ \relay -> + withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile $ \relay -> withNewTestChat ps "cath" cathProfile $ \cath -> withNewTestChat ps "dan" danProfile $ \dan -> do (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay @@ -10030,6 +10558,37 @@ testChannelMessageDelete ps = bob <# "#team> [marked deleted] hello" [cath, dan, eve] *<# "#team> [marked deleted] hello" -- TODO show as forwarded +testChannelMessageDeleteFromHistory :: HasCallStack => TestParams -> IO () +testChannelMessageDeleteFromHistory ps = + testChat4 aliceProfile bobProfile cathProfile danProfile test ps + where + test alice bob cath dan = withRelay ps $ \relay -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice relay + memberJoinChannel "team" [relay] [alice] shortLink fullLink bob + memberJoinChannel "team" [relay] [alice] shortLink fullLink cath + + alice #> "#team hello" + relay <# "#team> hello" + [bob, cath] *<# "#team> hello [>>]" + + -- owner deletes from history (relay processes locally but doesn't forward) + msgId <- lastItemId alice + alice #$> ("/_delete item #1 " <> msgId <> " history", id, "message marked deleted") + relay <# "#team> [marked deleted] hello" + + -- subscribers don't receive deletion - next message arrives cleanly + alice #> "#team still here" + relay <# "#team> still here" + [bob, cath] *<# "#team> still here [>>]" + + -- internal delete rejected for channel owner + msgId2 <- lastItemId alice + alice ##> ("/_delete item #1 " <> msgId2 <> " internal") + alice <## "cannot delete this item" + + memberJoinChannel "team" [relay] [alice] shortLink fullLink dan + dan <# "#team> still here [>>]" + testChannelMessageFile :: HasCallStack => TestParams -> IO () testChannelMessageFile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> @@ -10138,11 +10697,11 @@ testChannelMessageQuote ps = alice <# "#team cath> > hello from channel [>>]" alice <## " replying to channel [>>]", do - dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> > hello from channel [>>]" dan <## " replying to channel [>>]", do - eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> > hello from channel [>>]" eve <## " replying to channel [>>]" ] @@ -10498,9 +11057,9 @@ testChannelMemberMessageUpdate ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] @@ -10529,9 +11088,9 @@ testChannelMemberMessageDelete ps = bob <# "#team cath> hello" concurrentlyN_ [ alice <# "#team cath> hello [>>]", - do dan <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do dan <## "#team: bob introduced cath (Catherine) in the channel" dan <# "#team cath> hello [>>]", - do eve <## "#team: bob forwarded a message from an unknown member, creating unknown member record cath" + do eve <## "#team: bob introduced cath (Catherine) in the channel" eve <# "#team cath> hello [>>]" ] diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index b6ab89d00a..4987319899 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -81,8 +81,8 @@ frankProfile = mkProfile "frank" "Frank" Nothing businessProfile :: Profile businessProfile = mkProfile "biz" "Biz Inc" Nothing -relayProfile :: Profile -relayProfile = mkProfile "relay" "Relay" Nothing +chatRelayProfile :: Profile +chatRelayProfile = mkProfile "relay" "Relay" Nothing serviceProfile :: Profile serviceProfile = mkProfile "service_user" "Service user" Nothing @@ -152,6 +152,9 @@ runTestCfg3 aliceCfg bobCfg cathCfg runTest ps = withNewTestChatCfg ps cathCfg "cath" cathProfile $ \cath -> runTest alice bob cath +withRelay :: HasCallStack => TestParams -> (TestCC -> IO ()) -> IO () +withRelay ps = withNewTestChatOpts ps relayTestOpts "relay" chatRelayProfile + -- | test sending direct messages (<##>) :: HasCallStack => TestCC -> TestCC -> IO () cc1 <##> cc2 = do diff --git a/tests/MarkdownTests.hs b/tests/MarkdownTests.hs index 3683b536dd..a82e18f988 100644 --- a/tests/MarkdownTests.hs +++ b/tests/MarkdownTests.hs @@ -227,6 +227,10 @@ textWithUri = describe "text with Uri" do "https://github.com/simplex-chat/ - SimpleX on GitHub" <==> uri "https://github.com/simplex-chat/" <> " - SimpleX on GitHub" -- "SimpleX on GitHub (https://github.com/simplex-chat/)" <==> "SimpleX on GitHub (" <> uri "https://github.com/simplex-chat/" <> ")" "https://en.m.wikipedia.org/wiki/Servo_(software)" <==> uri "https://en.m.wikipedia.org/wiki/Servo_(software)" + "https://simplex.chat/page_name_" <==> uri "https://simplex.chat/page_name_" + "https://simplex.chat/page_name_, hello" <==> uri "https://simplex.chat/page_name_" <> ", hello" + "https://simplex.chat/page!" <==> uri "https://simplex.chat/page!" + "https://simplex.chat/page!, hello" <==> uri "https://simplex.chat/page!" <> ", hello" "example.com" <==> uri "example.com" "example.com." <==> uri "example.com" <> "." "example.com..." <==> uri "example.com" <> "..." @@ -416,6 +420,10 @@ testSanitizeUri = describe "sanitizeUri" $ do "https://example.com/page/a123?source=abc" `safeSanitized` Nothing -- source is in unsafe blacklist "https://example.com/page/a123?name=abc" `eagerSanitized` Just "https://example.com/page/a123" "https://example.com/page/a123?name=abc" `safeSanitized` Nothing -- name is not in a whitelist + it "should keep whitelisted parameters in safe mode even if they match a blacklist prefix" $ do + "https://example.com/playlist?list=abc" `sanitized` Nothing -- "list" is whitelisted, "li" is blacklisted + "https://example.com/playlist?list=abc&si=def" `sanitized` Just "https://example.com/playlist?list=abc" + "https://github.com/owner/repo?ref=main" `sanitized` Nothing -- "ref" is whitelisted for github.com where s `eagerSanitized` res = sanitized_ False s res s `safeSanitized` res = sanitized_ True s res diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 7df0beb2fa..197e9a9b89 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -78,5 +78,7 @@ skipComparisonForDownMigrations = [ -- via_group field moves "20250922_remove_unused_connections", -- group_member_intro_id field moves - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column is re-added at the end of the table + "20260515_delivery_job_senders" ] diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 01399f6bbb..aef41e90d2 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -191,7 +191,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing it "x.msg.del" $ "{\"v\":\"1\",\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" - #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing + #==# XMsgDel (SharedMsgId "\1\2\3\4") Nothing Nothing False it "x.msg.deleted" $ "{\"v\":\"1\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 2c4ba05ce6..bc74f3ec33 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -141,7 +141,11 @@ skipComparisonForDownMigrations = -- indexes move down to the end of the file "20250922_remove_unused_connections", -- group_member_intros table moves down to the end of the file - "20251128_migrate_member_relations" + "20251128_migrate_member_relations", + -- on down migration single_sender_group_member_id column and its index + -- are re-added at the end of the table / file (ALTER TABLE ADD COLUMN + -- appends; CREATE INDEX appends). + "20260515_delivery_job_senders" ] getSchema :: FilePath -> FilePath -> IO String diff --git a/website/.eleventy.js b/website/.eleventy.js index b02cc49e78..f0310c5665 100644 --- a/website/.eleventy.js +++ b/website/.eleventy.js @@ -12,6 +12,15 @@ const pluginRss = require('@11ty/eleventy-plugin-rss') const { JSDOM } = require('jsdom') +// Links page data +const parseLinks = require('./parse_links') +const linksFilePath = path.resolve(__dirname, '../docs/LINKS.md') +const linksData = fs.existsSync(linksFilePath) ? parseLinks(linksFilePath) : [] +const linkImagesDir = path.resolve(__dirname, 'src/link-images') +linksData.forEach(entry => { + entry.imageExists = entry.image && fs.existsSync(path.join(linkImagesDir, entry.image)) +}) + // The implementation of Glossary feature const md = new markdownIt() const glossaryMarkdownContent = fs.readFileSync(path.resolve(__dirname, '../docs/GLOSSARY.md'), 'utf8') @@ -77,6 +86,15 @@ module.exports = function (ty) { return markdownLib.render(content); }); + ty.addGlobalData("links", linksData) + ty.addGlobalData("linkLanguages", [...new Set(linksData.map(e => e.language).filter(Boolean))].sort()) + + const catCounts = {} + linksData.forEach(e => { if (e.category) { const c = e.category.toLowerCase(); catCounts[c] = (catCounts[c] || 0) + 1 } }) + const mediaPills = ["Video", "Audio"].filter(p => linksData.some(e => e.mediaType === p.toLowerCase())) + const catPills = Object.keys(catCounts).sort() + ty.addGlobalData("linkPills", mediaPills.concat(catPills)) + ty.addShortcode("cfg", (name) => globalConfig[name]) ty.addFilter("getlang", (path) => { @@ -298,6 +316,7 @@ module.exports = function (ty) { ty.addPassthroughCopy("src/call") ty.addPassthroughCopy("src/hero-phone") ty.addPassthroughCopy("src/hero-phone-dark") + ty.addPassthroughCopy({ "src/link-images": "links/images" }) ty.addPassthroughCopy("src/blog/images") ty.addPassthroughCopy("src/docs/*.png") ty.addPassthroughCopy("src/docs/images") diff --git a/website/langs/en.json b/website/langs/en.json index 490e693f18..2e860d76d4 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -368,5 +368,8 @@ "file-proto-p-2": "File encryption key is present only in the URL hash fragment - your browser never sends it to a server. There are 3 encryption layers: TLS transport, per-recipient encryption (unique ephemeral key per transfer), and file end-to-end encryption.", "file-proto-h-4": "Independent data routers", "file-proto-p-4": "When file is split to fragments, it is sent via network routers operated by independent parties. No operator can see the actual file size or name. Even if a router is compromised, it can only see encrypted fragments of fixed size. File fragments are cached by network routers for approximately 48 hours.", - "file-proto-spec": "Read the XFTP protocol specification →" + "file-proto-spec": "Read the XFTP protocol specification →", + "links": "Links", + "links-title": "Community Links", + "links-all-languages": "All languages" } diff --git a/website/parse_links.js b/website/parse_links.js new file mode 100644 index 0000000000..487330b861 --- /dev/null +++ b/website/parse_links.js @@ -0,0 +1,184 @@ +const fs = require("fs") +const slugify = require("slugify") + +function parseLinks(linksFilePath) { + const content = fs.readFileSync(linksFilePath, "utf8") + const lines = content.split("\n") + const entries = [] + + // First pass: split into raw entry blocks at ## boundaries + const blocks = [] + let current = null + for (const line of lines) { + if (line.startsWith("## ")) { + if (current) blocks.push(current) + current = { title: line.slice(3).trim(), lines: [] } + } else if (current) { + current.lines.push(line) + } + } + if (current) blocks.push(current) + + // Second pass: parse each block + for (const block of blocks) { + // Collect non-empty lines in order + const parts = block.lines.map(l => l.trim()).filter(l => l) + + let originalTitle = "" + let publisher = "" + let category = "" + let featured = false + let preview = "" + let image = "" + let language = "" + let date = "" + let estimated = false + let url = "" + + let idx = 0 + + // Optional: original title in parentheses + if (idx < parts.length && parts[idx].startsWith("(") && parts[idx].endsWith(")")) { + originalTitle = parts[idx].slice(1, -1) + idx++ + } + + // Publisher: first line that's not a metadata prefix and not "Featured" + if (idx < parts.length && !isMetadata(parts[idx]) && parts[idx] !== "Featured") { + publisher = parts[idx] + idx++ + } + + // Category: next non-metadata, non-Featured line + if (idx < parts.length && !isMetadata(parts[idx]) && parts[idx] !== "Featured") { + category = parts[idx] + idx++ + } + + // Optional: Featured + if (idx < parts.length && parts[idx] === "Featured") { + featured = true + idx++ + } + + // Preview: collect lines until we hit a metadata line + const previewLines = [] + while (idx < parts.length && !isMetadata(parts[idx])) { + previewLines.push(parts[idx]) + idx++ + } + preview = previewLines.join(" ") + + // Metadata lines: Image, Language, Date, URL + while (idx < parts.length) { + const line = parts[idx] + if (line.startsWith("Image: ")) { + image = line.slice(7) + } else if (line.startsWith("Language: ")) { + language = line.slice(10) + } else if (line.startsWith("Date: ")) { + const rawDate = line.slice(6) + if (rawDate.includes("(estimated)")) { + estimated = true + date = rawDate.replace("(estimated)", "").trim() + } else { + date = rawDate + } + } else if (line.startsWith("http")) { + url = line + } + idx++ + } + + if (!block.title || !url) continue + + let contentCategory = category + let explicitMedia = "" + if (category.includes(", ")) { + const parts = category.split(", ") + contentCategory = parts[0].trim() + explicitMedia = parts[1].trim().toLowerCase() + } + + entries.push({ + id: slugify(block.title, { lower: true, strict: true }).slice(0, 80), + title: block.title, + originalTitle, + publisher, + category: contentCategory, + featured, + preview, + image, + language, + date, + dateSort: normalizeDateForSort(date), + estimated, + url, + mediaType: explicitMedia || deriveMediaType(category), + }) + } + + // Deduplicate IDs by appending language suffix where needed + const idCounts = {} + for (const entry of entries) { + idCounts[entry.id] = (idCounts[entry.id] || 0) + 1 + } + for (const entry of entries) { + if (idCounts[entry.id] > 1 && entry.language) { + entry.id = entry.id.slice(0, 70) + "-" + slugify(entry.language, { lower: true, strict: true }) + } + } + // Final pass: if still duplicates, append index + const seen = {} + for (const entry of entries) { + if (seen[entry.id]) { + entry.id = entry.id + "-" + (seen[entry.id]++) + } else { + seen[entry.id] = 1 + } + } + + entries.sort((a, b) => b.dateSort.localeCompare(a.dateSort)) + return entries +} + +function isMetadata(line) { + return line.startsWith("Image: ") || + line.startsWith("Language: ") || + line.startsWith("Date: ") || + line.startsWith("http") +} + +function deriveMediaType(category) { + const lower = category.toLowerCase() + if (lower.includes("video") || lower.includes("livestream") || lower.includes("conference talk")) return "video" + if (lower.includes("podcast") || lower.includes("audio")) return "audio" + return "text" +} + + +function normalizeDateForSort(dateStr) { + if (!dateStr) return "1970-01-01" + + // Full date: "Apr 29, 2026" or "Dec 2, 2022" + const fullDate = new Date(dateStr) + if (!isNaN(fullDate.getTime())) { + return fullDate.toISOString().slice(0, 10) + } + + // Month + year: "May 2026" + const monthYear = new Date(dateStr + " 1") + if (!isNaN(monthYear.getTime())) { + return monthYear.toISOString().slice(0, 10) + } + + // Year only: "2024" + const yearMatch = dateStr.match(/(\d{4})/) + if (yearMatch) { + return yearMatch[1] + "-01-01" + } + + return "1970-01-01" +} + +module.exports = parseLinks diff --git a/website/plans/2026-05-20-links-page.md b/website/plans/2026-05-20-links-page.md new file mode 100644 index 0000000000..eb4760620f --- /dev/null +++ b/website/plans/2026-05-20-links-page.md @@ -0,0 +1,189 @@ +# Links Page Implementation Plan + +## Overview + +Single page at `/links` showing 300+ external publications, reviews, bots, services, and community content about SimpleX Chat. All items rendered as HTML in the DOM for SEO. Client-side JS handles pagination via `display:none` and hash-based navigation. + +Content source: `docs/LINKS.md` (parsed at build time). +Images: `docs/links/images/` (copied at build time, missing images handled gracefully). + +## Architecture + +The parser is a Node.js module imported directly by `.eleventy.js` (like the glossary parser), not a separate build step writing to `_data/`. It reads `docs/LINKS.md` and returns a structured array that Eleventy uses as template data. + +## Files to Create + +### 1. `website/parse_links.js` - Markdown parser module + +Exports a function that reads `src/docs/LINKS.md` (after `web.sh` copies docs into src/) and returns an array of entry objects. + +Parser logic - reads line by line, entry starts at `## `: +``` +## Title -> title +(Original Title) -> originalTitle (optional, detected by leading paren) +Publisher -> publisher +Category -> category +Featured -> featured: true (optional, detected by exact match) +Preview paragraph text. -> preview (first non-metadata, non-empty line after above) +Image: filename.jpg -> image (strip "Image: " prefix) +Language: German -> language (strip "Language: " prefix) +Date: Dec 2, 2022 -> date (raw string), dateSort (normalized YYYY-MM-DD) + -> estimated: true if "(estimated)" in date string +https://example.com/... -> url (bare URL line) +``` + +Derives from category: +- `mediaType`: "video" if category contains "video"/"livestream", "audio" if contains "podcast"/"audio", "text" otherwise + +Generates: +- `id`: semantic slug from title (slugify, lowercase, truncated to reasonable length) +- `imageExists`: checks if file exists in `src/link-images/` + +Returns array sorted reverse-chronologically by `dateSort`. + +### 2. `website/src/links.html` - Page template + +Frontmatter: +```yaml +layout: layouts/main.html +title: "SimpleX Chat Links" +description: "Reviews, articles, videos, podcasts, and community content about SimpleX Chat" +permalink: /links/ +templateEngineOverride: njk +active_links: true +``` + +Structure: +- Page heading (i18n) +- Filter bar: language dropdown, category chips, media type chips +- Items list: reuses blog card layout pattern (same Tailwind classes from blog.html - `shadow-[0px_20px_30px_rgba(0,0,0,0.12)]`, `dark:bg-[#0B2A59]`, etc.) +- Each item is an `
` with data attributes: `data-lang`, `data-category`, `data-media`, `data-date`, `data-featured`, and `id` attribute (semantic slug) +- Image with `loading="lazy"`, fallback to SimpleX logo if no image +- Title as link to external URL (opens in new tab) +- Anchor icon (link/chain icon) appears on hover, links to `#link=item-id` for sharing +- Publisher, category, language badge, date shown as metadata +- Preview paragraph +- Featured items get a subtle highlight (border or background tint) +- Pagination controls at bottom + +### 3. `website/src/js/links.js` - Client-side pagination/filtering + +On page load: +1. Collect all `
` elements +2. Read hash: `#page=N` or `#link=slug` +3. Apply any active filters +4. Paginate: show N items per page, hide rest with `display:none` +5. If `#link=slug`: find the item, calculate its page, show that page, scroll to item, briefly highlight it +6. If `#page=N`: show page N + +Filter logic: +- Filtering by language/category/media: iterate all articles, set `display:none` on non-matching, re-paginate the visible set +- Filters update the hash + +Pagination: +- Items per page: ~20 +- Page controls: prev/next + page numbers +- Clicking a page link sets `#page=N` in hash +- Hash change listener re-renders + +Share anchors: +- Each item has a hover-visible link icon +- Clicking it copies `#link=item-id` to clipboard / updates URL hash +- When page loads with `#link=item-id`, JS finds the item, determines which page it falls on (accounting for active filters), shows that page, scrolls to item + +## Files to Modify + +### 4. `website/.eleventy.js` + +At the top, after glossary parsing: +```js +const parseLinks = require('./parse_links') +``` + +Inside `module.exports`: +- Add `links` as global data: parsed from LINKS.md +- Add passthrough copy: `ty.addPassthroughCopy("src/link-images")` +- The docs collection already globs `src/docs/**/*.md` - LINKS.md must be excluded. Options: + - `web.sh` deletes `src/docs/LINKS.md` after parse_links reads it + - Or add frontmatter to LINKS.md with `eleventyExcludeFromCollections: true` and `permalink: false` + - Simplest: delete in web.sh after copy + +### 5. `website/web.sh` + +After `cp -R docs website/src`: +```bash +cp -R docs/links/images website/src/link-images +``` + +After `node customize_docs_frontmatter.js`: +```bash +rm website/src/docs/LINKS.md # prevent Eleventy from processing as doc page +``` + +### 6. `website/src/_includes/navbar.html` + +Add "Links" nav item after Blog (line ~115): +```html + +``` + +Language dropdown stays enabled on the links page - content spans 30 languages, so visitors from any language should be able to navigate and use the filters naturally. + +### 7. `website/langs/en.json` (and other lang files) + +Add i18n keys for page chrome: +- `"links"` - nav label +- `"links-title"` - page heading (e.g. "Community Links" or "Links to Community Publications") +- `"links-filter-language"` - "Language" dropdown label +- `"links-filter-all"` - "All" filter chip +- `"links-filter-category"` - "Category" label +- `"links-filter-media"` - "Media" label +- `"links-featured"` - "Featured" badge text + +Translate these keys across all language files (same approach as the 14-key translation done earlier in this branch). + +### 8. `website/src/index.html` - Homepage hero (follow-up) + +Change the 5 publication logo links (`publications-btns` section, lines 135-151) from external URLs to `/links#link=semantic-slug`. The JS on the links page handles showing the correct page and scrolling to the item. + +## Not in scope + +- RSS feed for links +- Per-language page copies in web.sh (one page, i18n handles chrome translation via language dropdown) + +## Build flow + +``` +web.sh: + cp -R docs website/src # copies LINKS.md into src/docs/ + cp -R docs/links/images website/src/link-images + cd website + npm install + node merge_translations.js + node customize_docs_frontmatter.js + rm src/docs/LINKS.md # prevent doc collection processing + npm run build # .eleventy.js imports parse_links.js, + # reads src/docs/LINKS.md -> data, + # links.html renders from that data +``` + +Wait - there's a sequencing issue. If web.sh deletes LINKS.md before Eleventy runs, parse_links.js can't read it. Two options: +1. parse_links.js reads from `../docs/LINKS.md` (the original, not the copy) +2. web.sh deletes LINKS.md AFTER parse_links reads it but BEFORE Eleventy processes docs + +Option 1 is simpler - parse_links.js always reads from repo root `docs/LINKS.md`, not from `src/docs/`. Then web.sh just never copies it (or deletes it after `cp -R docs website/src`). + +Revised flow: +``` +web.sh: + cp -R docs website/src + rm website/src/docs/LINKS.md # immediately remove from src/docs/ + cp -R docs/links/images website/src/link-images + cd website + ...existing steps... + npm run build # parse_links.js reads ../docs/LINKS.md +``` diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index 37daa78d3c..34ee893dd3 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -110,6 +110,12 @@
+ +