diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4e5050fe8f..348413fc1f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -153,6 +153,8 @@ jobs: - name: Checkout Code if: matrix.should_run == true uses: actions/checkout@v3 + with: + submodules: recursive - name: Setup swap if: matrix.ghc == '8.10.7' && matrix.should_run == true @@ -498,6 +500,8 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v3 + with: + submodules: recursive - name: Prepare build uses: ./.github/actions/prepare-build @@ -607,6 +611,8 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v3 + with: + submodules: recursive - name: Prepare build uses: ./.github/actions/prepare-build diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000..38bfea49c2 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "apps/multiplatform/external/nanohttpd/upstream"] + path = apps/multiplatform/external/nanohttpd/upstream + url = https://github.com/NanoHttpd/nanohttpd diff --git a/README.md b/README.md index 5583fad0b5..f7c2ccd514 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ SimpleX logo -Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplexchat). +Invest in SimpleX Chat. [Learn more on Wefunder](https://wefunder.com/simplex.chat?utm_source=github). # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! @@ -220,6 +220,8 @@ You can use SimpleX with your own servers and still communicate with people usin Recent and important updates: +[Aug 20, 2026. Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./blog/20260819-simplex-chat-crowdfunding.md) + [Jul 22, 2026. SimpleX Public Names — a Name Nobody Can Take From You](./blog/20260722-simplex-public-names.md) [Apr 30, 2026. SimpleX Channels, SimpleX Network Consortium and Community Crowdfunding - to Preserve Freedom of Speech](./blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md) diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index fffff5e05d..85963202c4 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -21,6 +21,7 @@ enum ChatCommand: ChatCmdProtocol { case apiSetUserContactReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) case apiSetUserGroupReceipts(userId: Int64, userMsgReceiptSettings: UserMsgReceiptSettings) case apiSetUserAutoAcceptMemberContacts(userId: Int64, enable: Bool) + case apiSetUserAutoAcceptGroupInvitations(userId: Int64, enable: Bool) case apiHideUser(userId: Int64, viewPwd: String) case apiUnhideUser(userId: Int64, viewPwd: String) case apiMuteUser(userId: Int64) @@ -216,6 +217,8 @@ enum ChatCommand: ChatCmdProtocol { return "/_set receipts groups \(userId) \(onOff(umrs.enable)) clear_overrides=\(onOff(umrs.clearOverrides))" case let .apiSetUserAutoAcceptMemberContacts(userId, enable): return "/_set accept member contacts \(userId) \(onOff(enable))" + case let .apiSetUserAutoAcceptGroupInvitations(userId, enable): + return "/_set accept group invitations \(userId) \(onOff(enable))" case let .apiHideUser(userId, viewPwd): return "/_hide user \(userId) \(encodeJSON(viewPwd))" case let .apiUnhideUser(userId, viewPwd): return "/_unhide user \(userId) \(encodeJSON(viewPwd))" case let .apiMuteUser(userId): return "/_mute user \(userId)" @@ -434,6 +437,7 @@ enum ChatCommand: ChatCmdProtocol { case .apiSetUserContactReceipts: return "apiSetUserContactReceipts" case .apiSetUserGroupReceipts: return "apiSetUserGroupReceipts" case .apiSetUserAutoAcceptMemberContacts: return "apiSetUserAutoAcceptMemberContacts" + case .apiSetUserAutoAcceptGroupInvitations: return "apiSetUserAutoAcceptGroupInvitations" case .apiHideUser: return "apiHideUser" case .apiUnhideUser: return "apiUnhideUser" case .apiMuteUser: return "apiMuteUser" diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6b40b909d6..20efa8e461 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -302,6 +302,10 @@ func apiSetUserAutoAcceptMemberContacts(_ userId: Int64, enable: Bool) async thr try await sendCommandOkResp(.apiSetUserAutoAcceptMemberContacts(userId: userId, enable: enable)) } +func apiSetUserAutoAcceptGroupInvitations(_ userId: Int64, enable: Bool) async throws { + try await sendCommandOkResp(.apiSetUserAutoAcceptGroupInvitations(userId: userId, enable: enable)) +} + func apiHideUser(_ userId: Int64, viewPwd: String) async throws -> User { try await setUserPrivacy_(.apiHideUser(userId: userId, viewPwd: viewPwd)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 44284350dc..ac27cb27c2 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -75,7 +75,35 @@ struct FramedItemView: View { } }) } else if let itemForwarded = chatItem.meta.itemForwarded { - framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + let twoRowHeader: Bool = if chat.chatInfo.chatType == .local { + itemForwarded.chatTypeApiIdMsgId != nil || itemForwarded.sourceGroupLink != nil + } else { + switch itemForwarded { + case let .group(_, _, _, _, _, _, groupType): groupType != nil + case .groupLink: true + default: false + } + } + if twoRowHeader { + let caption: LocalizedStringKey = chat.chatInfo.chatType == .local ? "saved from" : "forwarded from" + headerFrame(pad: true) { + VStack(alignment: .leading, spacing: 4) { + headerRow(icon: "arrowshape.turn.up.forward", caption: Text(caption).italic()) + Text(itemForwarded.chatName) + .font(.subheadline) + .lineLimit(1) + } + } + .simultaneousGesture(TapGesture().onEnded { + if let (chatType, apiId, msgId) = itemForwarded.chatTypeApiIdMsgId { + im.loadOpenChatNoWait("\(chatType.rawValue)\(apiId)", msgId) + } else if let link = itemForwarded.sourceGroupLink { + planAndConnect(link, theme: theme, dismiss: false) + } + }) + } else { + framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) + } } ChatItemContentView(chat: chat, im: im, chatItem: chatItem, msgContentView: framedMsgContentView) @@ -191,8 +219,14 @@ struct FramedItemView: View { } } - @ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { - let v = HStack(spacing: 6) { + func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View { + headerFrame(pad: pad) { + headerRow(icon: icon, iconColor: iconColor, caption: caption) + } + } + + private func headerRow(icon: String?, iconColor: Color? = nil, caption: Text) -> some View { + HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) .resizable() @@ -204,13 +238,17 @@ struct FramedItemView: View { .font(.caption) .lineLimit(1) } - .foregroundColor(theme.colors.secondary) - .padding(.horizontal, 12) - .padding(.top, 6) - .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) - .overlay(DetermineWidth()) - .frame(minWidth: msgWidth, alignment: .leading) - .background(chatItemFrameContextColor(chatItem, theme)) + } + + @ViewBuilder private func headerFrame(pad: Bool = false, @ViewBuilder _ content: () -> some View) -> some View { + let v = content() + .foregroundColor(theme.colors.secondary) + .padding(.horizontal, 12) + .padding(.top, 6) + .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) + .overlay(DetermineWidth()) + .frame(minWidth: msgWidth, alignment: .leading) + .background(chatItemFrameContextColor(chatItem, theme)) if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { v.frame(maxWidth: mediaWidth, alignment: .leading) } else { diff --git a/apps/ios/Shared/Views/Helpers/ShareSheet.swift b/apps/ios/Shared/Views/Helpers/ShareSheet.swift index 670cc7cae0..56e437e5c8 100644 --- a/apps/ios/Shared/Views/Helpers/ShareSheet.swift +++ b/apps/ios/Shared/Views/Helpers/ShareSheet.swift @@ -142,6 +142,7 @@ class OpenChatAlertViewController: UIViewController { private let profileBadge: LocalBadge? private let subtitle: String? private let information: String? + private let secondaryInformation: Bool private let cancelTitle: String private let confirmTitle: String? private let secondTitle: String? @@ -156,6 +157,7 @@ class OpenChatAlertViewController: UIViewController { profileBadge: LocalBadge? = nil, subtitle: String? = nil, information: String? = nil, + secondaryInformation: Bool = false, cancelTitle: String = "Cancel", confirmTitle: String? = "Open", secondTitle: String? = nil, @@ -169,6 +171,7 @@ class OpenChatAlertViewController: UIViewController { self.profileBadge = profileBadge self.subtitle = subtitle self.information = information + self.secondaryInformation = secondaryInformation self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.secondTitle = secondTitle @@ -248,7 +251,7 @@ class OpenChatAlertViewController: UIViewController { let infoLabel = UILabel() infoLabel.text = information infoLabel.font = UIFont.preferredFont(forTextStyle: .footnote) - infoLabel.textColor = .label + infoLabel.textColor = secondaryInformation ? .secondaryLabel : .label infoLabel.numberOfLines = 3 infoLabel.textAlignment = .center infoLabel.translatesAutoresizingMaskIntoConstraints = false @@ -426,6 +429,7 @@ func showOpenChatAlert( theme: AppTheme, subtitle: String? = nil, information: String? = nil, + secondaryInformation: Bool = false, cancelTitle: String = "Cancel", confirmTitle: String? = "Open", secondTitle: String? = nil, @@ -446,6 +450,7 @@ func showOpenChatAlert( profileBadge: profileBadge, subtitle: subtitle, information: information, + secondaryInformation: secondaryInformation, cancelTitle: cancelTitle, confirmTitle: confirmTitle, secondTitle: secondTitle, diff --git a/apps/ios/Shared/Views/NewChat/NewChatView.swift b/apps/ios/Shared/Views/NewChat/NewChatView.swift index a87b9b46f4..f938fb0063 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatView.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatView.swift @@ -859,8 +859,8 @@ enum ConnectTarget { func strConnectTarget(_ str: String) -> ConnectTarget? { let parsedMd = parseSimpleXMarkdown(str) let links = parsedMd?.filter { $0.format?.isSimplexLink ?? false } ?? [] - return if links.count == 1, case let .simplexLink(_, linkType, _, smpHosts) = links[0].format { - .link(text: links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) + return if links.count == 1, case let .simplexLink(showText, linkType, simplexUri, smpHosts) = links[0].format { + .link(text: showText != nil ? simplexUri : links[0].text, linkType: linkType, linkText: simplexLinkText(linkType, smpHosts)) } else if links.isEmpty, let nameFt = parsedMd?.first(where: { if case .simplexName = $0.format { true } else { false } }), case let .simplexName(nameInfo) = nameFt.format { @@ -1195,8 +1195,8 @@ private func showPrepareGroupAlert( information: ownerVerificationMessage(ownerVerification), cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: isChannel - ? NSLocalizedString("Open new channel", comment: "new chat action") - : NSLocalizedString("Open new group", comment: "new chat action"), + ? NSLocalizedString("Open channel", comment: "new chat action") + : NSLocalizedString("Open group", comment: "new chat action"), secondTitle: connectOtherButton, onCancel: { cleanup?() }, onConfirm: { @@ -1259,6 +1259,20 @@ private func showOpenKnownContactAlert( ) } +private func memberRoleInformation(_ role: GroupMemberRole, isChannel: Bool) -> String { + switch role { + case .observer: isChannel + ? NSLocalizedString("You are a subscriber", comment: "new chat alert") + : NSLocalizedString("You are an observer", comment: "new chat alert") + case .moderator: NSLocalizedString("You are a moderator", comment: "new chat alert") + case .admin: NSLocalizedString("You are an admin", comment: "new chat alert") + case .owner: NSLocalizedString("You are an owner", comment: "new chat alert") + default: isChannel + ? NSLocalizedString("You are a contributor", comment: "new chat alert") + : NSLocalizedString("You are a member", comment: "new chat alert") + } +} + private func showOpenKnownGroupAlert( _ groupInfo: GroupInfo, theme: AppTheme, @@ -1278,18 +1292,16 @@ private func showOpenKnownGroupAlert( ), theme: theme, subtitle: groupInfo.useRelays ? subscriberCount : nil, + information: groupInfo.nextConnectPrepared || groupInfo.businessChat != nil + ? nil + : memberRoleInformation(groupInfo.membership.memberRole, isChannel: groupInfo.useRelays), + secondaryInformation: true, cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"), confirmTitle: groupInfo.useRelays - ? ( groupInfo.nextConnectPrepared - ? NSLocalizedString("Open new channel", comment: "new chat action") - : NSLocalizedString("Open channel", comment: "new chat action") - ) + ? NSLocalizedString("Open channel", comment: "new chat action") : groupInfo.businessChat == nil - ? ( groupInfo.nextConnectPrepared - ? NSLocalizedString("Open new group", comment: "new chat action") - : NSLocalizedString("Open group", comment: "new chat action") - ) + ? NSLocalizedString("Open group", comment: "new chat action") : ( groupInfo.nextConnectPrepared ? NSLocalizedString("Open new chat", comment: "new chat action") : NSLocalizedString("Open chat", comment: "new chat action") diff --git a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift index b7753e8539..ba87729012 100644 --- a/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift +++ b/apps/ios/Shared/Views/Onboarding/WhatsNewView.swift @@ -8,6 +8,7 @@ // Spec: spec/client/navigation.md import SwiftUI +import StoreKit import SimpleXChat private struct VersionDescription { @@ -41,6 +42,11 @@ private struct FeatureView { let view: () -> any View } +let isInUS = { + let code = SKStorefront().countryCode + return code == "USA" || code == "" +}() + private let versionDescriptions: [VersionDescription] = [ VersionDescription( version: "v4.2", @@ -665,9 +671,15 @@ private let versionDescriptions: [VersionDescription] = [ ] ), VersionDescription( - version: "v7.0", - post: nil, - features: [ + version: isInUS ? "v7.0.1" : "v7.0", + post: URL(string: "https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.html"), + features: (isInUS ? [ + .view(FeatureView( + icon: nil, + title: "You can now invest in SimpleX Chat", + view: { InvestInSimpleXChat() } + )) + ] : []) + [ .feature(Description( icon: "at", title: "SimpleX public names (BETA)", @@ -762,6 +774,134 @@ fileprivate struct CreateUpdateAddressShortLink: View { } } +fileprivate struct InvestInSimpleXChat: View { + @EnvironmentObject var theme: AppTheme + @State private var showGetStakeSheet = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text("You can now invest in SimpleX Chat! 🚀").font(.title3).bold() + (Text("Crowdfunding on Wefunder.") + Text(verbatim: " ") + Text("Learn more").foregroundColor(theme.colors.primary)) + .multilineTextAlignment(.leading) + .onTapGesture { showGetStakeSheet = true } + #if SIMPLEX_ASSETS + Image("crowdfunding_1") + .resizable() + .scaledToFit() + .cornerRadius(12) + .padding(.vertical, 4) + .onTapGesture { showGetStakeSheet = true } + #endif + } + .frame(maxWidth: .infinity, alignment: .leading) + .sheet(isPresented: $showGetStakeSheet) { + GetStakeView(fromSettings: false) + } + } +} + +fileprivate let getStakeSlides: [(image: String, heading: String, info: String?, text: String)] = [ + ( + "crowdfunding_1", + "The first and the only messaging network without any user IDs", + nil, + "By investing, you can benefit from the company growth, and help us build the future of private and secure communications." + ), + ( + "crowdfunding_2", + "480,000+ users joined on their own", + nil, + "SimpleX users have been more than doubling every year without any paid marketing, and donated over $650,000." + ), + ( + "crowdfunding_3", + "Developers already bet on SimpleX success", + "Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.", + "Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat." + ), + ( + "crowdfunding_4", + "Revenue plan: free for users, channels & businesses pay", + "SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.", + "Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder." + ), +] + +private let wefunderURL = URL(string: "https://wefunder.com/simplex.chat?utm_source=app")! + +private let simplexCrowdfundingURL = URL(string: "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im")! + +struct GetStakeView: View { + @Environment(\.dismiss) var dismiss: DismissAction + @EnvironmentObject var chatModel: ChatModel + var fromSettings: Bool + + var body: some View { + ZoomablePageView { + VStack(alignment: .leading, spacing: 18) { + Text(verbatim: "Get a stake in\nSimpleX Chat") + .font(.largeTitle) + .bold() + .fixedSize(horizontal: false, vertical: true) + .if(!fromSettings) { $0.padding(.top) } + if fromSettings { + slideImage(getStakeSlides[0]) + } + (Text(verbatim: getStakeSlides[0].text) + Text(verbatim: " Learn more and invest on Wefunder.").bold().foregroundColor(.accentColor)) + .multilineTextAlignment(.leading) + .onTapGesture { + UIApplication.shared.open(wefunderURL) + } + .padding(.bottom) + ForEach(getStakeSlides[1...3], id: \.image) { slide in + VStack(alignment: .leading) { + slideImage(slide) + Text(slide.text) + } + .padding(.bottom) + } + + Button { + UIApplication.shared.open(wefunderURL) + } label: { + Text(verbatim: "Learn more on Wefunder") + } + .buttonStyle(OnboardingButtonStyle()) + + Button { + dismiss() + DispatchQueue.main.async { + ChatModel.shared.appOpenUrl = simplexCrowdfundingURL + } + } label: { + Text(verbatim: "or ask SimpleX team") + .font(.callout) + } + .disabled(chatModel.chatRunning != true) + .frame(maxWidth: .infinity) + } + .padding() + } + .ignoresSafeArea(edges: .bottom) + .modifier(ThemedBackground(grouped: true)) + } + + @ViewBuilder + func slideImage(_ slide: (image: String, heading: String, info: String?, text: String?)) -> some View { + #if SIMPLEX_ASSETS + Image(slide.image) + .resizable() + .scaledToFit() + .cornerRadius(12) + #else + Text(slide.heading).font(.title3).bold() + if let info = slide.info { + Text(info) + } + #endif + } +} + private enum WhatsNewViewSheet: Identifiable { case showConditions diff --git a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift index d891efcd90..bf3dbac440 100644 --- a/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift +++ b/apps/ios/Shared/Views/UserSettings/PrivacySettings.swift @@ -37,6 +37,8 @@ struct PrivacySettings: View { @State private var groupReceiptsDialogue = false @State private var autoAcceptMemberContacts = false @State private var autoAcceptMemberContactsReset = false + @State private var autoAcceptGroupInvitations = false + @State private var autoAcceptGroupInvitationsReset = false @State private var alert: PrivacySettingsViewAlert? enum PrivacySettingsViewAlert: Identifiable { @@ -117,14 +119,17 @@ struct PrivacySettings: View { } Section { - settingsRow("checkmark", color: theme.colors.secondary) { - Toggle("Auto-accept", isOn: $autoAcceptMemberContacts) + settingsRow("person", color: theme.colors.secondary) { + Toggle("Contact requests in groups", isOn: $autoAcceptMemberContacts) + } + settingsRow("person.2", color: theme.colors.secondary) { + Toggle("Group invitations", isOn: $autoAcceptGroupInvitations) } } header: { - Text("Contact requests from groups") + Text("Auto-accept") .foregroundColor(theme.colors.secondary) } footer: { - Text("This setting is for your current profile **\(m.currentUser?.displayName ?? "")**.") + Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.") .foregroundColor(theme.colors.secondary) } @@ -139,7 +144,14 @@ struct PrivacySettings: View { if autoAcceptMemberContactsReset { autoAcceptMemberContactsReset = false } else { - setAutoAcceptGrpDirectInvs(autoAcceptMemberContacts) + setAutoAcceptMemberContacts(autoAcceptMemberContacts) + } + } + .onChange(of: autoAcceptGroupInvitations) { _ in + if autoAcceptGroupInvitationsReset { + autoAcceptGroupInvitationsReset = false + } else { + setAutoAcceptGroupInvitations(autoAcceptGroupInvitations) } } .onAppear { @@ -148,6 +160,10 @@ struct PrivacySettings: View { autoAcceptMemberContactsReset = true autoAcceptMemberContacts = u.autoAcceptMemberContacts } + if autoAcceptGroupInvitations != u.autoAcceptGroupInvitations { + autoAcceptGroupInvitationsReset = true + autoAcceptGroupInvitations = u.autoAcceptGroupInvitations + } } } .alert(item: $alert) { alert in @@ -426,7 +442,7 @@ struct PrivacySettings: View { } } - private func setAutoAcceptGrpDirectInvs(_ enable: Bool) { + private func setAutoAcceptMemberContacts(_ enable: Bool) { Task { do { if let currentUser = m.currentUser { @@ -443,6 +459,23 @@ struct PrivacySettings: View { } } + private func setAutoAcceptGroupInvitations(_ enable: Bool) { + Task { + do { + if let currentUser = m.currentUser { + try await apiSetUserAutoAcceptGroupInvitations(currentUser.userId, enable: enable) + await MainActor.run { + var updatedUser = currentUser + updatedUser.autoAcceptGroupInvitations = enable + m.updateUser(updatedUser) + } + } + } catch let error { + alert = .error(title: "Error setting auto-accept", error: "Error: \(responseError(error))") + } + } + } + private func simplexLockRow(_ value: LocalizedStringKey) -> some View { HStack { Text("SimpleX Lock") diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 15094577b1..f8340543fd 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -383,6 +383,17 @@ struct SettingsView: View { Text(verbatim: "v\(appVersion ?? "?")") } } + + if isInUS { + Section(header: Text("You can now invest in SimpleX Chat").foregroundColor(theme.colors.secondary)) { + NavigationLink { + GetStakeView(fromSettings: true) + .navigationBarTitle("", displayMode: .inline) + } label: { + settingsRow("dollarsign.circle", color: theme.colors.secondary) { Text("Crowdfunding on Wefunder") } + } + } + } } .navigationTitle("Your settings") .modifier(ThemedBackground(grouped: true)) diff --git a/apps/ios/Shared/Views/ZoomableScrollView.swift b/apps/ios/Shared/Views/ZoomableScrollView.swift index 83528b593a..87eb645822 100644 --- a/apps/ios/Shared/Views/ZoomableScrollView.swift +++ b/apps/ios/Shared/Views/ZoomableScrollView.swift @@ -58,3 +58,54 @@ struct ZoomableScrollView: UIViewRepresentable { } } } + +struct ZoomablePageView: UIViewRepresentable { + private var content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = UIScrollView() + scrollView.delegate = context.coordinator + scrollView.maximumZoomScale = 5 + scrollView.minimumZoomScale = 1 + scrollView.bouncesZoom = true + scrollView.backgroundColor = .clear + + let hostedView = context.coordinator.hostingController.view! + hostedView.backgroundColor = .clear + hostedView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(hostedView) + NSLayoutConstraint.activate([ + hostedView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + hostedView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + hostedView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + hostedView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + hostedView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor) + ]) + + return scrollView + } + + func makeCoordinator() -> Coordinator { + Coordinator(hostingController: UIHostingController(rootView: self.content)) + } + + func updateUIView(_ uiView: UIScrollView, context: Context) { + context.coordinator.hostingController.rootView = self.content + } + + class Coordinator: NSObject, UIScrollViewDelegate { + var hostingController: UIHostingController + + init(hostingController: UIHostingController) { + self.hostingController = hostingController + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + hostingController.view + } + } +} diff --git a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff index 0b7e24f040..d0e27058e8 100644 --- a/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff +++ b/apps/ios/SimpleX Localizations/bg.xcloc/Localized Contents/bg.xliff @@ -2429,8 +2429,8 @@ This is your own one-time link! Настройки за контакт No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2603,6 +2603,14 @@ This is your own one-time link! Линкът се създава… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Текущ kод за достъп @@ -4500,6 +4508,10 @@ Error: %2$@ Груповата покана вече е невалидна, премахната е от подателя. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Групов линк @@ -8989,10 +9001,6 @@ alert subtitle Тази настройка се прилага за съобщения в текущия ви профил **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9938,6 +9946,14 @@ Repeat join request? Вече можете да изпращате съобщения до %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff index 0549fdc20b..26c97d549d 100644 --- a/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff +++ b/apps/ios/SimpleX Localizations/cs.xcloc/Localized Contents/cs.xliff @@ -2333,8 +2333,8 @@ Toto je váš vlastní jednorázový odkaz! Předvolby kontaktů No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2499,6 +2499,14 @@ Toto je váš vlastní jednorázový odkaz! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktuální heslo @@ -4352,6 +4360,10 @@ Error: %2$@ Skupinová pozvánka již není platná, byla odstraněna odesílatelem. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Odkaz na skupinu @@ -8748,10 +8760,6 @@ alert subtitle Toto nastavení platí pro zprávy ve vašem aktuálním profilu chatu **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9655,6 +9663,14 @@ Repeat join request? Nyní můžete posílat zprávy %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff index 6fd8232e93..1265c782e0 100644 --- a/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff +++ b/apps/ios/SimpleX Localizations/de.xcloc/Localized Contents/de.xliff @@ -2427,7 +2427,7 @@ Das ist Ihr eigener Einmal-Link! Connection link removed - Verbindungsfehler + Verbindungslink entfernt conn error description @@ -2525,8 +2525,8 @@ Das ist Ihr eigener Einmal-Link! Kontakt-Präferenzen No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups KONTAKTANFRAGEN VON GRUPPEN No comment provided by engineer. @@ -2715,6 +2715,14 @@ Das ist Ihr eigener Einmal-Link! Link wird erstellt… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktueller Zugangscode @@ -4697,7 +4705,7 @@ Fehler: %2$@ Get SimpleX name (BETA) - SimpleX-Name erhalten (BETA) + Einen SimpleX-Namen erhalten (BETA) No comment provided by engineer. @@ -4770,6 +4778,10 @@ Fehler: %2$@ Die Gruppeneinladung ist nicht mehr gültig, da sie vom Absender entfernt wurde. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Gruppen-Link @@ -6423,7 +6435,7 @@ Die sicherste Verschlüsselung. Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life. - Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich. + Niemand verfolgte Ihre Gespräche. Niemand hat eine Karte erstellt, wo Sie überall waren. Privatsphäre war nie ein Feature – sie war eine Selbstverständlichkeit. No comment provided by engineer. @@ -7876,12 +7888,12 @@ swipe action Role will be changed to "%@". All chat members will be notified. - Die Rolle des Mitglieds wird auf "%@" geändert. Alle Chat-Mitglieder werden darüber informiert. + Die Rolle wird auf "%@" geändert. Alle Chat-Mitglieder werden darüber informiert. No comment provided by engineer. Role will be changed to "%@". All group members will be notified. - Die Mitgliederrolle wird auf "%@" geändert. Alle Gruppenmitglieder werden benachrichtigt. + Die Rolle wird auf "%@" geändert. Alle Gruppenmitglieder werden benachrichtigt. No comment provided by engineer. @@ -7891,7 +7903,7 @@ swipe action Role will be changed to "%@". The member will receive a new invitation. - Die Mitgliederrolle wird auf "%@" geändert. Das Mitglied wird eine neue Einladung erhalten. + Die Rolle wird auf "%@" geändert. Das Mitglied wird eine neue Einladung erhalten. No comment provided by engineer. @@ -9428,7 +9440,7 @@ Dies kann passieren, wenn es einen Fehler gegeben hat oder die Verbindung kompro The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. - Der SimpleX‑Name %@ wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte zu Ihrer Adresse oder zum Kanalprofil hinzufügen, sofern Sie der Besitzer sind. + Der SimpleX‑Name %@ wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte fügen Sie ihn zu Ihrer Adresse oder zum Kanalprofil hinzu, sofern Sie der Besitzer sind. No comment provided by engineer. @@ -9575,7 +9587,7 @@ in dem Sie Ihre Kontakte und Gruppen besitzen. The sender deleted the connection request. - Der Absender hat möglicherweise die Verbindungsanfrage gelöscht. + Der Absender hat die Verbindungsanfrage gelöscht. No comment provided by engineer. @@ -9734,11 +9746,6 @@ alert subtitle Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Diese Einstellung gilt für Ihr aktuelles Profil **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Die Zeit bis zum Verschwinden wird nur für neue Kontakte eingestellt. @@ -10769,6 +10776,14 @@ Verbindungsanfrage wiederholen? Sie können nun Nachrichten an %@ versenden notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Sie können aus den archivierten Kontakten heraus Nachrichten an %@ versenden. @@ -11058,8 +11073,8 @@ Verbindungsanfrage wiederholen? Your contact removed this link, or it was a one-time link that was already used. To connect, ask your contact to create a new link. - Entweder hat Ihr Kontakt die Verbindung gelöscht, oder dieser Link wurde bereits verwendet, es könnte sich um einen Fehler handeln - Bitte melden Sie es uns. -Bitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben. + Ihr Kontakt hat diesen Link entfernt oder es war ein Einmal‑Link, welcher bereits verwendet wurde. +Um sich zu verbinden, bitten Sie Ihren Kontakt, einen neuen Link zu erstellen. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff index 97a8d4879c..38e0ecfe85 100644 --- a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -2525,9 +2525,9 @@ This is your own one-time link! Contact preferences No comment provided by engineer. - - Contact requests from groups - Contact requests from groups + + Contact requests in groups + Contact requests in groups No comment provided by engineer. @@ -2715,6 +2715,16 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Current Passcode @@ -4770,6 +4780,11 @@ Error: %2$@ Group invitation is no longer valid, it was removed by sender. No comment provided by engineer. + + Group invitations + Group invitations + No comment provided by engineer. + Group link Group link @@ -9734,11 +9749,6 @@ alert subtitle This setting applies to messages in your current chat profile **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Time to disappear is set only for new contacts. @@ -10769,6 +10779,16 @@ Repeat join request? You can now chat with %@ notification body + + You can now invest in SimpleX Chat + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. You can send messages to %@ from Archived contacts. diff --git a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff index 6d140c7f78..60f0dd3e2d 100644 --- a/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff +++ b/apps/ios/SimpleX Localizations/es.xcloc/Localized Contents/es.xliff @@ -2525,8 +2525,8 @@ This is your own one-time link! Preferencias de contacto No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Solicitudes de contacto en grupo No comment provided by engineer. @@ -2715,6 +2715,14 @@ This is your own one-time link! Creando enlace… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Código de Acceso @@ -4770,6 +4778,10 @@ Error: %2$@ La invitación al grupo ya no es válida, ha sido eliminada por el remitente. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Enlace de grupo @@ -9734,11 +9746,6 @@ alert subtitle Esta configuración se aplica a los mensajes del perfil actual **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Esta configuración se aplica al perfil actual **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Mensajes temporales activados sólo para los contactos nuevos. @@ -10769,6 +10776,14 @@ Repeat join request? Ya puedes chatear con %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Puedes enviar mensajes a %@ desde Contactos archivados. diff --git a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff index cd6030b2be..78caebe522 100644 --- a/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff +++ b/apps/ios/SimpleX Localizations/fi.xcloc/Localized Contents/fi.xliff @@ -2220,8 +2220,8 @@ This is your own one-time link! Kontaktin asetukset No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2386,6 +2386,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Nykyinen pääsykoodi @@ -4236,6 +4244,10 @@ Error: %2$@ Ryhmäkutsu ei ole enää voimassa, lähettäjä poisti sen. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Ryhmälinkki @@ -8623,10 +8635,6 @@ alert subtitle Tämä asetus koskee nykyisen keskusteluprofiilisi viestejä *%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9529,6 +9537,14 @@ Repeat join request? Voit nyt lähettää viestejä %@:lle notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff index cf8eafaac6..78e8e30096 100644 --- a/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff +++ b/apps/ios/SimpleX Localizations/fr.xcloc/Localized Contents/fr.xliff @@ -202,18 +202,22 @@ %d owner + %d propriétaire channel owners count %d owners + %d propriétaires channel owners count %d owners & contributors + %d propriétaires & contributeurs channel members count %d relays failed + %d relais ont échoué channel relay bar channel subscriber relay bar @@ -272,11 +276,13 @@ channel relay bar progress %1$d/%2$d relays active, %3$d failed + %1$d/%2$d relais actifs, %3$d en échec channel creation progress with errors channel relay bar %1$d/%2$d relays active, %3$d removed + %1$d/%2$d relais actifs, %3$d supprimé(s) channel relay bar @@ -291,10 +297,12 @@ channel relay bar %1$d/%2$d relays connected, %3$d failed + %1$d/%2$d relais connectés, %3$d ont échoué channel subscriber relay bar %1$d/%2$d relays connected, %3$d removed + %1$d/%2$d relais connectés, %3$d retirés channel subscriber relay bar @@ -639,7 +647,7 @@ time interval A separate TCP connection will be used **for each chat profile you have in the app**. - Une connexion TCP distincte sera utilisée **pour chaque profil de discussion que vous avez dans l'application**. + Une connexion TCP distincte sera utilisée **pour chaque profil de messagerie que vous avez dans l'application**. No comment provided by engineer. @@ -765,10 +773,12 @@ swipe action Add contributors. + Ajouter des contributeurs. No comment provided by engineer. Add description + Ajouter une description No comment provided by engineer. @@ -928,12 +938,12 @@ swipe action All chats and messages will be deleted - this cannot be undone! - Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! + Toutes les conversations et tous les messages seront supprimés - il est impossible de revenir en arrière ! No comment provided by engineer. All chats will be removed from the list %@, and the list deleted. - Toutes les discussions seront supprimées de la liste %@ et la liste sera supprimée. + Toutes les conversations seront supprimées de la liste %@ et la liste sera supprimée. alert message @@ -1188,7 +1198,7 @@ swipe action An empty chat profile with the provided name is created, and the app opens as usual. - Un profil de discussion vierge portant le nom fourni est créé et l'application s'ouvre normalement. + Un profil de messagerie vierge portant le nom fourni est créé et l'application s'ouvre normalement. No comment provided by engineer. @@ -1403,7 +1413,7 @@ swipe action Bad desktop address - Mauvaise adresse de bureau + Adresse du PC incorrecte No comment provided by engineer. @@ -1424,6 +1434,8 @@ swipe action Be free in your network + Soyez libre +au sein de votre réseau No comment provided by engineer. @@ -1433,6 +1445,7 @@ in your network Because we destroyed the power to know who you are. So that your power can never be taken. + Parce que nous avons détruit le pouvoir de vous identifier. Pour que votre pouvoir ne puisse jamais vous être enlevé. No comment provided by engineer. @@ -1442,6 +1455,7 @@ in your network Better channels 📢 + De meilleurs canaux 📢 No comment provided by engineer. @@ -1536,6 +1550,7 @@ in your network Block subscriber for all? + Bloquer l'abonné pour tout le monde ? No comment provided by engineer. @@ -1555,6 +1570,7 @@ in your network Bot + Bot No comment provided by engineer. @@ -1579,6 +1595,7 @@ in your network Both you and your contact can send files and media. + Vous pouvez tous deux envoyer des fichiers et des médias. No comment provided by engineer. @@ -1593,6 +1610,7 @@ in your network Broadcast + Diffusion compose placeholder for channel owner @@ -1607,7 +1625,7 @@ in your network Business chats - Discussions professionnelles + Conversations professionnelles No comment provided by engineer. @@ -1622,7 +1640,7 @@ in your network By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). - Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). + Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA). No comment provided by engineer. @@ -1729,7 +1747,7 @@ new chat action Change chat profiles - Changer de profil de discussion + Changer les profils de messagerie authentication reason @@ -1764,6 +1782,7 @@ new chat action Change role? + Changer le rôle ? No comment provided by engineer. @@ -1784,6 +1803,7 @@ set passcode view Channel SimpleX name + Nom du canal SimpleX No comment provided by engineer. @@ -1824,7 +1844,7 @@ alert subtitle Channel profile is stored on subscribers' devices and on the chat relays. - Le profil du canal est stocké sur les périphériques des abonné·es et sur les relais de discussion. + Le profil du canal est stocké sur les périphériques des abonné·es et sur les relais de messagerie. No comment provided by engineer. @@ -1864,72 +1884,72 @@ alert subtitle Chat - Discussions + Conversation No comment provided by engineer. Chat already exists - La discussion existe déjà + La conversation existe déjà No comment provided by engineer. Chat already exists! - La discussion existe déjà ! + La conversation existe déjà ! new chat sheet title Chat colors - Couleurs de chat + Couleurs de conversation No comment provided by engineer. Chat console - Console du chat + Console de la messagerie No comment provided by engineer. Chat data - Données de la discussion + Données de la conversation No comment provided by engineer. Chat database - Base de données du chat + Base de données de la messagerie No comment provided by engineer. Chat database deleted - Base de données du chat supprimée + Base de données de la messagerie supprimée No comment provided by engineer. Chat database exported - Exportation de la base de données des discussions + Base de données de la messagerie exportée No comment provided by engineer. Chat database imported - Base de données du chat importée + Base de données de la messagerie importée No comment provided by engineer. Chat is running - Le chat est en cours d'exécution + La messagerie est en fonctionnement No comment provided by engineer. Chat is stopped - Le chat est arrêté + La messagerie est arrêtée No comment provided by engineer. Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat. - La discussion est arrêtée. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer la discussion. + La messagerie est arrêtée. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer la messagerie. No comment provided by engineer. Chat list - Liste de discussion + Liste de conversation No comment provided by engineer. @@ -1939,52 +1959,52 @@ alert subtitle Chat preferences - Préférences de chat + Préférences de conversation No comment provided by engineer. Chat preferences were changed. - Les préférences de discussion ont été modifiées. + Les préférences de la conversation ont été modifiées. alert message Chat profile - Profil d'utilisateur + Profil de messagerie No comment provided by engineer. Chat relay - Relais de la discussion + Relais de messagerie No comment provided by engineer. Chat relays - Relais de la discussion + Relais de messagerie No comment provided by engineer. Chat relays forward messages in channels you create. - Les relais de discussion transmettent les messages dans les canaux que vous créez. + Les relais de messagerie transmettent les messages dans les canaux que vous créez. No comment provided by engineer. Chat relays forward messages to channel subscribers. - Les relais de discussion transmettent les messages aux abonné·es du canal. + Les relais de messagerie transmettent les messages aux abonné·es du canal. No comment provided by engineer. Chat theme - Thème de chat + Thème de la conversation No comment provided by engineer. Chat will be deleted for all members - this cannot be undone! - La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! + La conversation sera supprimée pour tous les membres - cela ne peut pas être annulé ! No comment provided by engineer. Chat will be deleted for you - this cannot be undone! - Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière ! + La conversation sera supprimée pour vous - il n'est pas possible de revenir en arrière ! No comment provided by engineer. @@ -2005,27 +2025,27 @@ chat toolbar Chats - Discussions + Conversations No comment provided by engineer. Chats with admins are prohibited. - Les discussions avec les admins sont interdites. + Les conversations avec les admins sont interdites. No comment provided by engineer. Chats with admins in public channels have no E2E encryption - use only with trusted chat relays. - Les discussions avec les admins dans les canaux publics n'ont pas de chiffrement E2E ; à utiliser uniquement avec des relais de discussion fiables. + Les conversations avec les admins dans les canaux publics n'ont pas de chiffrement de bout en bout ; à utiliser uniquement avec des relais de messagerie fiables. alert message Chats with members - Discussions avec les membres + Conversations avec les membres No comment provided by engineer. Chats with members are disabled - Les discussions avec les membres sont désactivées + Les conversations avec les membres sont désactivées No comment provided by engineer. @@ -2125,7 +2145,7 @@ chat toolbar Color chats with the new themes. - Colorez vos discussions avec les nouveaux thèmes. + Colorez vos conversations avec les nouveaux thèmes. No comment provided by engineer. @@ -2271,11 +2291,12 @@ server test step Connect to %@ + Se connecter à %@ new chat action Connect to desktop - Connexion au bureau + Connexion à un PC No comment provided by engineer. @@ -2329,7 +2350,7 @@ Il s'agit de votre propre lien unique ! Connected desktop - Bureau connecté + Ordinateur connecté No comment provided by engineer. @@ -2339,7 +2360,7 @@ Il s'agit de votre propre lien unique ! Connected to desktop - Connecté au bureau + Connecté au PC No comment provided by engineer. @@ -2364,7 +2385,7 @@ Il s'agit de votre propre lien unique ! Connecting to desktop - Connexion au bureau + Connexion au PC No comment provided by engineer. @@ -2384,6 +2405,7 @@ Il s'agit de votre propre lien unique ! Connection blocked: %@ + Connexion bloquée : %@ conn error description @@ -2445,7 +2467,7 @@ Il s'agit de votre propre lien unique ! Connection with desktop stopped - La connexion avec le bureau s'est arrêtée + La connexion au PC s'est arrêtée No comment provided by engineer. @@ -2503,8 +2525,8 @@ Il s'agit de votre propre lien unique ! Préférences de contact No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Demandes de contact des groupes No comment provided by engineer. @@ -2590,6 +2612,7 @@ Il s'agit de votre propre lien unique ! Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting. + Créez une page d’aperçu de votre canal pour les visiteurs avant qu'ils ne s'abonnent. Hébergez-la vous-même ou via un service statique. No comment provided by engineer. @@ -2639,6 +2662,7 @@ Il s'agit de votre propre lien unique ! Create web preview. + Créer un aperçu de canal. No comment provided by engineer. @@ -2691,6 +2715,14 @@ Il s'agit de votre propre lien unique ! Création d'un lien… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Code d'accès actuel @@ -2812,7 +2844,7 @@ Il s'agit de votre propre lien unique ! Database passphrase is required to open chat. - La phrase secrète de la base de données est nécessaire pour ouvrir le chat. + La phrase secrète de la base de données est nécessaire pour ouvrir la messagerie. No comment provided by engineer. @@ -2907,32 +2939,32 @@ swipe action Delete chat - Supprimer la discussion + Supprimer la conversation No comment provided by engineer. Delete chat messages from your device. - Supprimer les messages de chat de votre appareil. + Supprimer les messages de messagerie de votre appareil. No comment provided by engineer. Delete chat profile - Supprimer le profil de chat + Supprimer le profil de messagerie No comment provided by engineer. Delete chat profile? - Supprimer le profil du chat ? + Supprimer le profil de messagerie ? No comment provided by engineer. Delete chat with member? - Supprimer la discussion avec le membre ? + Supprimer la conversation avec le membre ? alert title Delete chat? - Supprimer la discussion ? + Supprimer la conversation ? No comment provided by engineer. @@ -2972,7 +3004,7 @@ swipe action Delete files for all chat profiles - Effacer les fichiers de tous les profils de chat + Effacer les fichiers de tous les profils de messagerie No comment provided by engineer. @@ -3163,7 +3195,7 @@ alert button Desktop address - Adresse de bureau + Adresse du PC No comment provided by engineer. @@ -3173,7 +3205,7 @@ alert button Desktop devices - Appareils de bureau + Ordinateurs No comment provided by engineer. @@ -3238,7 +3270,7 @@ alert button Direct messages between members are prohibited in this chat. - Les messages directs entre membres sont interdits dans cette discussion. + Les messages directs entre membres sont interdits dans cette conversation. No comment provided by engineer. @@ -3298,7 +3330,7 @@ alert button Disappearing messages are prohibited in this chat. - Les messages éphémères sont interdits dans cette discussion. + Les messages éphémères sont interdits dans cette conversation. No comment provided by engineer. @@ -3323,7 +3355,7 @@ alert button Disconnect desktop? - Déconnecter le bureau ? + Déconnecter le PC ? No comment provided by engineer. @@ -3353,11 +3385,12 @@ alert button Do it later - Faites-le plus tard + Reporter l'opération No comment provided by engineer. Do not require signing messages. + Ne pas exiger la signature des messages. No comment provided by engineer. @@ -3397,6 +3430,7 @@ alert button Don't save + Ne pas enregistrer alert action @@ -3411,7 +3445,7 @@ alert button Downgrade and open chat - Rétrograder et ouvrir le chat + Rétrograder et ouvrir la messagerie No comment provided by engineer. @@ -3482,6 +3516,7 @@ chat item action Easier to read. + Plus facile à lire. No comment provided by engineer. @@ -3496,6 +3531,7 @@ chat item action Edit description + Modifier la description No comment provided by engineer. @@ -3535,7 +3571,7 @@ chat item action Enable at least one chat relay in Network & Servers. - Activez au moins un relais de discussion dans Réseaux et serveurs. + Activez au moins un relais de messagerie dans Réseaux et serveurs. channel creation warning @@ -3550,7 +3586,7 @@ chat item action Enable chats with admins? - Activer les discussions avec les admins ? + Activer les conversations avec les admins ? alert title @@ -3700,6 +3736,7 @@ chat item action Enter description (optional) + Saisir une description (facultatif) placeholder @@ -3819,7 +3856,7 @@ chat item action Error changing chat profile - Erreur lors du changement du profil de discussion + Erreur lors du changement du profil de messagerie alert title @@ -3909,17 +3946,17 @@ chat item action Error deleting chat - Erreur lors de la suppression de la discussion + Erreur lors de la suppression de la conversation alert title Error deleting chat database - Erreur lors de la suppression de la base de données du chat + Erreur lors de la suppression de la base de données de la messagerie alert title Error deleting chat! - Erreur lors de la suppression du chat ! + Erreur lors de la suppression de la conversation ! alert title @@ -3974,7 +4011,7 @@ chat item action Error exporting chat database - Erreur lors de l'exportation de la base de données du chat + Erreur lors de l'exportation de la base de données de la messagerie alert title @@ -3984,7 +4021,7 @@ chat item action Error importing chat database - Erreur lors de l'importation de la base de données du chat + Erreur lors de l'importation de la base de données de la messagerie alert title @@ -4004,7 +4041,7 @@ chat item action Error opening chat - Erreur lors de l'ouverture du chat + Erreur lors de l'ouverture de la conversation No comment provided by engineer. @@ -4059,7 +4096,7 @@ chat item action Error saving chat list - Erreur lors de l'enregistrement de la liste des chats + Erreur lors de l'enregistrement de la liste des conversations alert title @@ -4069,6 +4106,7 @@ chat item action Error saving name + Erreur d'enregistrement du nom alert title @@ -4128,6 +4166,7 @@ chat item action Error sharing address + Erreur du partage d'adresse alert title @@ -4137,12 +4176,12 @@ chat item action Error starting chat - Erreur lors du démarrage du chat + Erreur lors du démarrage de la messagerie No comment provided by engineer. Error stopping chat - Erreur lors de l'arrêt du chat + Erreur lors de l'arrêt de la messagerie No comment provided by engineer. @@ -4355,10 +4394,12 @@ server test error File servers + Serveurs de fichiers No comment provided by engineer. File servers: %@ + Serveurs de fichiers : %@ copied message info @@ -4408,7 +4449,7 @@ server test error Files and media are prohibited in this chat. - Les fichiers et médias sont interdits dans cette discussion. + Les fichiers et médias sont interdits dans cette conversation. No comment provided by engineer. @@ -4433,7 +4474,7 @@ server test error Filter unread and favorite chats. - Filtrer les messages non lus et favoris. + Filtrer les conversations non lues et favorites. No comment provided by engineer. @@ -4453,26 +4494,28 @@ server test error Find chats faster - Recherche de message plus rapide + Trouvez vos conversations plus rapidement No comment provided by engineer. Fingerprint in destination server address does not match certificate: %@. - L'empreinte dans l'adresse du serveur de destination ne correspond pas au certificat : %@. + L'empreinte numérique dans l'adresse du serveur de destination ne correspond pas au certificat : %@. No comment provided by engineer. Fingerprint in forwarding server address does not match certificate: %@. + L'empreinte numérique dans l'adresse du serveur de transfert ne correspond pas au certificat : %@. No comment provided by engineer. Fingerprint in server address does not match certificate. - Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte + L’empreinte numérique de l’adresse du serveur ne correspond pas au certificat. relay test error server test error Fingerprint in server address does not match certificate: %@. + L'empreinte numérique dans l'adresse du serveur ne correspond pas au certificat : %@. No comment provided by engineer. @@ -4507,6 +4550,7 @@ server test error For all moderators + Pour tous les modérateurs No comment provided by engineer. @@ -4516,7 +4560,7 @@ server test error For chat profile %@: - Pour le profil de discussion %@ : + Pour le profil de messagerie %@ : servers error servers warning @@ -4621,7 +4665,7 @@ Erreur : %2$@ Found desktop - Bureau trouvé + PC trouvé No comment provided by engineer. @@ -4661,6 +4705,7 @@ Erreur : %2$@ Get SimpleX name (BETA) + Obtenir un nom SimpleX (BETA) No comment provided by engineer. @@ -4733,6 +4778,10 @@ Erreur : %2$@ L'invitation du groupe n'est plus valide, elle a été supprimé par l'expéditeur. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Lien du groupe @@ -4820,7 +4869,7 @@ Erreur : %2$@ Hidden chat profiles - Profils de chat cachés + Profils de messagerie cachés No comment provided by engineer. @@ -4890,6 +4939,7 @@ Erreur : %2$@ How to register a test name + Comment enregistrer un nom de test No comment provided by engineer. @@ -4939,7 +4989,7 @@ Erreur : %2$@ If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app). - Si vous avez besoin d'utiliser le chat maintenant appuyez sur **le faire plus tard** (vous pourrez migrer la base de données quand vous relancerez l'appli). + Si vous devez utiliser la conversation maintenant, appuyez sur **Reporter l'opération** ci-dessous (la migration de la base de données vous sera proposée au redémarrage de l'application). No comment provided by engineer. @@ -4974,7 +5024,7 @@ Erreur : %2$@ Import chat database? - Importer la base de données du chat ? + Importer la base de données de la messagerie ? No comment provided by engineer. @@ -5021,7 +5071,7 @@ D'autres améliorations sont à venir ! In order to continue, chat should be stopped. - Pour continuer, le chat doit être interrompu. + Pour continuer, la messagerie doit être interrompue. No comment provided by engineer. @@ -5258,7 +5308,7 @@ D'autres améliorations sont à venir ! Irreversible message deletion is prohibited in this chat. - La suppression irréversible de message est interdite dans ce chat. + La suppression irréversible de message est interdite dans cette conversation. No comment provided by engineer. @@ -5268,7 +5318,7 @@ D'autres améliorations sont à venir ! It allows having many anonymous connections without any shared data between them in a single chat profile. - Cela permet d'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil. + Cela permet d'avoir plusieurs connexions anonymes sans aucune donnée partagée entre elles dans un même profil de messagerie. No comment provided by engineer. @@ -5329,6 +5379,7 @@ D'autres améliorations sont à venir ! Join channel %@ + Rejoindre le canal %@ new chat action @@ -5380,7 +5431,7 @@ Voici votre lien pour le groupe %@ ! Keep your chats clean - Gardez vos discussions propres + Gardez vos conversations propres No comment provided by engineer. @@ -5430,12 +5481,12 @@ Voici votre lien pour le groupe %@ ! Leave chat - Quitter la discussion + Quitter la conversation No comment provided by engineer. Leave chat? - Quitter la discussion ? + Quitter la conversation ? No comment provided by engineer. @@ -5455,10 +5506,12 @@ Voici votre lien pour le groupe %@ ! Let people connect to you via name registered with your SimpleX address. + Permettez aux gens de se connecter à vous via le nom associé à votre adresse SimpleX. No comment provided by engineer. Let people join via name registered with this channel link. + Laissez les gens rejoindre via le nom enregistré avec ce lien de canal. No comment provided by engineer. @@ -5483,7 +5536,7 @@ Voici votre lien pour le groupe %@ ! Link mobile and desktop apps! 🔗 - Liez vos applications mobiles et de bureau ! 🔗 + Liez les applications mobiles et de bureau ! 🔗 No comment provided by engineer. @@ -5493,12 +5546,12 @@ Voici votre lien pour le groupe %@ ! Linked desktop options - Options de bureau lié + Options d'ordinateur lié No comment provided by engineer. Linked desktops - Bureaux liés + Ordinateurs liés No comment provided by engineer. @@ -5573,6 +5626,7 @@ Voici votre lien pour le groupe %@ ! Manage your relays. + Gérer vos relais. No comment provided by engineer. @@ -5647,7 +5701,7 @@ Voici votre lien pour le groupe %@ ! Member will be removed from chat - this cannot be undone! - Le membre sera retiré de la discussion - cela ne peut pas être annulé ! + Le membre sera retiré de la conversation - cette action est irréversible ! alert message @@ -5677,7 +5731,7 @@ Voici votre lien pour le groupe %@ ! Members can report messsages to moderators. - Les membres peuvent signaler les messages aux modérateur·ices. + Les membres peuvent signaler les messages aux modérateurs. No comment provided by engineer. @@ -5767,7 +5821,7 @@ Voici votre lien pour le groupe %@ ! Message reactions are prohibited in this chat. - Les réactions aux messages sont interdites dans ce chat. + Les réactions aux messages sont interdites dans cette conversation. No comment provided by engineer. @@ -5792,10 +5846,12 @@ Voici votre lien pour le groupe %@ ! Message signing is not required. + La signature des messages n'est pas requise. No comment provided by engineer. Message signing is required. + La signature des messages est requise. No comment provided by engineer. @@ -5845,17 +5901,17 @@ Voici votre lien pour le groupe %@ ! Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages. - Les messages dans ce canal **ne sont pas chiffrés de bout-en-bout**. Les relais de discussion peuvent voir ces messages. + Les messages dans ce canal **ne sont pas chiffrés de bout-en-bout**. Les relais de messagerie peuvent voir ces messages. No comment provided by engineer. Messages in this channel are not end-to-end encrypted. Chat relays can see these messages. - Les messages dans ce canal ne sont pas chiffrés de bout-en-bout. Les relais de discussion peuvent voir ces messages. + Les messages dans ce canal ne sont pas chiffrés de bout-en-bout. Les relais de messagerie peuvent voir ces messages. E2EE info chat item Messages in this chat will never be deleted. - Les messages dans cette discussion ne seront jamais supprimés. + Les messages dans cette conversation ne seront jamais supprimés. alert message @@ -5930,7 +5986,7 @@ Voici votre lien pour le groupe %@ ! Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat). - Échec de la migration. Appuyez sur **Passer** ci-dessous pour continuer à utiliser la base de données actuelle. Veuillez signaler le problème aux développeurs de l'appli par discussion ou par courriel [chat@simplex.chat](mailto:chat@simplex.chat). + Échec de la migration. Appuyez sur **Ignorer** ci-dessous pour continuer à utiliser la base de données actuelle. Veuillez signaler le problème aux développeurs de l'application via une conversation ou par email à [chat@simplex.chat](mailto:chat@simplex.chat). No comment provided by engineer. @@ -5990,7 +6046,7 @@ Voici votre lien pour le groupe %@ ! Multiple chat profiles - Différents profils de chat + Plusieurs profils de messagerie No comment provided by engineer. @@ -6015,6 +6071,7 @@ Voici votre lien pour le groupe %@ ! Name not found + Nom introuvable No comment provided by engineer. @@ -6101,17 +6158,17 @@ qui parle à qui New chat - Nouvelle discussion + Nouvelle conversation No comment provided by engineer. New chat experience 🎉 - Nouvelle expérience de discussion 🎉 + Nouvelle expérience de conversation 🎉 No comment provided by engineer. New chat relay - Nouveau relais de discussion + Nouveau relais de messagerie No comment provided by engineer. @@ -6141,6 +6198,7 @@ qui parle à qui New group role: Moderator + Nouveau rôle de groupe : Modérateur No comment provided by engineer. @@ -6207,32 +6265,32 @@ Le chiffrement le plus sûr. No chat relays - Aucun relais de discussion + Aucun relais de messagerie No comment provided by engineer. No chat relays enabled. - Aucun relais de discussion disponible. + Aucun relais de messagerie n'est activé. servers warning No chats - Aucune discussion + Aucune conversation No comment provided by engineer. No chats found - Aucune discussion trouvée + Aucune conversation trouvée No comment provided by engineer. No chats in list %@ - Aucune discussion dans la liste %@ + Aucune conversation dans la liste %@ No comment provided by engineer. No chats with members - Aucune discussion avec les membres + Aucune conversation avec les membres No comment provided by engineer. @@ -6262,7 +6320,7 @@ Le chiffrement le plus sûr. No filtered chats - Aucune discussion filtrés + Aucune conversation filtrée No comment provided by engineer. @@ -6352,6 +6410,7 @@ Le chiffrement le plus sûr. No servers to resolve names. + Aucun serveur pour résoudre les noms. servers warning @@ -6366,11 +6425,12 @@ Le chiffrement le plus sûr. No unread chats - Aucune discussion non lue + Aucune conversation non lue No comment provided by engineer. No valid link + Aucun lien valide No comment provided by engineer. @@ -6385,6 +6445,7 @@ Le chiffrement le plus sûr. None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link. + Aucun serveur de résolution de noms SimpleX n'est configuré. Configurez des serveurs ou utilisez un lien de connexion. No comment provided by engineer. @@ -6640,12 +6701,12 @@ alert button Open chat - Ouvrir le chat + Ouvrir la conversation new chat action Open chat console - Ouvrir la console du chat + Ouvrir la console de la messagerie authentication reason @@ -6690,7 +6751,7 @@ alert button Open new chat - Ouvrir une nouvelle discussion + Ouvrir une nouvelle conversation new chat action @@ -6786,7 +6847,7 @@ alert button Organize chats into lists - Organisez des discussions en listes + Organisez des conversations en listes No comment provided by engineer. @@ -6808,6 +6869,7 @@ alert button Owners & contributors + Propriétaires et contributeurs No comment provided by engineer. @@ -6862,7 +6924,7 @@ alert button Paste desktop address - Coller l'adresse du bureau + Coller l'adresse du PC No comment provided by engineer. @@ -6907,7 +6969,7 @@ alert button Play from the chat list. - Aperçu depuis la liste de conversation. + Lire depuis la liste des conversations. No comment provided by engineer. @@ -6923,8 +6985,8 @@ alert button Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection. Please share any other issues with the developers. - Veuillez vérifier que le téléphone portable et l'ordinateur de bureau sont connectés au même réseau local et que le pare-feu de l'ordinateur de bureau autorise la connexion. -Veuillez faire part de tout autre problème aux développeurs. + Vérifiez que le mobile et l’ordinateur sont connectés au même réseau local et que le pare-feu de l’ordinateur autorise la connexion. +Veuillez signaler tout autre problème aux développeurs. No comment provided by engineer. @@ -6986,7 +7048,7 @@ Erreur : %@ Please store passphrase securely, you will NOT be able to access chat if you lose it. - Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez. + Conservez votre phrase secrète en lieu sûr, vous ne pourrez PAS accéder à vos conversations si vous la perdez. No comment provided by engineer. @@ -7001,7 +7063,7 @@ Erreur : %@ Please wait for group moderators to review your request to join the group. - Veuillez attendre que les modérateur·ices de groupe examinent votre demande pour rejoindre le groupe. + Veuillez attendre que les modérateurs de groupe examinent votre demande pour rejoindre le groupe. snd group event chat item @@ -7177,7 +7239,7 @@ alert title Prohibit reporting messages to moderators. - Interdire de signaler des messages aux modérateur·ices. + Interdire de signaler des messages aux modérateurs. No comment provided by engineer. @@ -7229,7 +7291,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Protect your chat profiles with a password! - Protégez vos profils de discussion par un mot de passe ! + Protégez vos profils de messagerie par un mot de passe ! No comment provided by engineer. @@ -7269,6 +7331,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Public names for your channel or business. + Noms publics pour votre canal ou votre entreprise. No comment provided by engineer. @@ -7293,7 +7356,7 @@ Activez-le dans les paramètres *Réseau et serveurs*. Reachable chat toolbar - Barre d'outils accessible + Barre d’outils de conversation accessible No comment provided by engineer. @@ -7515,18 +7578,22 @@ swipe action Relay test failed! + Échec du test de relais ! No comment provided by engineer. Relay will be removed from channel - this cannot be undone! + Le relais sera supprimé du canal ; cette action est irréversible ! alert message Relays added: %@. + Relais ajoutés : %@. alert message Reliability: many relays per channel. + Fiabilité : plusieurs relais par canal. No comment provided by engineer. @@ -7536,6 +7603,7 @@ swipe action Remove and delete messages + Retirer et supprimer des messages alert action @@ -7565,6 +7633,7 @@ swipe action Remove name + Retirer le nom No comment provided by engineer. @@ -7574,18 +7643,22 @@ swipe action Remove relay + Retirer le relais No comment provided by engineer. Remove relay? + Retirer le relais ? alert title Remove subscriber? + Supprimer l'abonné ? alert title Removes messages and blocks members. + Supprime les messages et bloque les membres. No comment provided by engineer. @@ -7670,7 +7743,7 @@ swipe action Reporting messages to moderators is prohibited. - Signaler des messages aux modérateur·ices est interdit. + Signaler des messages aux modérateurs est interdit. No comment provided by engineer. @@ -7680,6 +7753,7 @@ swipe action Require signing messages. + Exiger la signature des messages. No comment provided by engineer. @@ -7729,16 +7803,17 @@ swipe action Resolver error: %@ + Erreur de résolution : %@ No comment provided by engineer. Restart the app to create a new chat profile - Redémarrez l'appli pour créer un nouveau profil de discussion + Redémarrez l'appli pour créer un nouveau profil de messagerie No comment provided by engineer. Restart the app to use imported chat database - Redémarrez l'application pour utiliser la base de données de chat importée + Redémarrez l’application pour utiliser la base de données de conversations importée No comment provided by engineer. @@ -7813,7 +7888,7 @@ swipe action Role will be changed to "%@". All chat members will be notified. - Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés. + Le rôle du membre sera modifié pour « %@ ». Tous les membres de la conversation seront notifiés. No comment provided by engineer. @@ -7823,6 +7898,7 @@ swipe action Role will be changed to "%@". All subscribers will be notified. + Le rôle sera remplacé par « %@ ». Tous les abonnés en seront informés. No comment provided by engineer. @@ -7832,7 +7908,7 @@ swipe action Run chat - Exécuter le chat + Lancer la messagerie No comment provided by engineer. @@ -7884,6 +7960,7 @@ chat item action Save SimpleX name? + Enregistrer le nom SimpleX ? alert title @@ -7948,7 +8025,7 @@ chat item action Save passphrase and open chat - Enregistrer la phrase secrète et ouvrir le chat + Enregistrer la phrase secrète et ouvrir la messagerie No comment provided by engineer. @@ -8033,7 +8110,7 @@ chat item action Scan QR code from desktop - Scannez le code QR du bureau + Scannez le QR code du PC No comment provided by engineer. @@ -8128,7 +8205,7 @@ chat item action Select chat profile - Sélectionner un profil de discussion + Sélectionner un profil de messagerie No comment provided by engineer. @@ -8138,7 +8215,7 @@ chat item action Selected chat preferences prohibit this message. - Les préférences de chat sélectionnées interdisent ce message. + Les préférences de conversation sélectionnées interdisent ce message. No comment provided by engineer. @@ -8288,7 +8365,7 @@ chat item action Sending delivery receipts will be enabled for all contacts in all visible chat profiles. - L'envoi d'accusés de réception sera activé pour tous les contacts dans tous les profils de chat visibles. + L'envoi d'accusés de réception sera activé pour tous les contacts dans tous les profils de messagerie visibles. No comment provided by engineer. @@ -8378,6 +8455,7 @@ chat item action Server %@ does not support name resolution. Configure servers, or use a connection link. + Le serveur %@ ne prend pas en charge la résolution de noms. Configurez des serveurs ou utilisez un lien de connexion. No comment provided by engineer. @@ -8477,7 +8555,7 @@ chat item action Set chat name… - Paramétrer le nom de la discussion… + Définir le nom de la conversation… No comment provided by engineer. @@ -8507,7 +8585,7 @@ chat item action Set message expiration in chats. - Paramétrer l'expiration des messages dans les discussions. + Définir l’expiration des messages dans les conversations. No comment provided by engineer. @@ -8648,7 +8726,7 @@ chat item action Share via chat - Partager via la discussion + Partager via la conversation No comment provided by engineer. @@ -8693,6 +8771,7 @@ chat item action Show encryption + Afficher le chiffrement No comment provided by engineer. @@ -8727,27 +8806,33 @@ chat item action Sign message + Signer le message No comment provided by engineer. Sign messages + Signer les messages chat feature Signature missing + Signature manquante alert title copied message info Signed + Signé copied message info Signed & verified + Signé et vérifié copied message info Signing proves you authored this message and can't be denied later. + La signature atteste que vous êtes l'auteur de ce message, de manière irrévocable. No comment provided by engineer. @@ -8762,7 +8847,7 @@ copied message info SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app. - SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application. + SimpleX Chat et Flux ont conclu un accord pour intégrer les serveurs opérés par Flux à l’application. No comment provided by engineer. @@ -8847,14 +8932,17 @@ copied message info SimpleX name + Nom SimpleX No comment provided by engineer. SimpleX name error + Échec du nom SimpleX No comment provided by engineer. SimpleX name not verified + Nom SimpleX non vérifié alert title @@ -8869,6 +8957,7 @@ copied message info SimpleX public names (BETA) + Noms publics SimpleX (BETA) No comment provided by engineer. @@ -8888,7 +8977,7 @@ copied message info Skip - Passer + Ignorer No comment provided by engineer. @@ -8918,7 +9007,7 @@ copied message info Some non-fatal errors occurred during import - you may see Chat console for more details. - Des erreurs non fatales se sont produites lors de l'importation - vous pouvez consulter la console de chat pour plus de détails. + Certaines erreurs non fatales sont survenues lors de l’importation : consultez la console des conversations pour plus de détails. No comment provided by engineer. @@ -8956,12 +9045,12 @@ report reason Start chat - Démarrer la discussion + Démarrer la messagerie No comment provided by engineer. Start chat? - Démarrer la discussion ? + Démarrer la messagerie ? No comment provided by engineer. @@ -8996,17 +9085,17 @@ report reason Stop chat - Arrêter la discussion + Arrêter la messagerie No comment provided by engineer. Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped. - Arrêtez la discussion pour exporter, importer ou supprimer la base de données de la discussion. Vous ne pourrez pas recevoir et envoyer de messages pendant que la discussion est arrêtée. + Arrêtez la conversation pour exporter, importer ou supprimer la base de données des conversations. Vous ne pourrez pas recevoir ni envoyer de messages tant que la conversation est arrêtée. No comment provided by engineer. Stop chat? - Arrêter la discussion ? + Arrêter la messagerie ? No comment provided by engineer. @@ -9026,17 +9115,17 @@ report reason Stop sharing - Cesser le partage + Arrêter le partage alert action Stop sharing address? - Cesser le partage d'adresse ? + Arrêter le partage d'adresse ? alert title Stopping chat - Arrêt du chat + Arrêt de la messagerie No comment provided by engineer. @@ -9066,55 +9155,69 @@ report reason Subscriber reports + Signalements d'abonnés chat feature Subscriber will be removed from channel - this cannot be undone! + L'abonné sera supprimé du canal ; cette action est irréversible ! alert message Subscribers + Abonnés No comment provided by engineer. Subscribers can add message reactions. + Les abonnés peuvent ajouter des réactions aux messages. No comment provided by engineer. Subscribers can chat with admins. + Les abonnés peuvent discuter avec les admins. No comment provided by engineer. Subscribers can irreversibly delete sent messages. (24 hours) + Les abonnés peuvent supprimer définitivement les messages envoyés. (24 heures) No comment provided by engineer. Subscribers can report messsages to moderators. + Les abonnés peuvent signaler les messages aux modérateurs. No comment provided by engineer. Subscribers can send SimpleX links. + Les abonnés peuvent envoyer des liens SimpleX. No comment provided by engineer. Subscribers can send direct messages. + Les abonnés peuvent envoyer des messages directs. No comment provided by engineer. Subscribers can send disappearing messages. + Les abonnés peuvent envoyer des messages éphémères. No comment provided by engineer. Subscribers can send files and media. + Les abonnés peuvent envoyer des fichiers et des médias. No comment provided by engineer. Subscribers can send voice messages. + Les abonnés peuvent envoyer des messages vocaux. No comment provided by engineer. Subscribers use relay link to connect to the channel. Relay address was used to set up this relay for the channel. + Les abonnés utilisent le lien du relais pour se connecter au canal. +L'adresse du relais a été utilisée pour configurer ce relais pour le canal. No comment provided by engineer. @@ -9129,6 +9232,7 @@ Relay address was used to set up this relay for the channel. Support the project + Soutenez le projet No comment provided by engineer. @@ -9138,7 +9242,7 @@ Relay address was used to set up this relay for the channel. Switch chat profile for 1-time invitations. - Changer de profil de chat pour les invitations à usage unique. + Changer de profil de messagerie pour les invitations à usage unique. No comment provided by engineer. @@ -9158,6 +9262,7 @@ Relay address was used to set up this relay for the channel. TCP connection bg timeout + Connexion TCP en arrière-plan : délai dépassé No comment provided by engineer. @@ -9167,6 +9272,7 @@ Relay address was used to set up this relay for the channel. TCP port for messaging + Port TCP pour la messagerie No comment provided by engineer. @@ -9201,22 +9307,27 @@ Relay address was used to set up this relay for the channel. Tap Connect to chat + Appuyez sur « Se connecter » pour discuter No comment provided by engineer. Tap Connect to send request + Appuyez sur « Se connecter » pour envoyer la requête No comment provided by engineer. Tap Connect to use bot + Appuyez sur « Se connecter » pour utiliser le bot No comment provided by engineer. Tap Join channel + Appuyez sur Rejoindre le canal No comment provided by engineer. Tap Join group + Appuyez sur Rejoindre le groupe No comment provided by engineer. @@ -9246,6 +9357,7 @@ Relay address was used to set up this relay for the channel. Tap to open + Appuyez pour ouvrir No comment provided by engineer. @@ -9271,10 +9383,12 @@ server test failure Test notifications + Notifications de test No comment provided by engineer. Test relay + Tester le relais No comment provided by engineer. @@ -9299,7 +9413,7 @@ server test failure Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)! - Merci aux utilisateurs - [contribuer via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) ! + Merci aux utilisateurs - [contribuer via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) ! No comment provided by engineer. @@ -9316,22 +9430,27 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page. + Le nom SimpleX #%@ est enregistré sans lien de canal. Ajoutez un lien de canal via la page d'enregistrement. alert message The SimpleX name %@ is registered, but it has no valid link. + Le nom SimpleX %@ est enregistré, mais ne possède aucun lien valide. No comment provided by engineer. The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner. + Le nom SimpleX %@ est enregistré, mais n'a pas été ajouté au profil. Ajoutez-le à votre adresse ou au profil du canal si vous en êtes le propriétaire. No comment provided by engineer. The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page. + Le nom SimpleX @%@ est enregistré sans adresse SimpleX. Ajoutez votre adresse à ce nom via la page d'enregistrement. alert message The address will be short, and your profile will be shared via the address. + L'adresse sera courte et votre profil sera partagé via cette adresse. alert message @@ -9346,6 +9465,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The app removed this message after %lld attempts to receive it. + L'application a supprimé ce message après %lld tentatives de réception. No comment provided by engineer. @@ -9360,10 +9480,12 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge. + Le badge est signé avec une clé que cette version de l'application ne reconnaît pas. Mettez l'application à jour pour vérifier ce badge. badge alert The channel required this message to be signed, but the signature is missing. + Le canal exige que ce message soit signé, mais la signature est absente. alert message @@ -9373,6 +9495,7 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The connection reached the limit of undelivered messages + La connexion a atteint la limite de messages non distribués conn error description @@ -9403,6 +9526,8 @@ Cela peut se produire en raison d'un bug ou lorsque la connexion est compromise. The first network where you own your contacts and groups. + Le premier réseau où vous possédez +vos contacts et vos groupes. No comment provided by engineer. @@ -9412,6 +9537,7 @@ your contacts and groups. The link will be short, and group profile will be shared via the link. + Le lien sera court et le profil du groupe sera partagé via ce lien. alert message @@ -9441,6 +9567,7 @@ your contacts and groups. The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it. + La plus ancienne des libertés humaines : parler à une autre personne sans être surveillé, sur une infrastructure qui ne peut pas la trahir. No comment provided by engineer. @@ -9470,12 +9597,12 @@ your contacts and groups. The servers for new connections of your current chat profile **%@**. - Les serveurs pour les nouvelles connexions de votre profil de chat actuel **%@**. + Les serveurs pour les nouvelles connexions de votre profil de messagerie actuel **%@**. No comment provided by engineer. The servers for new files of your current chat profile **%@**. - Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel **%@**. + Les serveurs pour les nouveaux fichiers de votre profil de messagerie actuel **%@**. No comment provided by engineer. @@ -9500,6 +9627,7 @@ your contacts and groups. There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected. + Il existe une autre voie. Un réseau sans numéro de téléphone. Sans nom d'utilisateur. Sans compte. Sans aucune identité utilisateur. Un réseau qui relie les personnes et transporte des messages chiffrés sans savoir qui est connecté. No comment provided by engineer. @@ -9519,6 +9647,7 @@ your contacts and groups. This SimpleX name is not registered. Please check the name. + Ce nom SimpleX n'est pas enregistré. Vérifiez-le. No comment provided by engineer. @@ -9533,6 +9662,7 @@ your contacts and groups. This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted. + Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. alert message @@ -9542,16 +9672,17 @@ your contacts and groups. This badge could not be verified and may not be genuine. + Ce badge n'a pas pu être vérifié et pourrait ne pas être authentique. badge alert This chat is protected by end-to-end encryption. - Cette discussion est protégée par un chiffrement de bout en bout. + Cette conversation est protégée par un chiffrement de bout en bout. E2EE info chat item This chat is protected by quantum resistant end-to-end encryption. - Cette discussion est protégée par un chiffrement de bout en bout résistant aux technologies quantiques. + Cette conversation est protégée par un chiffrement de bout en bout résistant aux attaques quantiques. E2EE info chat item @@ -9576,23 +9707,28 @@ your contacts and groups. This group requires a newer version of the app. Please update the app to join. + Ce groupe nécessite une version plus récente. Mettez l'application à jour pour le rejoindre. alert message alert subtitle This is a chat relay address, it cannot be used to connect. + C'est une adresse de relais de messagerie, elle ne permet pas de se connecter. alert message This is the last active relay. Removing it will prevent message delivery to subscribers. + Il s'agit du dernier relais actif. Le supprimer empêchera la distribution des messages aux abonnés. alert message This is your link for channel %@! + Voici votre lien pour le canal %@ ! new chat action This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link. + Ce lien nécessite une version plus récente. Mettez l'application à jour ou demandez à votre contact d'envoyer un lien compatible. No comment provided by engineer. @@ -9602,15 +9738,12 @@ alert subtitle This message was deleted or not received yet. + Ce message a été supprimé ou n'a pas encore été reçu. No comment provided by engineer. This setting applies to messages in your current chat profile **%@**. - Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**. - No comment provided by engineer. - - - This setting is for your current profile **%@**. + Ce paramètre s'applique aux messages de votre profil de messagerie actuel **%@**. No comment provided by engineer. @@ -9640,6 +9773,7 @@ alert subtitle To make SimpleX Network last. + Pour que le réseau SimpleX perdure. No comment provided by engineer. @@ -9696,11 +9830,12 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To resolve names + Pour résoudre les noms No comment provided by engineer. To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page. - Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de discussion**. + Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de messagerie**. No comment provided by engineer. @@ -9710,15 +9845,17 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To send commands you must be connected. + Vous devez être connecté pour envoyer des commandes. alert message To support instant push notifications the chat database has to be migrated. - Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée. + Pour prendre en charge les notifications push instantanées, la base de données de messagerie doit être migrée. No comment provided by engineer. To use another profile after connection attempt, delete the chat and use the link again. + Pour utiliser un autre profil après une tentative de connexion, supprimez la conversation et utilisez à nouveau le lien. alert message @@ -9733,6 +9870,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s To verify keys with this subscriber, compare (or scan) the code on your devices. + Pour vérifier les clés avec cet abonné, comparez (ou scannez) le code sur vos appareils. No comment provided by engineer. @@ -9742,6 +9880,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Token status: %@. + Statut du jeton : %@. token status @@ -9771,6 +9910,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Trying to connect to the server used to receive messages from this connection. + Tentative de connexion au serveur utilisé pour recevoir les messages de cette connexion. subscription status explanation @@ -9820,10 +9960,12 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unblock subscriber for all? + Débloquer l'abonné pour tout le monde ? No comment provided by engineer. Unconfirmed name + Nom non confirmé No comment provided by engineer. @@ -9848,7 +9990,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unhide chat profile - Dévoiler le profil de chat + Afficher le profil de messagerie No comment provided by engineer. @@ -9893,7 +10035,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unlink desktop? - Délier le bureau ? + Dissocier le PC ? No comment provided by engineer. @@ -9918,10 +10060,12 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Unsupported connection link + Lien de connexion non pris en charge conn error description Unverified badge + Badge non vérifié badge alert title @@ -9931,6 +10075,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Up to 100 last messages are sent to new subscribers. + Jusqu'à 100 messages récents sont envoyés aux nouveaux abonnés. No comment provided by engineer. @@ -9955,6 +10100,7 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Updated conditions + Conditions mises à jour No comment provided by engineer. @@ -9964,32 +10110,38 @@ Vous serez invité à confirmer l'authentification avant que cette fonction ne s Upgrade + Mettre à jour alert button Upgrade address + Mettre à jour l'adresse No comment provided by engineer. Upgrade address? + Mettre à jour l'adresse ? alert message alert title Upgrade and open chat - Mettre à niveau et ouvrir le chat + Mettre à jour et ouvrir la messagerie No comment provided by engineer. Upgrade group link? + Mettre à jour le lien du groupe ? alert message Upgrade link + Lien de mise à jour No comment provided by engineer. Upgrade your address + Mettre à jour votre adresse No comment provided by engineer. @@ -10044,10 +10196,12 @@ alert title Use TCP port %@ when no port is specified. + Utiliser le port TCP %@ lorsqu'aucun port n'est spécifié. No comment provided by engineer. Use TCP port 443 for preset servers only. + Utiliser le port TCP 443 uniquement pour les serveurs prédéfinis. No comment provided by engineer. @@ -10067,6 +10221,7 @@ alert title Use for new channels + Utiliser pour les nouveaux canaux No comment provided by engineer. @@ -10076,7 +10231,7 @@ alert title Use from desktop - Accès au bureau + Utiliser depuis un ordinateur No comment provided by engineer. @@ -10086,6 +10241,7 @@ alert title Use incognito profile + Utiliser le profil incognito No comment provided by engineer. @@ -10110,6 +10266,7 @@ alert title Use relay + Utiliser un relais No comment provided by engineer. @@ -10139,10 +10296,12 @@ alert title Use web port + Utiliser le port Web No comment provided by engineer. Used chat relays do not support webpages. + Les relais de messagerie utilisés ne prennent pas en charge les pages Web. No comment provided by engineer. @@ -10167,11 +10326,12 @@ alert title Verify SimpleX names + Vérifier les noms SimpleX No comment provided by engineer. Verify code with desktop - Vérifier le code avec le bureau + Vérifier le code avec le PC No comment provided by engineer. @@ -10196,6 +10356,7 @@ alert title Verify name + Vérifier le nom No comment provided by engineer. @@ -10270,7 +10431,7 @@ alert title Voice messages are prohibited in this chat. - Les messages vocaux sont interdits dans ce chat. + Les messages vocaux sont interdits dans cette conversation. No comment provided by engineer. @@ -10310,7 +10471,7 @@ alert title Waiting for desktop... - En attente du bureau... + En attente du PC... No comment provided by engineer. @@ -10340,7 +10501,7 @@ alert title Warning: starting chat on multiple devices is not supported and will cause message delivery failures - Attention : démarrer une session de discussion sur plusieurs appareils n'est pas pris en charge et entraînera des dysfonctionnements au niveau de la transmission des messages + Avertissement : démarrer la messagerie sur plusieurs appareils n’est pas pris en charge et entraînera des échecs de distribution des messages No comment provided by engineer. @@ -10350,6 +10511,7 @@ alert title We made connecting simpler for new users. + Nous avons simplifié la connexion pour les nouveaux utilisateurs. No comment provided by engineer. @@ -10359,10 +10521,12 @@ alert title Webpage code + Code de la page Web No comment provided by engineer. Webpage settings were changed. If you save, the updated settings will be sent to subscribers. + Les paramètres de la page Web ont été modifiés. Si vous enregistrez, ils seront envoyés aux abonnés. alert message @@ -10412,6 +10576,7 @@ alert title Why SimpleX is built. + Pourquoi SimpleX a été créé. No comment provided by engineer. @@ -10421,7 +10586,7 @@ alert title Will be enabled in direct chats! - Activé dans les discussions directes ! + Activé dans les conversations directes ! No comment provided by engineer. @@ -10496,7 +10661,7 @@ alert title You already have a chat profile with the same display name. Please choose another name. - Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom. + Vous avez déjà un profil de messagerie avec le même nom d’affichage. Veuillez choisir un autre nom. No comment provided by engineer. @@ -10543,6 +10708,7 @@ Répéter la demande d'adhésion ? You are connected to the server used to receive messages from this connection. + Vous êtes connecté au serveur utilisé pour recevoir les messages de cette connexion. subscription status explanation @@ -10552,6 +10718,7 @@ Répéter la demande d'adhésion ? You are not connected to the server used to receive messages from this connection (no subscription). + Vous n'êtes pas connecté au serveur utilisé pour recevoir les messages de cette connexion (aucun abonnement). subscription status explanation @@ -10586,6 +10753,7 @@ Répéter la demande d'adhésion ? You can enable them later via app Your privacy settings. + Vous pourrez les activer plus tard dans les paramètres « Votre vie privée ». No comment provided by engineer. @@ -10608,6 +10776,14 @@ Répéter la demande d'adhésion ? Vous pouvez maintenant envoyer des messages à %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Vous pouvez envoyer des messages à %@ à partir des contacts archivés. @@ -10625,6 +10801,7 @@ Répéter la demande d'adhésion ? You can share a link or a QR code - anybody will be able to join the channel. + Vous pouvez partager un lien ou un QR code : n'importe qui pourra rejoindre le canal. No comment provided by engineer. @@ -10639,16 +10816,17 @@ Répéter la demande d'adhésion ? You can start chat via app Settings / Database or by restarting the app - Vous pouvez lancer le chat via Paramètres / Base de données ou en redémarrant l'app + Vous pouvez lancer la messagerie via Paramètres / Base de données ou en redémarrant l'app No comment provided by engineer. You can still view conversation with %@ in the list of chats. - Vous pouvez toujours voir la conversation avec %@ dans la liste des discussions. + Vous pouvez toujours voir la conversation avec %@ dans la liste des conversations. No comment provided by engineer. You can support SimpleX starting from v7 of the app. + Vous pouvez soutenir SimpleX à partir de la version 7 de l'application. badge alert @@ -10668,6 +10846,7 @@ Répéter la demande d'adhésion ? You can view your reports in Chat with admins. + Vous pouvez consulter vos rapports dans la section Discuter avec les admins. alert message @@ -10679,10 +10858,14 @@ Répéter la demande d'adhésion ? You commit to: - Only legal content in public groups - Respect other users - no spam + Vous vous engagez à : +- Ne publier que du contenu légal dans les groupes publics +- Respecter les autres utilisateurs ; pas de spam No comment provided by engineer. You connected to the channel via this relay link. + Vous vous êtes connecté au canal via ce lien du relais. No comment provided by engineer. @@ -10729,7 +10912,7 @@ Répéter la demande de connexion ? You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts. - Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts. + Vous devez utiliser la version la plus récente de votre base de données de messagerie sur UN SEUL appareil, sinon vous risquez de ne plus recevoir les messages de certains contacts. No comment provided by engineer. @@ -10754,14 +10937,17 @@ Répéter la demande de connexion ? You should receive notifications. + Vous devriez recevoir des notifications. token info You were born without an account + Vous êtes né sans compte No comment provided by engineer. You will be able to send messages **only after your request is accepted**. + Vous pourrez envoyer des messages **uniquement après l'acceptation de votre demande**. No comment provided by engineer. @@ -10796,16 +10982,17 @@ Répéter la demande de connexion ? You will stop receiving messages from this channel. Chat history will be preserved. + Vous ne recevrez plus de messages provenant de ce canal. L'historique des conversations sera conservé. No comment provided by engineer. You will stop receiving messages from this chat. Chat history will be preserved. - Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé. + Vous ne recevrez plus de messages de cette conversation. L'historique sera préservé. No comment provided by engineer. You will stop receiving messages from this group. Chat history will be preserved. - Vous ne recevrez plus de messages de ce groupe. L'historique du chat sera conservé. + Vous ne recevrez plus de messages de ce groupe. L'historique de la conversation sera conservé. No comment provided by engineer. @@ -10835,6 +11022,7 @@ Répéter la demande de connexion ? Your SimpleX name + Votre nom SimpleX No comment provided by engineer. @@ -10849,25 +11037,27 @@ Répéter la demande de connexion ? Your channel + Votre canal No comment provided by engineer. Your chat database is not encrypted - set passphrase to encrypt it. - Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète. + Votre base de données de la messagerie n'est pas chiffrée : définissez une phrase secrète. No comment provided by engineer. Your chat preferences - Vos préférences de discussion + Vos préférences de conversation alert title Your chat profiles - Vos profils de discussion + Vos profils de messagerie No comment provided by engineer. Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile. + Votre conversation a été déplacée vers %@, mais une erreur inattendue s'est produite lors de la redirection vers le profil. alert message @@ -10904,6 +11094,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public. + Vos conversations vous appartiennent, comme avant Internet. Le réseau n'est pas un lieu que vous visitez, mais un lieu que vous créez et possédez. Et personne ne peut vous le retirer, que vous le rendiez privé ou public. No comment provided by engineer. @@ -10913,7 +11104,7 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Your current chat database will be DELETED and REPLACED with the imported one. - Votre base de données de chat actuelle va être SUPPRIMEE et REMPLACEE par celle importée. + Votre base de données de messagerie actuelle va être SUPPRIMÉE et REMPLACÉE par celle importée. No comment provided by engineer. @@ -10934,6 +11125,8 @@ Pour vous connecter, veuillez demander à votre contact de créer un autre lien Your new channel %1$@ is connected to %2$d of %3$d relays. If you cancel, the channel will be deleted - you can create it again. + Votre nouveau canal %1$@ est connecté à %2$d relais sur %3$d. +Si vous annulez, le canal sera supprimé : vous pourrez le recréer. alert message @@ -10954,6 +11147,8 @@ If you cancel, the channel will be deleted - you can create it again. Your profile **%@** will be shared with channel relays and subscribers. Relays can access channel messages. + Votre profil **%@** sera partagé avec les relais et les abonnés du canal. +Les relais peuvent accéder aux messages du canal. No comment provided by engineer. @@ -11058,6 +11253,7 @@ Relays can access channel messages. acknowledged roster + liste reconnue No comment provided by engineer. @@ -11087,6 +11283,7 @@ Relays can access channel messages. all + tous member criteria value @@ -11106,6 +11303,7 @@ Relays can access channel messages. archived report + signalement archivé No comment provided by engineer. @@ -11176,10 +11374,12 @@ marked deleted chat item preview text can't broadcast + impossible de diffuser No comment provided by engineer. can't send messages + impossible d'envoyer des messages No comment provided by engineer. @@ -11234,7 +11434,7 @@ marked deleted chat item preview text connect to SimpleX Chat developers. - se connecter aux developpeurs de SimpleX Chat. + se connecter aux développeurs de SimpleX Chat. No comment provided by engineer. @@ -11319,10 +11519,12 @@ marked deleted chat item preview text contact should accept… + Le contact devrait accepter… No comment provided by engineer. contributor + contributeur member role @@ -11373,6 +11575,7 @@ pref value deleted channel + canal supprimé rcv group event chat item @@ -11487,6 +11690,7 @@ pref value error: %@ + erreur : %@ receive error chat item @@ -11496,6 +11700,7 @@ pref value failed + échec No comment provided by engineer. @@ -11515,6 +11720,7 @@ pref value group is deleted + le groupe est supprimé No comment provided by engineer. @@ -11529,6 +11735,7 @@ pref value https:// + https:// No comment provided by engineer. @@ -11568,12 +11775,12 @@ pref value invalid chat - chat invalide + conversation invalide invalid chat data invalid chat data - données de chat invalides + données de conversation invalides No comment provided by engineer. @@ -11683,7 +11890,7 @@ pref value moderator - modérateur·ice + modérateur member role @@ -11975,6 +12182,7 @@ dernier message reçu : %2$@ subscriber + abonné member role @@ -12201,7 +12409,7 @@ dernier message reçu : %2$@ SimpleX uses local network access to allow using user chat profile via desktop app on the same network. - SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau. + SimpleX utilise l’accès au réseau local pour permettre l’utilisation du profil de messagerie sur l’app de bureau du même réseau. Privacy - Local Network Usage Description @@ -12265,7 +12473,7 @@ dernier message reçu : %2$@ From %d chat(s) - De %d discussion(s) + De %d conversation(s) notification body @@ -12369,7 +12577,7 @@ dernier message reçu : %2$@ Database passphrase is required to open chat. - La phrase secrète de la base de données est nécessaire pour ouvrir le chat. + La phrase secrète de la base de données est nécessaire pour ouvrir la messagerie. No comment provided by engineer. @@ -12449,7 +12657,7 @@ dernier message reçu : %2$@ Selected chat preferences prohibit this message. - Les paramètres de chat sélectionnés ne permettent pas l'envoi de ce message. + Les paramètres de conversation sélectionnés ne permettent pas l'envoi de ce message. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff index b3facbe03e..4663996480 100644 --- a/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff +++ b/apps/ios/SimpleX Localizations/hu.xcloc/Localized Contents/hu.xliff @@ -2525,8 +2525,8 @@ Ez a saját egyszer használható meghívója! Partnerbeállítások No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Partneri kapcsolatkérések a csoportokból No comment provided by engineer. @@ -2715,6 +2715,14 @@ Ez a saját egyszer használható meghívója! Hivatkozás létrehozása… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Jelenlegi jelkód @@ -4770,6 +4778,10 @@ Hiba: %2$@ A csoportmeghívó már nem érvényes, a küldője eltávolította. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Csoporthivatkozás @@ -9734,11 +9746,6 @@ alert subtitle Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik. No comment provided by engineer. - - This setting is for your current profile **%@**. - Ez a beállítás csak a jelenlegi **%@** nevű csevegési profiljára vonatkozik. - No comment provided by engineer. - Time to disappear is set only for new contacts. Az üzeneteltűnési idő csak az új partnerekre vonatkozik. @@ -10769,6 +10776,14 @@ Megismétli a csatlakozási kérést? Mostantól küldhet üzeneteket %@ számára notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Az „Archivált partnerekből” továbbra is küldhet üzeneteket neki: %@. diff --git a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff index 5381b8cbb5..80f6c9a224 100644 --- a/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff +++ b/apps/ios/SimpleX Localizations/it.xcloc/Localized Contents/it.xliff @@ -2525,8 +2525,8 @@ Questo è il tuo link una tantum! Preferenze del contatto No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Richieste di contatto dai gruppi No comment provided by engineer. @@ -2715,6 +2715,14 @@ Questo è il tuo link una tantum! Creazione link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Codice di accesso attuale @@ -4770,6 +4778,10 @@ Errore: %2$@ L'invito al gruppo non è più valido, è stato rimosso dal mittente. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Link del gruppo @@ -9734,11 +9746,6 @@ alert subtitle Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Questa impostazione è per il tuo profilo attuale **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Il tempo di scomparsa è impostato solo per i contatti nuovi. @@ -10769,6 +10776,14 @@ Ripetere la richiesta di ingresso? Ora puoi inviare messaggi a %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Puoi inviare messaggi a %@ dai contatti archiviati. diff --git a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff index c8b2285649..345e6836a6 100644 --- a/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff +++ b/apps/ios/SimpleX Localizations/ja.xcloc/Localized Contents/ja.xliff @@ -2325,8 +2325,8 @@ This is your own one-time link! 連絡先の設定 No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2491,6 +2491,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode 現在のパスコード @@ -4353,6 +4361,10 @@ Error: %2$@ グループ招待が無効となり、送信元によって取り消されました。 No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link グループのリンク @@ -8736,10 +8748,6 @@ alert subtitle この設定は現在のチャットプロフィール **%@** のメッセージに適用されます。 No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9643,6 +9651,14 @@ Repeat join request? %@ にメッセージを送信できるようになりました notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff index 11f81cf90b..5ebd47c99a 100644 --- a/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff +++ b/apps/ios/SimpleX Localizations/nl.xcloc/Localized Contents/nl.xliff @@ -2422,8 +2422,8 @@ Dit is uw eigen eenmalige link! Contact voorkeuren No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2603,6 +2603,14 @@ Dit is uw eigen eenmalige link! Link maken… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Huidige toegangscode @@ -4605,6 +4613,10 @@ Fout: %2$@ Groep uitnodiging is niet meer geldig, deze is verwijderd door de afzender. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Groep link @@ -9353,10 +9365,6 @@ alert subtitle Deze instelling is van toepassing op berichten in je huidige chatprofiel **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -10349,6 +10357,14 @@ Deelnameverzoek herhalen? Je kunt nu berichten sturen naar %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. U kunt berichten naar %@ sturen vanuit gearchiveerde contacten. diff --git a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff index 19401459d5..68f836184f 100644 --- a/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff +++ b/apps/ios/SimpleX Localizations/pl.xcloc/Localized Contents/pl.xliff @@ -2439,8 +2439,8 @@ To jest twój jednorazowy link! Preferencje kontaktu No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Prośby o kontakt od grup No comment provided by engineer. @@ -2622,6 +2622,14 @@ To jest twój jednorazowy link! Tworzenie linku… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Aktualny Pin @@ -4641,6 +4649,10 @@ Błąd: %2$@ Zaproszenie do grupy jest już nieważne, zostało usunięte przez nadawcę. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Link do grupy @@ -9449,11 +9461,6 @@ alert subtitle To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - To ustawienie jest dla Twojego obecnego profilu **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Czas zniknięcia jest ustawiony tylko dla nowych kontaktów. @@ -10461,6 +10468,14 @@ Powtórzyć prośbę dołączenia? Możesz teraz wysyłać wiadomości do %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Możesz wysyłać wiadomości do %@ ze zarchiwizowanych kontaktów. diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff index 7254d80c9c..4d08db0a99 100644 --- a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -2525,8 +2525,8 @@ This is your own one-time link! Предпочтения контакта No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Запросы на соединение из групп No comment provided by engineer. @@ -2715,6 +2715,14 @@ This is your own one-time link! Создаётся ссылка… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Текущий Код @@ -4770,6 +4778,10 @@ Error: %2$@ Приглашение в группу больше не действительно, оно было удалено отправителем. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Ссылка группы @@ -9733,11 +9745,6 @@ alert subtitle Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - Эта настройка применяется к Вашему текущему профилю чата **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Время удаления устанавливается только для новых контактов. @@ -10768,6 +10775,14 @@ Repeat join request? Вы теперь можете общаться с %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Вы можете отправлять сообщения %@ из Архивированных контактов. diff --git a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff index 20dec9b68d..5859a228a3 100644 --- a/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff +++ b/apps/ios/SimpleX Localizations/th.xcloc/Localized Contents/th.xliff @@ -2211,8 +2211,8 @@ This is your own one-time link! การกําหนดลักษณะการติดต่อ No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2375,6 +2375,14 @@ This is your own one-time link! Creating link… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode รหัสผ่านปัจจุบัน @@ -4221,6 +4229,10 @@ Error: %2$@ คำเชิญเข้าร่วมกลุ่มใช้ไม่ถูกต้องอีกต่อไป คำเชิญถูกลบโดยผู้ส่ง No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link ลิงค์กลุ่ม @@ -8593,10 +8605,6 @@ alert subtitle การตั้งค่านี้ใช้กับข้อความในโปรไฟล์แชทปัจจุบันของคุณ **%@** No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. No comment provided by engineer. @@ -9497,6 +9505,14 @@ Repeat join request? ตอนนี้คุณสามารถส่งข้อความถึง %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. No comment provided by engineer. diff --git a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff index 00385e5312..f6c20f8cdd 100644 --- a/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff +++ b/apps/ios/SimpleX Localizations/tr.xcloc/Localized Contents/tr.xliff @@ -2450,8 +2450,8 @@ Bu senin kendi tek kullanımlık bağlantın! Kişi tercihleri No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups Gruplardan gelen iletişim talepleri No comment provided by engineer. @@ -2633,6 +2633,14 @@ Bu senin kendi tek kullanımlık bağlantın! Link oluşturuluyor… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Şu anki şifre @@ -4644,6 +4652,10 @@ Hata: %2$@ Grup davet artık geçerli değil, gönderici tarafından silindi. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Grup bağlantısı @@ -9436,11 +9448,6 @@ alert subtitle Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır. No comment provided by engineer. - - This setting is for your current profile **%@**. - Bu ayar, mevcut profiliniz içindir. - No comment provided by engineer. - Time to disappear is set only for new contacts. Kaybolma süresi yalnızca yeni kişiler için ayarlanır. @@ -10444,6 +10451,14 @@ Katılma isteği tekrarlansın mı? Artık %@ adresine mesaj gönderebilirsin notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Arşivlenen kişilerden %@'ya mesaj gönderebilirsiniz. diff --git a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff index fe69dea6ab..1d6c687a94 100644 --- a/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff +++ b/apps/ios/SimpleX Localizations/uk.xcloc/Localized Contents/uk.xliff @@ -2472,8 +2472,8 @@ This is your own one-time link! Налаштування контактів No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups No comment provided by engineer. @@ -2654,6 +2654,14 @@ This is your own one-time link! Створення посилання… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode Поточний пароль @@ -4662,6 +4670,10 @@ Error: %2$@ Групове запрошення більше не дійсне, воно було видалено відправником. No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link Посилання на групу @@ -9445,10 +9457,6 @@ alert subtitle Це налаштування застосовується до повідомлень у вашому поточному профілі чату **%@**. No comment provided by engineer. - - This setting is for your current profile **%@**. - No comment provided by engineer. - Time to disappear is set only for new contacts. Час зникнення встановлюється тільки для нових контактів. @@ -10451,6 +10459,14 @@ Repeat join request? Тепер ви можете надсилати повідомлення на адресу %@ notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. Ви можете надсилати повідомлення на %@ з архівних контактів. diff --git a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff index 1f6067fe53..c977009785 100644 --- a/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff +++ b/apps/ios/SimpleX Localizations/zh-Hans.xcloc/Localized Contents/zh-Hans.xliff @@ -2521,8 +2521,8 @@ This is your own one-time link! 联系人偏好设置 No comment provided by engineer. - - Contact requests from groups + + Contact requests in groups 来自群的联络请求 No comment provided by engineer. @@ -2710,6 +2710,14 @@ This is your own one-time link! 创建链接中… No comment provided by engineer. + + Crowdfunding on Wefunder + No comment provided by engineer. + + + Crowdfunding on Wefunder. + No comment provided by engineer. + Current Passcode 当前密码 @@ -4755,6 +4763,10 @@ Error: %2$@ 群组邀请不再有效,已被发件人删除。 No comment provided by engineer. + + Group invitations + No comment provided by engineer. + Group link 群组链接 @@ -9695,11 +9707,6 @@ alert subtitle 此设置适用于您当前聊天资料 **%@** 中的消息。 No comment provided by engineer. - - This setting is for your current profile **%@**. - 此设置用于当前个人资料 **%@**。 - No comment provided by engineer. - Time to disappear is set only for new contacts. 只为新联系人设置了消失时间。 @@ -10725,6 +10732,14 @@ Repeat join request? 您现在可以给 %@ 发送消息 notification body + + You can now invest in SimpleX Chat + No comment provided by engineer. + + + You can now invest in SimpleX Chat! 🚀 + No comment provided by engineer. + You can send messages to %@ from Archived contacts. 您可以从存档的联系人向%@发送消息。 diff --git a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings index 387be3ae26..9ff760e010 100644 --- a/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX NSE/fr.lproj/Localizable.strings @@ -2,7 +2,7 @@ "%d new events" = "%d nouveaux événements"; /* notification body */ -"From %d chat(s)" = "De %d discussion(s)"; +"From %d chat(s)" = "De %d conversation(s)"; /* notification body */ "From: %@" = "De : %@"; diff --git a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings index df67d6b28b..f20a8cfb99 100644 --- a/apps/ios/SimpleX SE/fr.lproj/Localizable.strings +++ b/apps/ios/SimpleX SE/fr.lproj/Localizable.strings @@ -32,7 +32,7 @@ "Database passphrase is different from saved in the keychain." = "La phrase secrète de la base de données est différente de celle enregistrée dans la keychain."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "La phrase secrète de la base de données est nécessaire pour ouvrir le chat."; +"Database passphrase is required to open chat." = "La phrase secrète de la base de données est nécessaire pour ouvrir la messagerie."; /* No comment provided by engineer. */ "Database upgrade required" = "Mise à niveau de la base de données nécessaire"; @@ -80,7 +80,7 @@ "Please create a profile in the SimpleX app" = "Veuillez créer un profil dans l'app SimpleX"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "Les paramètres de chat sélectionnés ne permettent pas l'envoi de ce message."; +"Selected chat preferences prohibit this message." = "Les paramètres de conversation sélectionnés ne permettent pas l'envoi de ce message."; /* No comment provided by engineer. */ "Sending a message takes longer than expected." = "L'envoi d'un message prend plus de temps que prévu."; diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 0f7dab1eba..890a85fac1 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-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */; }; - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */; }; + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */; }; + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.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 */; }; @@ -563,8 +563,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-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a"; sourceTree = ""; }; - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a"; sourceTree = ""; }; + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a"; sourceTree = ""; }; + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.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 = ""; }; @@ -735,8 +735,8 @@ 64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */, 64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */, 64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */, - 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a in Frameworks */, - 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a in Frameworks */, + 64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a in Frameworks */, + 64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a in Frameworks */, CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -822,8 +822,8 @@ 64C829992D54AEEE006B9E89 /* libffi.a */, 64C829982D54AEED006B9E89 /* libgmp.a */, 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */, - 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo-ghc9.6.3.a */, - 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.11-SNj2VtVeH9ARktfFtATBo.a */, + 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD-ghc9.6.3.a */, + 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.1.0.3-9pyEF8uuax6HMofQg4cpqD.a */, ); path = Libraries; sourceTree = ""; @@ -2081,7 +2081,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2106,7 +2106,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2131,7 +2131,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -2156,7 +2156,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000"; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; @@ -2173,11 +2173,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2193,11 +2193,11 @@ buildSettings = { ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2218,7 +2218,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -2233,7 +2233,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2255,7 +2255,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -2270,7 +2270,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -2292,7 +2292,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2318,7 +2318,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2343,7 +2343,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -2370,7 +2370,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -2397,7 +2397,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2412,7 +2412,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; @@ -2431,7 +2431,7 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 345; + CURRENT_PROJECT_VERSION = 350; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -2446,7 +2446,7 @@ "@executable_path/../../Frameworks", ); LOCALIZATION_PREFERS_STRING_CATALOGS = YES; - MARKETING_VERSION = 7.0; + MARKETING_VERSION = 7.1; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ddc9454b86..e3212ee87a 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -42,6 +42,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { public var sendRcptsContacts: Bool public var sendRcptsSmallGroups: Bool public var autoAcceptMemberContacts: Bool + public var autoAcceptGroupInvitations: Bool public var viewPwdHash: UserPwdHash? public var uiThemes: ThemeModeOverrides? public var userChatRelay: Bool @@ -71,6 +72,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable { sendRcptsContacts: true, sendRcptsSmallGroups: false, autoAcceptMemberContacts: false, + autoAcceptGroupInvitations: false, userChatRelay: false ) } @@ -4326,13 +4328,15 @@ public enum MsgDirection: String, Decodable, Hashable { public enum CIForwardedFrom: Decodable, Hashable { case unknown case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) - case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) + case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?, memberId: String?, sharedMsgId_: String?, groupType: GroupType?) + case groupLink(chatName: String, msgDir: MsgDirection, groupLink: String, publicGroupId: String, memberId: String?, sharedMsgId: String, groupType: GroupType?) - var chatName: String { + public var chatName: String { switch self { case .unknown: "" case let .contact(chatName, _, _, _): chatName - case let .group(chatName, _, _, _): chatName + case let .group(chatName, _, _, _, _, _, _): chatName + case let .groupLink(chatName, _, _, _, _, _, _): chatName } } @@ -4343,17 +4347,23 @@ public enum CIForwardedFrom: Decodable, Hashable { if let contactId { (ChatType.direct, contactId, msgId) } else { nil } - case let .group(_, _, groupId, msgId): + case let .group(_, _, groupId, msgId, _, _, _): if let groupId { (ChatType.group, groupId, msgId) } else { nil } + case .groupLink: nil + } + } + + public var sourceGroupLink: String? { + switch self { + case let .groupLink(_, _, groupLink, _, _, _, _): groupLink + default: nil } } public func text(_ chatType: ChatType) -> LocalizedStringKey { - chatType == .local - ? (chatName == "" ? "saved" : "saved from \(chatName)") - : "forwarded" + chatType == .local ? "saved" : "forwarded" } } diff --git a/apps/ios/bg.lproj/Localizable.strings b/apps/ios/bg.lproj/Localizable.strings index 7956ef1c17..05f5185e4d 100644 --- a/apps/ios/bg.lproj/Localizable.strings +++ b/apps/ios/bg.lproj/Localizable.strings @@ -3519,7 +3519,7 @@ chat item action */ "Saved from" = "Запазено от"; /* No comment provided by engineer. */ -"saved from %@" = "запазено от %@"; +"saved from" = "запазено от"; /* message info title */ "Saved message" = "Запазено съобщение"; @@ -4420,6 +4420,18 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "вие сте наблюдател"; +/* new chat alert */ +"You are an observer" = "Вие сте наблюдател"; + +/* new chat alert */ +"You are a member" = "Вие сте член"; + +/* new chat alert */ +"You are an admin" = "Вие сте админ"; + +/* new chat alert */ +"You are an owner" = "Вие сте собственик"; + /* snd group event chat item */ "you blocked %@" = "вие блокирахте %@"; diff --git a/apps/ios/cs.lproj/Localizable.strings b/apps/ios/cs.lproj/Localizable.strings index 165177876c..cb596480dc 100644 --- a/apps/ios/cs.lproj/Localizable.strings +++ b/apps/ios/cs.lproj/Localizable.strings @@ -3514,6 +3514,18 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "jste pozorovatel"; +/* new chat alert */ +"You are an observer" = "Jste pozorovatel"; + +/* new chat alert */ +"You are a member" = "Jste člen"; + +/* new chat alert */ +"You are an admin" = "Jste správce"; + +/* new chat alert */ +"You are an owner" = "Jste vlastník"; + /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "Můžete přijímat hovory z obrazovky zámku, bez ověření zařízení a aplikace."; diff --git a/apps/ios/de.lproj/Localizable.strings b/apps/ios/de.lproj/Localizable.strings index 41f64e5400..924cbc2735 100644 --- a/apps/ios/de.lproj/Localizable.strings +++ b/apps/ios/de.lproj/Localizable.strings @@ -1611,7 +1611,7 @@ server test step */ "Connection is blocked by server operator:\n%@" = "Die Verbindung wurde vom Serverbetreiber blockiert:\n%@"; /* conn error description */ -"Connection link removed" = "Verbindungsfehler"; +"Connection link removed" = "Verbindungslink entfernt"; /* No comment provided by engineer. */ "Connection not ready." = "Verbindung noch nicht bereit."; @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Kontakt-Präferenzen"; /* No comment provided by engineer. */ -"Contact requests from groups" = "KONTAKTANFRAGEN VON GRUPPEN"; +"Contact requests in groups" = "KONTAKTANFRAGEN VON GRUPPEN"; /* No comment provided by engineer. */ "contact should accept…" = "Kontakt sollte annehmen…"; @@ -3106,7 +3106,7 @@ servers warning */ "Get notified when mentioned." = "Bei Erwähnung benachrichtigt werden."; /* No comment provided by engineer. */ -"Get SimpleX name (BETA)" = "SimpleX-Name erhalten (BETA)"; +"Get SimpleX name (BETA)" = "Einen SimpleX-Namen erhalten (BETA)"; /* No comment provided by engineer. */ "Get started" = "Jetzt starten"; @@ -4264,7 +4264,7 @@ servers warning */ "No valid link" = "Kein gültiger Link"; /* No comment provided by engineer. */ -"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich."; +"Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Niemand verfolgte Ihre Gespräche. Niemand hat eine Karte erstellt, wo Sie überall waren. Privatsphäre war nie ein Feature – sie war eine Selbstverständlichkeit."; /* No comment provided by engineer. */ "Non-profit governance" = "Non‑Profit‑Governance"; @@ -5233,16 +5233,16 @@ swipe action */ "Role" = "Rolle"; /* No comment provided by engineer. */ -"Role will be changed to \"%@\". All chat members will be notified." = "Die Rolle des Mitglieds wird auf \"%@\" geändert. Alle Chat-Mitglieder werden darüber informiert."; +"Role will be changed to \"%@\". All chat members will be notified." = "Die Rolle wird auf \"%@\" geändert. Alle Chat-Mitglieder werden darüber informiert."; /* No comment provided by engineer. */ -"Role will be changed to \"%@\". All group members will be notified." = "Die Mitgliederrolle wird auf \"%@\" geändert. Alle Gruppenmitglieder werden benachrichtigt."; +"Role will be changed to \"%@\". All group members will be notified." = "Die Rolle wird auf \"%@\" geändert. Alle Gruppenmitglieder werden benachrichtigt."; /* No comment provided by engineer. */ "Role will be changed to \"%@\". All subscribers will be notified." = "Die Rolle wird auf \"%@\" geändert. Alle Abonnenten werden benachrichtigt."; /* No comment provided by engineer. */ -"Role will be changed to \"%@\". The member will receive a new invitation." = "Die Mitgliederrolle wird auf \"%@\" geändert. Das Mitglied wird eine neue Einladung erhalten."; +"Role will be changed to \"%@\". The member will receive a new invitation." = "Die Rolle wird auf \"%@\" geändert. Das Mitglied wird eine neue Einladung erhalten."; /* No comment provided by engineer. */ "Run chat" = "Chat starten"; @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Abgespeichert von"; /* No comment provided by engineer. */ -"saved from %@" = "abgespeichert von %@"; +"saved from" = "abgespeichert von"; /* message info title */ "Saved message" = "Gespeicherte Nachricht"; @@ -6280,7 +6280,7 @@ server test failure */ "The second tick we missed! ✅" = "Wir haben das zweite Häkchen vermisst! ✅"; /* No comment provided by engineer. */ -"The sender deleted the connection request." = "Der Absender hat möglicherweise die Verbindungsanfrage gelöscht."; +"The sender deleted the connection request." = "Der Absender hat die Verbindungsanfrage gelöscht."; /* alert message */ "The sender will NOT be notified" = "Der Absender wird NICHT benachrichtigt"; @@ -6301,7 +6301,7 @@ server test failure */ "The SimpleX name %@ is registered, but it has no valid link." = "Der SimpleX-Name %@ wurde registriert, hat aber keinen gültigen Link."; /* No comment provided by engineer. */ -"The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner." = "Der SimpleX‑Name %@ wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte zu Ihrer Adresse oder zum Kanalprofil hinzufügen, sofern Sie der Besitzer sind."; +"The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner." = "Der SimpleX‑Name %@ wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte fügen Sie ihn zu Ihrer Adresse oder zum Kanalprofil hinzu, sofern Sie der Besitzer sind."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Der von Ihnen eingefügte Text ist kein SimpleX-Link."; @@ -6388,9 +6388,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Diese Einstellung gilt für Nachrichten in Ihrem aktuellen Chat-Profil **%@**."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Diese Einstellung gilt für Ihr aktuelles Profil **%@**."; - /* No comment provided by engineer. */ "This SimpleX name is not registered. Please check the name." = "Dieser SimpleX-Name wurde nicht registriert. Bitte überprüfen Sie den Namen."; @@ -7055,6 +7052,27 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Sie sind Beobachter"; +/* new chat alert */ +"You are an observer" = "Sie sind Beobachter"; + +/* new chat alert */ +"You are a member" = "Sie sind Mitglied"; + +/* new chat alert */ +"You are a moderator" = "Sie sind Moderator"; + +/* new chat alert */ +"You are an admin" = "Sie sind Admin"; + +/* new chat alert */ +"You are an owner" = "Sie sind Eigentümer"; + +/* new chat alert */ +"You are a subscriber" = "Sie sind Abonnent"; + +/* new chat alert */ +"You are a contributor" = "Sie sind Mitwirkender"; + /* No comment provided by engineer. */ "you are subscriber" = "Sie sind Abonnent"; @@ -7281,7 +7299,7 @@ alert title */ "Your contact" = "Ihr Kontakt"; /* No comment provided by engineer. */ -"Your contact removed this link, or it was a one-time link that was already used.\nTo connect, ask your contact to create a new link." = "Entweder hat Ihr Kontakt die Verbindung gelöscht, oder dieser Link wurde bereits verwendet, es könnte sich um einen Fehler handeln - Bitte melden Sie es uns.\nBitten Sie Ihren Kontakt darum einen weiteren Verbindungs-Link zu erzeugen, um sich neu verbinden zu können und stellen Sie sicher, dass Sie eine stabile Netzwerk-Verbindung haben."; +"Your contact removed this link, or it was a one-time link that was already used.\nTo connect, ask your contact to create a new link." = "Ihr Kontakt hat diesen Link entfernt oder es war ein Einmal‑Link, welcher bereits verwendet wurde.\nUm sich zu verbinden, bitten Sie Ihren Kontakt, einen neuen Link zu erstellen."; /* No comment provided by engineer. */ "Your contact sent a file that is larger than currently supported maximum size (%@)." = "Ihr Kontakt hat eine Datei gesendet, die größer ist als die derzeit unterstützte maximale Größe (%@)."; diff --git a/apps/ios/es.lproj/Localizable.strings b/apps/ios/es.lproj/Localizable.strings index a6d4e9d20c..02f6415cca 100644 --- a/apps/ios/es.lproj/Localizable.strings +++ b/apps/ios/es.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Preferencias de contacto"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Solicitudes de contacto en grupo"; +"Contact requests in groups" = "Solicitudes de contacto en grupo"; /* No comment provided by engineer. */ "contact should accept…" = "el contacto debe aceptarte…"; @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Guardado desde"; /* No comment provided by engineer. */ -"saved from %@" = "Guardado desde %@"; +"saved from" = "Guardado desde"; /* message info title */ "Saved message" = "Mensaje guardado"; @@ -6388,9 +6388,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Esta configuración se aplica a los mensajes del perfil actual **%@**."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Esta configuración se aplica al perfil actual **%@**."; - /* No comment provided by engineer. */ "This SimpleX name is not registered. Please check the name." = "El nombre SimpleX no está registrado. Por favor, comprueba el nombre."; @@ -7055,6 +7052,27 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Tu rol es observador"; +/* new chat alert */ +"You are an observer" = "Eres observador"; + +/* new chat alert */ +"You are a member" = "Eres miembro"; + +/* new chat alert */ +"You are a moderator" = "Eres moderador"; + +/* new chat alert */ +"You are an admin" = "Eres administrador"; + +/* new chat alert */ +"You are an owner" = "Eres propietario"; + +/* new chat alert */ +"You are a subscriber" = "Eres suscriptor"; + +/* new chat alert */ +"You are a contributor" = "Eres colaborador"; + /* No comment provided by engineer. */ "you are subscriber" = "eres suscriptor"; diff --git a/apps/ios/fi.lproj/Localizable.strings b/apps/ios/fi.lproj/Localizable.strings index dfe1b6479d..8abb4e69f8 100644 --- a/apps/ios/fi.lproj/Localizable.strings +++ b/apps/ios/fi.lproj/Localizable.strings @@ -3150,6 +3150,18 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "olet tarkkailija"; +/* new chat alert */ +"You are an observer" = "Olet tarkkailija"; + +/* new chat alert */ +"You are a member" = "Olet jäsen"; + +/* new chat alert */ +"You are an admin" = "Olet ylläpitäjä"; + +/* new chat alert */ +"You are an owner" = "Olet omistaja"; + /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "Voit vastaanottaa puheluita lukitusnäytöltä ilman laitteen ja sovelluksen todennusta."; diff --git a/apps/ios/fr.lproj/Localizable.strings b/apps/ios/fr.lproj/Localizable.strings index 0952cacb90..6816de9f5f 100644 --- a/apps/ios/fr.lproj/Localizable.strings +++ b/apps/ios/fr.lproj/Localizable.strings @@ -187,6 +187,19 @@ /* time interval */ "%d months" = "%d mois"; +/* channel owners count */ +"%d owner" = "%d propriétaire"; + +/* channel owners count */ +"%d owners" = "%d propriétaires"; + +/* channel members count */ +"%d owners & contributors" = "%d propriétaires & contributeurs"; + +/* channel relay bar +channel subscriber relay bar */ +"%d relays failed" = "%d relais ont échoué"; + /* channel relay bar channel subscriber relay bar */ "%d relays not active" = "%d relais inactifs"; @@ -220,12 +233,25 @@ channel relay bar progress */ /* channel relay bar */ "%d/%d relays active, %d errors" = "%1$d/%2$d relais actifs, %3$d erreurs"; +/* channel creation progress with errors +channel relay bar */ +"%d/%d relays active, %d failed" = "%1$d/%2$d relais actifs, %3$d en échec"; + +/* channel relay bar */ +"%d/%d relays active, %d removed" = "%1$d/%2$d relais actifs, %3$d supprimé(s)"; + /* channel subscriber relay bar progress */ "%d/%d relays connected" = "%1$d/%2$d relais connectés"; /* channel subscriber relay bar */ "%d/%d relays connected, %d errors" = "%1$d/%2$d relais connectés, %3$d erreurs"; +/* channel subscriber relay bar */ +"%d/%d relays connected, %d failed" = "%1$d/%2$d relais connectés, %3$d ont échoué"; + +/* channel subscriber relay bar */ +"%d/%d relays connected, %d removed" = "%1$d/%2$d relais connectés, %3$d retirés"; + /* No comment provided by engineer. */ "%lld" = "%lld"; @@ -362,7 +388,7 @@ time interval */ "A new random profile will be shared." = "Un nouveau profil aléatoire sera partagé."; /* No comment provided by engineer. */ -"A separate TCP connection will be used **for each chat profile you have in the app**." = "Une connexion TCP distincte sera utilisée **pour chaque profil de discussion que vous avez dans l'application**."; +"A separate TCP connection will be used **for each chat profile you have in the app**." = "Une connexion TCP distincte sera utilisée **pour chaque profil de messagerie que vous avez dans l'application**."; /* No comment provided by engineer. */ "A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail." = "Une connexion TCP distincte sera utilisée **pour chaque contact et membre de groupe**.\n**Veuillez noter** : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer."; @@ -440,6 +466,9 @@ swipe action */ /* No comment provided by engineer. */ "Acknowledged" = "Reçu avec accusé de réception"; +/* No comment provided by engineer. */ +"acknowledged roster" = "liste reconnue"; + /* No comment provided by engineer. */ "Acknowledgement errors" = "Erreur d'accusé de réception"; @@ -458,6 +487,12 @@ swipe action */ /* No comment provided by engineer. */ "Add address to your profile, so that your SimpleX contacts can share it with other people. Profile update will be sent to your SimpleX contacts." = "Ajoutez une adresse à votre profil afin que vos contacts puissent la partager avec d'autres personnes. La mise à jour du profil sera envoyée à vos contacts."; +/* No comment provided by engineer. */ +"Add contributors." = "Ajouter des contributeurs."; + +/* No comment provided by engineer. */ +"Add description" = "Ajouter une description"; + /* No comment provided by engineer. */ "Add friends" = "Ajouter des amis"; @@ -557,6 +592,9 @@ swipe action */ /* chat item text */ "agreeing encryption…" = "négociation du chiffrement…"; +/* member criteria value */ +"all" = "tous"; + /* No comment provided by engineer. */ "All" = "Tout"; @@ -564,10 +602,10 @@ swipe action */ "All app data is deleted." = "Toutes les données de l'application sont supprimées."; /* No comment provided by engineer. */ -"All chats and messages will be deleted - this cannot be undone!" = "Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière !"; +"All chats and messages will be deleted - this cannot be undone!" = "Toutes les conversations et tous les messages seront supprimés - il est impossible de revenir en arrière !"; /* alert message */ -"All chats will be removed from the list %@, and the list deleted." = "Toutes les discussions seront supprimées de la liste %@ et la liste sera supprimée."; +"All chats will be removed from the list %@, and the list deleted." = "Toutes les conversations seront supprimées de la liste %@ et la liste sera supprimée."; /* No comment provided by engineer. */ "All data is erased when it is entered." = "Toutes les données sont effacées lorsqu'il est saisi."; @@ -726,7 +764,7 @@ swipe action */ "Always use relay" = "Se connecter via relais"; /* No comment provided by engineer. */ -"An empty chat profile with the provided name is created, and the app opens as usual." = "Un profil de discussion vierge portant le nom fourni est créé et l'application s'ouvre normalement."; +"An empty chat profile with the provided name is created, and the app opens as usual." = "Un profil de messagerie vierge portant le nom fourni est créé et l'application s'ouvre normalement."; /* No comment provided by engineer. */ "and %lld other events" = "et %lld autres événements"; @@ -809,6 +847,9 @@ swipe action */ /* No comment provided by engineer. */ "Archived contacts" = "Contacts archivés"; +/* No comment provided by engineer. */ +"archived report" = "signalement archivé"; + /* No comment provided by engineer. */ "Archiving database" = "Archivage de la base de données"; @@ -867,7 +908,7 @@ swipe action */ "Background" = "Fond"; /* No comment provided by engineer. */ -"Bad desktop address" = "Mauvaise adresse de bureau"; +"Bad desktop address" = "Adresse du PC incorrecte"; /* integrity error chat item */ "bad message hash" = "hash de message incorrect"; @@ -884,12 +925,21 @@ swipe action */ /* badge alert title */ "Badge cannot be verified" = "Le badge ne peut pas être vérifié"; +/* No comment provided by engineer. */ +"Be free\nin your network" = "Soyez libre\nau sein de votre réseau"; + /* No comment provided by engineer. */ "Be free in your network." = "Soyez libre dans votre réseau."; +/* No comment provided by engineer. */ +"Because we destroyed the power to know who you are. So that your power can never be taken." = "Parce que nous avons détruit le pouvoir de vous identifier. Pour que votre pouvoir ne puisse jamais vous être enlevé."; + /* No comment provided by engineer. */ "Better calls" = "Appels améliorés"; +/* No comment provided by engineer. */ +"Better channels 📢" = "De meilleurs canaux 📢"; + /* No comment provided by engineer. */ "Better groups" = "Des groupes plus performants"; @@ -944,6 +994,9 @@ swipe action */ /* No comment provided by engineer. */ "Block member?" = "Bloquer ce membre ?"; +/* No comment provided by engineer. */ +"Block subscriber for all?" = "Bloquer l'abonné pour tout le monde ?"; + /* marked deleted chat item preview text */ "blocked" = "blocké"; @@ -966,6 +1019,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "bold" = "gras"; +/* No comment provided by engineer. */ +"Bot" = "Bot"; + /* No comment provided by engineer. */ "Both you and your contact can add message reactions." = "Vous et votre contact pouvez ajouter des réactions aux messages."; @@ -978,12 +1034,18 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Both you and your contact can send disappearing messages." = "Vous et votre contact êtes tous deux en mesure d'envoyer des messages éphémères."; +/* No comment provided by engineer. */ +"Both you and your contact can send files and media." = "Vous pouvez tous deux envoyer des fichiers et des médias."; + /* No comment provided by engineer. */ "Both you and your contact can send voice messages." = "Vous et votre contact êtes tous deux en mesure d'envoyer des messages vocaux."; /* No comment provided by engineer. */ "Bottom bar" = "Barre inférieure"; +/* compose placeholder for channel owner */ +"Broadcast" = "Diffusion"; + /* No comment provided by engineer. */ "Bulgarian, Finnish, Thai and Ukrainian - thanks to the users and [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Bulgare, finnois, thaï et ukrainien - grâce aux utilisateurs et à [Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; @@ -991,7 +1053,7 @@ marked deleted chat item preview text */ "Business address" = "Adresse professionnelle"; /* No comment provided by engineer. */ -"Business chats" = "Discussions professionnelles"; +"Business chats" = "Conversations professionnelles"; /* No comment provided by engineer. */ "Business connection" = "Connexion pro"; @@ -1000,7 +1062,7 @@ marked deleted chat item preview text */ "Businesses" = "Entreprises"; /* No comment provided by engineer. */ -"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de chat (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; +"By chat profile (default) or [by connection](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)." = "Par profil de messagerie (par défaut) ou [par connexion](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (BETA)."; /* No comment provided by engineer. */ "call" = "appeler"; @@ -1023,6 +1085,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Camera not available" = "Caméra non disponible"; +/* No comment provided by engineer. */ +"can't broadcast" = "impossible de diffuser"; + /* No comment provided by engineer. */ "Can't call contact" = "Impossible d'appeler le contact"; @@ -1041,6 +1106,9 @@ marked deleted chat item preview text */ /* No comment provided by engineer. */ "Can't message member" = "Impossible d'envoyer un message à ce membre"; +/* No comment provided by engineer. */ +"can't send messages" = "impossible d'envoyer des messages"; + /* alert action alert button new chat action */ @@ -1080,7 +1148,7 @@ new chat action */ "Change automatic message deletion?" = "Modifier la suppression automatique des messages ?"; /* authentication reason */ -"Change chat profiles" = "Changer de profil de discussion"; +"Change chat profiles" = "Changer les profils de messagerie"; /* No comment provided by engineer. */ "Change database passphrase?" = "Changer la phrase secrète de la base de données ?"; @@ -1100,6 +1168,9 @@ new chat action */ /* No comment provided by engineer. */ "Change role" = "Changer le rôle"; +/* No comment provided by engineer. */ +"Change role?" = "Changer le rôle ?"; + /* authentication reason */ "Change self-destruct mode" = "Modifier le mode d'autodestruction"; @@ -1151,7 +1222,7 @@ alert subtitle */ "Channel profile" = "Profil du canal"; /* No comment provided by engineer. */ -"Channel profile is stored on subscribers' devices and on the chat relays." = "Le profil du canal est stocké sur les périphériques des abonné·es et sur les relais de discussion."; +"Channel profile is stored on subscribers' devices and on the chat relays." = "Le profil du canal est stocké sur les périphériques des abonné·es et sur les relais de messagerie."; /* snd group event chat item */ "channel profile updated" = "profil du canal mis à jour"; @@ -1159,6 +1230,9 @@ alert subtitle */ /* alert message */ "Channel profile was changed. If you save it, the updated profile will be sent to channel subscribers." = "Le profil a été changé. Si vous l'enregistrez, le profil mis à jour sera envoyé aux abonné·es du canal."; +/* No comment provided by engineer. */ +"Channel SimpleX name" = "Nom du canal SimpleX"; + /* alert title */ "Channel temporarily unavailable" = "Canal temporairement indisponible"; @@ -1178,79 +1252,79 @@ alert subtitle */ "Channels" = "Canaux"; /* No comment provided by engineer. */ -"Chat" = "Discussions"; +"Chat" = "Conversation"; /* No comment provided by engineer. */ -"Chat already exists" = "La discussion existe déjà"; +"Chat already exists" = "La conversation existe déjà"; /* new chat sheet title */ -"Chat already exists!" = "La discussion existe déjà !"; +"Chat already exists!" = "La conversation existe déjà !"; /* No comment provided by engineer. */ -"Chat colors" = "Couleurs de chat"; +"Chat colors" = "Couleurs de conversation"; /* No comment provided by engineer. */ -"Chat console" = "Console du chat"; +"Chat console" = "Console de la messagerie"; /* No comment provided by engineer. */ -"Chat data" = "Données de la discussion"; +"Chat data" = "Données de la conversation"; /* No comment provided by engineer. */ -"Chat database" = "Base de données du chat"; +"Chat database" = "Base de données de la messagerie"; /* No comment provided by engineer. */ -"Chat database deleted" = "Base de données du chat supprimée"; +"Chat database deleted" = "Base de données de la messagerie supprimée"; /* No comment provided by engineer. */ -"Chat database exported" = "Exportation de la base de données des discussions"; +"Chat database exported" = "Base de données de la messagerie exportée"; /* No comment provided by engineer. */ -"Chat database imported" = "Base de données du chat importée"; +"Chat database imported" = "Base de données de la messagerie importée"; /* No comment provided by engineer. */ -"Chat is running" = "Le chat est en cours d'exécution"; +"Chat is running" = "La messagerie est en fonctionnement"; /* No comment provided by engineer. */ -"Chat is stopped" = "Le chat est arrêté"; +"Chat is stopped" = "La messagerie est arrêtée"; /* No comment provided by engineer. */ -"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "La discussion est arrêtée. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer la discussion."; +"Chat is stopped. If you already used this database on another device, you should transfer it back before starting chat." = "La messagerie est arrêtée. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer la messagerie."; /* No comment provided by engineer. */ -"Chat list" = "Liste de discussion"; +"Chat list" = "Liste de conversation"; /* No comment provided by engineer. */ "Chat migrated!" = "Messagerie transférée !"; /* No comment provided by engineer. */ -"Chat preferences" = "Préférences de chat"; +"Chat preferences" = "Préférences de conversation"; /* alert message */ -"Chat preferences were changed." = "Les préférences de discussion ont été modifiées."; +"Chat preferences were changed." = "Les préférences de la conversation ont été modifiées."; /* No comment provided by engineer. */ -"Chat profile" = "Profil d'utilisateur"; +"Chat profile" = "Profil de messagerie"; /* No comment provided by engineer. */ -"Chat relay" = "Relais de la discussion"; +"Chat relay" = "Relais de messagerie"; /* No comment provided by engineer. */ -"Chat relays" = "Relais de la discussion"; +"Chat relays" = "Relais de messagerie"; /* No comment provided by engineer. */ -"Chat relays forward messages in channels you create." = "Les relais de discussion transmettent les messages dans les canaux que vous créez."; +"Chat relays forward messages in channels you create." = "Les relais de messagerie transmettent les messages dans les canaux que vous créez."; /* No comment provided by engineer. */ -"Chat relays forward messages to channel subscribers." = "Les relais de discussion transmettent les messages aux abonné·es du canal."; +"Chat relays forward messages to channel subscribers." = "Les relais de messagerie transmettent les messages aux abonné·es du canal."; /* No comment provided by engineer. */ -"Chat theme" = "Thème de chat"; +"Chat theme" = "Thème de la conversation"; /* No comment provided by engineer. */ -"Chat will be deleted for all members - this cannot be undone!" = "La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé !"; +"Chat will be deleted for all members - this cannot be undone!" = "La conversation sera supprimée pour tous les membres - cela ne peut pas être annulé !"; /* No comment provided by engineer. */ -"Chat will be deleted for you - this cannot be undone!" = "Le discussion sera supprimé pour vous - il n'est pas possible de revenir en arrière !"; +"Chat will be deleted for you - this cannot be undone!" = "La conversation sera supprimée pour vous - il n'est pas possible de revenir en arrière !"; /* chat feature chat toolbar */ @@ -1263,19 +1337,19 @@ chat toolbar */ "Chat with members before they join." = "Discuter avec les membres avant qu'ils rejoignent le canal."; /* No comment provided by engineer. */ -"Chats" = "Discussions"; +"Chats" = "Conversations"; /* No comment provided by engineer. */ -"Chats with admins are prohibited." = "Les discussions avec les admins sont interdites."; +"Chats with admins are prohibited." = "Les conversations avec les admins sont interdites."; /* alert message */ -"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "Les discussions avec les admins dans les canaux publics n'ont pas de chiffrement E2E ; à utiliser uniquement avec des relais de discussion fiables."; +"Chats with admins in public channels have no E2E encryption - use only with trusted chat relays." = "Les conversations avec les admins dans les canaux publics n'ont pas de chiffrement de bout en bout ; à utiliser uniquement avec des relais de messagerie fiables."; /* No comment provided by engineer. */ -"Chats with members" = "Discussions avec les membres"; +"Chats with members" = "Conversations avec les membres"; /* No comment provided by engineer. */ -"Chats with members are disabled" = "Les discussions avec les membres sont désactivées"; +"Chats with members are disabled" = "Les conversations avec les membres sont désactivées"; /* No comment provided by engineer. */ "Check messages every 20 min." = "Consulter les messages toutes les 20 minutes."; @@ -1335,7 +1409,7 @@ chat toolbar */ "Clear verification" = "Retirer la vérification"; /* No comment provided by engineer. */ -"Color chats with the new themes." = "Colorez vos discussions avec les nouveaux thèmes."; +"Color chats with the new themes." = "Colorez vos conversations avec les nouveaux thèmes."; /* No comment provided by engineer. */ "Color mode" = "Mode de couleur"; @@ -1428,11 +1502,14 @@ server test step */ /* No comment provided by engineer. */ "Connect faster! 🚀" = "Connectez-vous plus vite ! 🚀"; -/* No comment provided by engineer. */ -"Connect to desktop" = "Connexion au bureau"; +/* new chat action */ +"Connect to %@" = "Se connecter à %@"; /* No comment provided by engineer. */ -"connect to SimpleX Chat developers." = "se connecter aux developpeurs de SimpleX Chat."; +"Connect to desktop" = "Connexion à un PC"; + +/* No comment provided by engineer. */ +"connect to SimpleX Chat developers." = "se connecter aux développeurs de SimpleX Chat."; /* No comment provided by engineer. */ "Connect to your friends faster." = "Connectez-vous à vos amis plus rapidement."; @@ -1465,13 +1542,13 @@ server test step */ "Connected" = "Connecté"; /* No comment provided by engineer. */ -"Connected desktop" = "Bureau connecté"; +"Connected desktop" = "Ordinateur connecté"; /* No comment provided by engineer. */ "Connected servers" = "Serveurs connectés"; /* No comment provided by engineer. */ -"Connected to desktop" = "Connecté au bureau"; +"Connected to desktop" = "Connecté au PC"; /* No comment provided by engineer. */ "connecting" = "connexion"; @@ -1504,7 +1581,7 @@ server test step */ "Connecting to contact, please wait or check later!" = "Connexion au contact, veuillez patienter ou vérifier plus tard !"; /* No comment provided by engineer. */ -"Connecting to desktop" = "Connexion au bureau"; +"Connecting to desktop" = "Connexion au PC"; /* No comment provided by engineer. */ "connecting…" = "connexion…"; @@ -1518,6 +1595,9 @@ server test step */ /* No comment provided by engineer. */ "Connection blocked" = "Connexion bloquée"; +/* conn error description */ +"Connection blocked: %@" = "Connexion bloquée : %@"; + /* alert title */ "Connection error" = "Erreur de connexion"; @@ -1555,7 +1635,7 @@ server test step */ "Connection timeout" = "Délai de connexion"; /* No comment provided by engineer. */ -"Connection with desktop stopped" = "La connexion avec le bureau s'est arrêtée"; +"Connection with desktop stopped" = "La connexion au PC s'est arrêtée"; /* connection information */ "connection:%@" = "connexion : %@"; @@ -1612,7 +1692,10 @@ server test step */ "Contact preferences" = "Préférences de contact"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Demandes de contact des groupes"; +"Contact requests in groups" = "Demandes de contact des groupes"; + +/* No comment provided by engineer. */ +"contact should accept…" = "Le contact devrait accepter…"; /* No comment provided by engineer. */ "Contact will be deleted - this cannot be undone!" = "Le contact sera supprimé - il n'est pas possible de revenir en arrière !"; @@ -1632,6 +1715,9 @@ server test step */ /* No comment provided by engineer. */ "Contribute" = "Contribuer"; +/* member role */ +"contributor" = "contributeur"; + /* No comment provided by engineer. */ "Conversation deleted!" = "Conversation supprimée !"; @@ -1659,6 +1745,9 @@ server test step */ /* No comment provided by engineer. */ "Create a group using a random profile." = "Création de groupes via un profil aléatoire."; +/* No comment provided by engineer. */ +"Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting." = "Créez une page d’aperçu de votre canal pour les visiteurs avant qu'ils ne s'abonnent. Hébergez-la vous-même ou via un service statique."; + /* server test step */ "Create file" = "Créer un fichier"; @@ -1689,6 +1778,9 @@ server test step */ /* No comment provided by engineer. */ "Create SimpleX address" = "Créer une adresse SimpleX"; +/* No comment provided by engineer. */ +"Create web preview." = "Créer un aperçu de canal."; + /* No comment provided by engineer. */ "Create your address" = "Créer votre adresse"; @@ -1795,7 +1887,7 @@ server test step */ "Database passphrase is different from saved in the keychain." = "La phrase secrète de la base de données est différente de celle enregistrée dans la keychain."; /* No comment provided by engineer. */ -"Database passphrase is required to open chat." = "La phrase secrète de la base de données est nécessaire pour ouvrir le chat."; +"Database passphrase is required to open chat." = "La phrase secrète de la base de données est nécessaire pour ouvrir la messagerie."; /* No comment provided by engineer. */ "Database upgrade" = "Mise à niveau de la base de données"; @@ -1869,22 +1961,22 @@ swipe action */ "Delete channel?" = "Supprimer le canal ?"; /* No comment provided by engineer. */ -"Delete chat" = "Supprimer la discussion"; +"Delete chat" = "Supprimer la conversation"; /* No comment provided by engineer. */ -"Delete chat messages from your device." = "Supprimer les messages de chat de votre appareil."; +"Delete chat messages from your device." = "Supprimer les messages de messagerie de votre appareil."; /* No comment provided by engineer. */ -"Delete chat profile" = "Supprimer le profil de chat"; +"Delete chat profile" = "Supprimer le profil de messagerie"; /* No comment provided by engineer. */ -"Delete chat profile?" = "Supprimer le profil du chat ?"; +"Delete chat profile?" = "Supprimer le profil de messagerie ?"; /* alert title */ -"Delete chat with member?" = "Supprimer la discussion avec le membre ?"; +"Delete chat with member?" = "Supprimer la conversation avec le membre ?"; /* No comment provided by engineer. */ -"Delete chat?" = "Supprimer la discussion ?"; +"Delete chat?" = "Supprimer la conversation ?"; /* No comment provided by engineer. */ "Delete connection" = "Supprimer la connexion"; @@ -1908,7 +2000,7 @@ swipe action */ "Delete files and media?" = "Supprimer les fichiers et médias ?"; /* No comment provided by engineer. */ -"Delete files for all chat profiles" = "Effacer les fichiers de tous les profils de chat"; +"Delete files for all chat profiles" = "Effacer les fichiers de tous les profils de messagerie"; /* chat feature */ "Delete for everyone" = "Supprimer pour tous"; @@ -2001,6 +2093,9 @@ alert button */ /* copied message info */ "Deleted at: %@" = "Supprimé à : %@"; +/* rcv group event chat item */ +"deleted channel" = "canal supprimé"; + /* rcv direct event chat item */ "deleted contact" = "contact supprimé"; @@ -2032,13 +2127,13 @@ alert button */ "Description too large" = "Description trop longue"; /* No comment provided by engineer. */ -"Desktop address" = "Adresse de bureau"; +"Desktop address" = "Adresse du PC"; /* No comment provided by engineer. */ "Desktop app version %@ is not compatible with this app." = "La version de l'application de bureau %@ n'est pas compatible avec cette application."; /* No comment provided by engineer. */ -"Desktop devices" = "Appareils de bureau"; +"Desktop devices" = "Ordinateurs"; /* No comment provided by engineer. */ "Destination server address of %1$@ is incompatible with forwarding server %2$@ settings." = "L'adresse du serveur de destination %1$@ est incompatible avec les paramètres du serveur de redirection %2$@."; @@ -2083,7 +2178,7 @@ alert button */ "Direct messages" = "Messages directs"; /* No comment provided by engineer. */ -"Direct messages between members are prohibited in this chat." = "Les messages directs entre membres sont interdits dans cette discussion."; +"Direct messages between members are prohibited in this chat." = "Les messages directs entre membres sont interdits dans cette conversation."; /* No comment provided by engineer. */ "Direct messages between members are prohibited." = "Les messages directs entre membres sont interdits dans ce groupe."; @@ -2122,7 +2217,7 @@ alert button */ "Disappearing messages" = "Messages éphémères"; /* No comment provided by engineer. */ -"Disappearing messages are prohibited in this chat." = "Les messages éphémères sont interdits dans cette discussion."; +"Disappearing messages are prohibited in this chat." = "Les messages éphémères sont interdits dans cette conversation."; /* No comment provided by engineer. */ "Disappearing messages are prohibited." = "Les messages éphémères sont interdits dans ce groupe."; @@ -2137,7 +2232,7 @@ alert button */ "Disconnect" = "Se déconnecter"; /* No comment provided by engineer. */ -"Disconnect desktop?" = "Déconnecter le bureau ?"; +"Disconnect desktop?" = "Déconnecter le PC ?"; /* No comment provided by engineer. */ "Discover and join groups" = "Découvrir et rejoindre des groupes"; @@ -2146,7 +2241,10 @@ alert button */ "Discover via local network" = "Rechercher sur le réseau"; /* No comment provided by engineer. */ -"Do it later" = "Faites-le plus tard"; +"Do it later" = "Reporter l'opération"; + +/* No comment provided by engineer. */ +"Do not require signing messages." = "Ne pas exiger la signature des messages."; /* No comment provided by engineer. */ "Do not send history to new members." = "Ne pas envoyer d'historique aux nouveaux membres."; @@ -2178,6 +2276,9 @@ alert button */ /* No comment provided by engineer. */ "Don't miss important messages." = "Ne manquez pas les messages importants."; +/* alert action */ +"Don't save" = "Ne pas enregistrer"; + /* alert action */ "Don't show again" = "Ne plus afficher"; @@ -2185,7 +2286,7 @@ alert button */ "Done" = "Terminé"; /* No comment provided by engineer. */ -"Downgrade and open chat" = "Rétrograder et ouvrir le chat"; +"Downgrade and open chat" = "Rétrograder et ouvrir la messagerie"; /* alert button chat item action */ @@ -2236,12 +2337,18 @@ chat item action */ /* No comment provided by engineer. */ "Easier to invite your friends 👋" = "Plus facile d'inviter vos ami·es 👋"; +/* No comment provided by engineer. */ +"Easier to read." = "Plus facile à lire."; + /* chat item action */ "Edit" = "Modifier"; /* No comment provided by engineer. */ "Edit channel profile" = "Modifier le profil du canal"; +/* No comment provided by engineer. */ +"Edit description" = "Modifier la description"; + /* No comment provided by engineer. */ "Edit group profile" = "Modifier le profil du groupe"; @@ -2255,7 +2362,7 @@ chat item action */ "Enable (keep overrides)" = "Activer (conserver les remplacements)"; /* channel creation warning */ -"Enable at least one chat relay in Network & Servers." = "Activez au moins un relais de discussion dans Réseaux et serveurs."; +"Enable at least one chat relay in Network & Servers." = "Activez au moins un relais de messagerie dans Réseaux et serveurs."; /* alert title */ "Enable automatic message deletion?" = "Activer la suppression automatique des messages ?"; @@ -2264,7 +2371,7 @@ chat item action */ "Enable camera access" = "Autoriser l'accès à la caméra"; /* alert title */ -"Enable chats with admins?" = "Activer les discussions avec les admins ?"; +"Enable chats with admins?" = "Activer les conversations avec les admins ?"; /* No comment provided by engineer. */ "Enable disappearing messages by default." = "Activer les messages éphémères par défaut."; @@ -2398,6 +2505,9 @@ chat item action */ /* No comment provided by engineer. */ "Enter correct passphrase." = "Entrez la phrase secrète correcte."; +/* placeholder */ +"Enter description (optional)" = "Saisir une description (facultatif)"; + /* No comment provided by engineer. */ "Enter group name…" = "Entrer un nom de groupe…"; @@ -2474,7 +2584,7 @@ chat item action */ "Error changing address" = "Erreur de changement d'adresse"; /* alert title */ -"Error changing chat profile" = "Erreur lors du changement du profil de discussion"; +"Error changing chat profile" = "Erreur lors du changement du profil de messagerie"; /* No comment provided by engineer. */ "Error changing connection profile" = "Erreur lors du changement de profil de connexion"; @@ -2528,13 +2638,13 @@ chat item action */ "Error decrypting file" = "Erreur lors du déchiffrement du fichier"; /* alert title */ -"Error deleting chat" = "Erreur lors de la suppression de la discussion"; +"Error deleting chat" = "Erreur lors de la suppression de la conversation"; /* alert title */ -"Error deleting chat database" = "Erreur lors de la suppression de la base de données du chat"; +"Error deleting chat database" = "Erreur lors de la suppression de la base de données de la messagerie"; /* alert title */ -"Error deleting chat!" = "Erreur lors de la suppression du chat !"; +"Error deleting chat!" = "Erreur lors de la suppression de la conversation !"; /* No comment provided by engineer. */ "Error deleting connection" = "Erreur lors de la suppression de la connexion"; @@ -2567,13 +2677,13 @@ chat item action */ "Error encrypting database" = "Erreur lors du chiffrement de la base de données"; /* alert title */ -"Error exporting chat database" = "Erreur lors de l'exportation de la base de données du chat"; +"Error exporting chat database" = "Erreur lors de l'exportation de la base de données de la messagerie"; /* No comment provided by engineer. */ "Error exporting theme: %@" = "Erreur d'exportation du thème : %@"; /* alert title */ -"Error importing chat database" = "Erreur lors de l'importation de la base de données du chat"; +"Error importing chat database" = "Erreur lors de l'importation de la base de données de la messagerie"; /* No comment provided by engineer. */ "Error joining group" = "Erreur lors de la liaison avec le groupe"; @@ -2585,7 +2695,7 @@ chat item action */ "Error migrating settings" = "Erreur lors de la migration des paramètres"; /* No comment provided by engineer. */ -"Error opening chat" = "Erreur lors de l'ouverture du chat"; +"Error opening chat" = "Erreur lors de l'ouverture de la conversation"; /* alert title */ "Error receiving file" = "Erreur lors de la réception du fichier"; @@ -2615,7 +2725,7 @@ chat item action */ "Error saving channel profile" = "Erreur lors de l'enregistrement du profil du canal"; /* alert title */ -"Error saving chat list" = "Erreur lors de l'enregistrement de la liste des chats"; +"Error saving chat list" = "Erreur lors de l'enregistrement de la liste des conversations"; /* No comment provided by engineer. */ "Error saving group profile" = "Erreur lors de la sauvegarde du profil de groupe"; @@ -2623,6 +2733,9 @@ chat item action */ /* No comment provided by engineer. */ "Error saving ICE servers" = "Erreur lors de la sauvegarde des serveurs ICE"; +/* alert title */ +"Error saving name" = "Erreur d'enregistrement du nom"; + /* No comment provided by engineer. */ "Error saving passcode" = "Erreur lors de la sauvegarde du code d'accès"; @@ -2656,14 +2769,17 @@ chat item action */ /* No comment provided by engineer. */ "Error setting delivery receipts!" = "Erreur lors de la configuration des accusés de réception !"; +/* alert title */ +"Error sharing address" = "Erreur du partage d'adresse"; + /* alert title */ "Error sharing channel" = "Erreur lors du partage du canal"; /* No comment provided by engineer. */ -"Error starting chat" = "Erreur lors du démarrage du chat"; +"Error starting chat" = "Erreur lors du démarrage de la messagerie"; /* No comment provided by engineer. */ -"Error stopping chat" = "Erreur lors de l'arrêt du chat"; +"Error stopping chat" = "Erreur lors de l'arrêt de la messagerie"; /* alert title */ "Error switching profile" = "Erreur lors du changement de profil"; @@ -2701,6 +2817,9 @@ chat item action */ /* No comment provided by engineer. */ "Error: " = "Erreur : "; +/* receive error chat item */ +"error: %@" = "erreur : %@"; + /* alert message conn error description file error text @@ -2756,6 +2875,9 @@ server test error */ /* No comment provided by engineer. */ "Exporting database archive…" = "Exportation de l'archive de la base de données…"; +/* No comment provided by engineer. */ +"failed" = "échec"; + /* No comment provided by engineer. */ "Failed to remove passphrase" = "Échec de la suppression de la phrase secrète"; @@ -2792,6 +2914,12 @@ server test error */ /* file error text */ "File server error: %@" = "Erreur de serveur de fichiers : %@"; +/* No comment provided by engineer. */ +"File servers" = "Serveurs de fichiers"; + +/* copied message info */ +"File servers: %@" = "Serveurs de fichiers : %@"; + /* No comment provided by engineer. */ "File status" = "Statut du fichier"; @@ -2820,7 +2948,7 @@ server test error */ "Files and media" = "Fichiers et médias"; /* No comment provided by engineer. */ -"Files and media are prohibited in this chat." = "Les fichiers et médias sont interdits dans cette discussion."; +"Files and media are prohibited in this chat." = "Les fichiers et médias sont interdits dans cette conversation."; /* No comment provided by engineer. */ "Files and media are prohibited." = "Les fichiers et les médias sont interdits dans ce groupe."; @@ -2835,7 +2963,7 @@ server test error */ "Filter" = "Filtre"; /* No comment provided by engineer. */ -"Filter unread and favorite chats." = "Filtrer les messages non lus et favoris."; +"Filter unread and favorite chats." = "Filtrer les conversations non lues et favorites."; /* No comment provided by engineer. */ "Finalize migration" = "Finaliser le transfert"; @@ -2847,14 +2975,20 @@ server test error */ "Finally, we have them! 🚀" = "Enfin, les voilà ! 🚀"; /* No comment provided by engineer. */ -"Find chats faster" = "Recherche de message plus rapide"; +"Find chats faster" = "Trouvez vos conversations plus rapidement"; /* No comment provided by engineer. */ -"Fingerprint in destination server address does not match certificate: %@." = "L'empreinte dans l'adresse du serveur de destination ne correspond pas au certificat : %@."; +"Fingerprint in destination server address does not match certificate: %@." = "L'empreinte numérique dans l'adresse du serveur de destination ne correspond pas au certificat : %@."; + +/* No comment provided by engineer. */ +"Fingerprint in forwarding server address does not match certificate: %@." = "L'empreinte numérique dans l'adresse du serveur de transfert ne correspond pas au certificat : %@."; + +/* No comment provided by engineer. */ +"Fingerprint in server address does not match certificate: %@." = "L'empreinte numérique dans l'adresse du serveur ne correspond pas au certificat : %@."; /* relay test error server test error */ -"Fingerprint in server address does not match certificate." = "Il est possible que l'empreinte du certificat dans l'adresse du serveur soit incorrecte"; +"Fingerprint in server address does not match certificate." = "L’empreinte numérique de l’adresse du serveur ne correspond pas au certificat."; /* No comment provided by engineer. */ "Fix" = "Réparer"; @@ -2874,12 +3008,15 @@ server test error */ /* No comment provided by engineer. */ "Fix not supported by group member" = "Correction non prise en charge par un membre du groupe"; +/* No comment provided by engineer. */ +"For all moderators" = "Pour tous les modérateurs"; + /* No comment provided by engineer. */ "For anyone to reach you" = "Pour que n'importe qui puisse vous contacter"; /* servers error servers warning */ -"For chat profile %@:" = "Pour le profil de discussion %@ :"; +"For chat profile %@:" = "Pour le profil de messagerie %@ :"; /* No comment provided by engineer. */ "For console" = "Pour la console"; @@ -2942,7 +3079,7 @@ servers warning */ "Forwarding server: %@\nError: %@" = "Serveur de transfert : %1$@\nErreur : %2$@"; /* No comment provided by engineer. */ -"Found desktop" = "Bureau trouvé"; +"Found desktop" = "PC trouvé"; /* No comment provided by engineer. */ "French interface" = "Interface en français"; @@ -2968,6 +3105,9 @@ servers warning */ /* No comment provided by engineer. */ "Get notified when mentioned." = "Soyez averti·e quand vous êtes mentionné·e."; +/* No comment provided by engineer. */ +"Get SimpleX name (BETA)" = "Obtenir un nom SimpleX (BETA)"; + /* No comment provided by engineer. */ "Get started" = "Commençons"; @@ -3013,6 +3153,9 @@ servers warning */ /* No comment provided by engineer. */ "Group invitation is no longer valid, it was removed by sender." = "L'invitation du groupe n'est plus valide, elle a été supprimé par l'expéditeur."; +/* No comment provided by engineer. */ +"group is deleted" = "le groupe est supprimé"; + /* chat link info line */ "Group link" = "Lien du groupe"; @@ -3068,7 +3211,7 @@ servers warning */ "Hidden" = "Caché"; /* No comment provided by engineer. */ -"Hidden chat profiles" = "Profils de chat cachés"; +"Hidden chat profiles" = "Profils de messagerie cachés"; /* No comment provided by engineer. */ "Hidden profile password" = "Mot de passe de profil caché"; @@ -3112,12 +3255,18 @@ servers warning */ /* No comment provided by engineer. */ "How to" = "Comment faire"; +/* No comment provided by engineer. */ +"How to register a test name" = "Comment enregistrer un nom de test"; + /* No comment provided by engineer. */ "How to use it" = "Comment l'utiliser"; /* No comment provided by engineer. */ "How to use your servers" = "Comment utiliser vos serveurs"; +/* No comment provided by engineer. */ +"https://" = "https://"; + /* No comment provided by engineer. */ "Hungarian interface" = "Interface en hongrois"; @@ -3137,7 +3286,7 @@ servers warning */ "If you joined or created channels, they will stop working permanently." = "Si vous avez rejoint ou créé des canaux, ils arrêteront de fonctionner définitivement."; /* No comment provided by engineer. */ -"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Si vous avez besoin d'utiliser le chat maintenant appuyez sur **le faire plus tard** (vous pourrez migrer la base de données quand vous relancerez l'appli)."; +"If you need to use the chat now tap **Do it later** below (you will be offered to migrate the database when you restart the app)." = "Si vous devez utiliser la conversation maintenant, appuyez sur **Reporter l'opération** ci-dessous (la migration de la base de données vous sera proposée au redémarrage de l'application)."; /* No comment provided by engineer. */ "Ignore" = "Ignorer"; @@ -3158,7 +3307,7 @@ servers warning */ "Import" = "Importer"; /* No comment provided by engineer. */ -"Import chat database?" = "Importer la base de données du chat ?"; +"Import chat database?" = "Importer la base de données de la messagerie ?"; /* No comment provided by engineer. */ "Import database" = "Importer la base de données"; @@ -3185,7 +3334,7 @@ servers warning */ "Improved server configuration" = "Configuration de serveur améliorée"; /* No comment provided by engineer. */ -"In order to continue, chat should be stopped." = "Pour continuer, le chat doit être interrompu."; +"In order to continue, chat should be stopped." = "Pour continuer, la messagerie doit être interrompue."; /* No comment provided by engineer. */ "In reply to" = "En réponse à"; @@ -3284,10 +3433,10 @@ servers warning */ "Invalid (wrong topic)" = "Invalide (mauvais sujet)"; /* invalid chat data */ -"invalid chat" = "chat invalide"; +"invalid chat" = "conversation invalide"; /* No comment provided by engineer. */ -"invalid chat data" = "données de chat invalides"; +"invalid chat data" = "données de conversation invalides"; /* conn error description */ "Invalid connection link" = "Lien de connection invalide"; @@ -3377,13 +3526,13 @@ servers warning */ "Irreversible message deletion" = "Suppression irréversible des messages"; /* No comment provided by engineer. */ -"Irreversible message deletion is prohibited in this chat." = "La suppression irréversible de message est interdite dans ce chat."; +"Irreversible message deletion is prohibited in this chat." = "La suppression irréversible de message est interdite dans cette conversation."; /* No comment provided by engineer. */ "Irreversible message deletion is prohibited." = "La suppression irréversible de messages est interdite dans ce groupe."; /* No comment provided by engineer. */ -"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Cela permet d'avoir plusieurs connections anonymes sans aucune données partagées entre elles sur un même profil."; +"It allows having many anonymous connections without any shared data between them in a single chat profile." = "Cela permet d'avoir plusieurs connexions anonymes sans aucune donnée partagée entre elles dans un même profil de messagerie."; /* No comment provided by engineer. */ "It can happen when you or your connection used the old database backup." = "Cela peut se produire lorsque vous ou votre contact avez utilisé une ancienne sauvegarde de base de données."; @@ -3418,6 +3567,9 @@ servers warning */ /* No comment provided by engineer. */ "Join channel" = "Rejoindre le canal"; +/* new chat action */ +"Join channel %@" = "Rejoindre le canal %@"; + /* new chat sheet title */ "Join group" = "Rejoindre le groupe"; @@ -3446,7 +3598,7 @@ servers warning */ "Keep unused invitation?" = "Conserver l'invitation inutilisée ?"; /* No comment provided by engineer. */ -"Keep your chats clean" = "Gardez vos discussions propres"; +"Keep your chats clean" = "Gardez vos conversations propres"; /* No comment provided by engineer. */ "Keep your connections" = "Conserver vos connexions"; @@ -3473,10 +3625,10 @@ servers warning */ "Leave channel?" = "Quitter le canal ?"; /* No comment provided by engineer. */ -"Leave chat" = "Quitter la discussion"; +"Leave chat" = "Quitter la conversation"; /* No comment provided by engineer. */ -"Leave chat?" = "Quitter la discussion ?"; +"Leave chat?" = "Quitter la conversation ?"; /* No comment provided by engineer. */ "Leave group" = "Quitter le groupe"; @@ -3490,6 +3642,12 @@ servers warning */ /* No comment provided by engineer. */ "Less traffic on mobile networks." = "Moins de transferts de données sur les réseaux mobiles."; +/* No comment provided by engineer. */ +"Let people connect to you via name registered with your SimpleX address." = "Permettez aux gens de se connecter à vous via le nom associé à votre adresse SimpleX."; + +/* No comment provided by engineer. */ +"Let people join via name registered with this channel link." = "Laissez les gens rejoindre via le nom enregistré avec ce lien de canal."; + /* No comment provided by engineer. */ "Let someone connect to you" = "Laisser quelqu'un se connecter à vous"; @@ -3506,16 +3664,16 @@ servers warning */ "link" = "lien"; /* No comment provided by engineer. */ -"Link mobile and desktop apps! 🔗" = "Liez vos applications mobiles et de bureau ! 🔗"; +"Link mobile and desktop apps! 🔗" = "Liez les applications mobiles et de bureau ! 🔗"; /* owner verification */ "Link signature verified." = "Signature du lien vérifiée."; /* No comment provided by engineer. */ -"Linked desktop options" = "Options de bureau lié"; +"Linked desktop options" = "Options d'ordinateur lié"; /* No comment provided by engineer. */ -"Linked desktops" = "Bureaux liés"; +"Linked desktops" = "Ordinateurs liés"; /* No comment provided by engineer. */ "Links" = "Liens"; @@ -3562,6 +3720,9 @@ servers warning */ /* No comment provided by engineer. */ "Make sure WebRTC ICE server addresses are in correct format, line separated and are not duplicated." = "Assurez-vous que les adresses des serveurs WebRTC ICE sont au bon format et ne sont pas dupliquées, un par ligne."; +/* No comment provided by engineer. */ +"Manage your relays." = "Gérer vos relais."; + /* No comment provided by engineer. */ "Mark deleted for everyone" = "Marquer comme supprimé pour tout le monde"; @@ -3620,7 +3781,7 @@ servers warning */ "Member reports" = "Signalements des membres"; /* alert message */ -"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la discussion - cela ne peut pas être annulé !"; +"Member will be removed from chat - this cannot be undone!" = "Le membre sera retiré de la conversation - cette action est irréversible !"; /* alert message */ "Member will be removed from group - this cannot be undone!" = "Ce membre sera retiré du groupe - impossible de revenir en arrière !"; @@ -3638,7 +3799,7 @@ servers warning */ "Members can irreversibly delete sent messages. (24 hours)" = "Les membres du groupe peuvent supprimer de manière irréversible les messages envoyés. (24 heures)"; /* No comment provided by engineer. */ -"Members can report messsages to moderators." = "Les membres peuvent signaler les messages aux modérateur·ices."; +"Members can report messsages to moderators." = "Les membres peuvent signaler les messages aux modérateurs."; /* No comment provided by engineer. */ "Members can send direct messages." = "Les membres du groupe peuvent envoyer des messages directs."; @@ -3695,7 +3856,7 @@ servers warning */ "Message reactions" = "Réactions aux messages"; /* No comment provided by engineer. */ -"Message reactions are prohibited in this chat." = "Les réactions aux messages sont interdites dans ce chat."; +"Message reactions are prohibited in this chat." = "Les réactions aux messages sont interdites dans cette conversation."; /* No comment provided by engineer. */ "Message reactions are prohibited." = "Les réactions aux messages sont interdites dans ce groupe."; @@ -3712,6 +3873,12 @@ servers warning */ /* No comment provided by engineer. */ "Message shape" = "Forme du message"; +/* No comment provided by engineer. */ +"Message signing is not required." = "La signature des messages n'est pas requise."; + +/* No comment provided by engineer. */ +"Message signing is required." = "La signature des messages est requise."; + /* No comment provided by engineer. */ "Message source remains private." = "La source du message reste privée."; @@ -3740,13 +3907,13 @@ servers warning */ "Messages from %@ will be shown!" = "Les messages de %@ seront affichés !"; /* No comment provided by engineer. */ -"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Les messages dans ce canal **ne sont pas chiffrés de bout-en-bout**. Les relais de discussion peuvent voir ces messages."; +"Messages in this channel are **not end-to-end encrypted**. Chat relays can see these messages." = "Les messages dans ce canal **ne sont pas chiffrés de bout-en-bout**. Les relais de messagerie peuvent voir ces messages."; /* E2EE info chat item */ -"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Les messages dans ce canal ne sont pas chiffrés de bout-en-bout. Les relais de discussion peuvent voir ces messages."; +"Messages in this channel are not end-to-end encrypted. Chat relays can see these messages." = "Les messages dans ce canal ne sont pas chiffrés de bout-en-bout. Les relais de messagerie peuvent voir ces messages."; /* alert message */ -"Messages in this chat will never be deleted." = "Les messages dans cette discussion ne seront jamais supprimés."; +"Messages in this chat will never be deleted." = "Les messages dans cette conversation ne seront jamais supprimés."; /* No comment provided by engineer. */ "Messages received" = "Messages reçus"; @@ -3791,7 +3958,7 @@ servers warning */ "Migration error:" = "Erreur de migration :"; /* No comment provided by engineer. */ -"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Échec de la migration. Appuyez sur **Passer** ci-dessous pour continuer à utiliser la base de données actuelle. Veuillez signaler le problème aux développeurs de l'appli par discussion ou par courriel [chat@simplex.chat](mailto:chat@simplex.chat)."; +"Migration failed. Tap **Skip** below to continue using the current database. Please report the issue to the app developers via chat or email [chat@simplex.chat](mailto:chat@simplex.chat)." = "Échec de la migration. Appuyez sur **Ignorer** ci-dessous pour continuer à utiliser la base de données actuelle. Veuillez signaler le problème aux développeurs de l'application via une conversation ou par email à [chat@simplex.chat](mailto:chat@simplex.chat)."; /* No comment provided by engineer. */ "Migration is completed" = "La migration est terminée"; @@ -3821,7 +3988,7 @@ servers warning */ "moderated by %@" = "modéré par %@"; /* member role */ -"moderator" = "modérateur·ice"; +"moderator" = "modérateur"; /* time unit */ "months" = "mois"; @@ -3845,7 +4012,7 @@ servers warning */ "Most likely this connection is deleted." = "Connexion probablement supprimée."; /* No comment provided by engineer. */ -"Multiple chat profiles" = "Différents profils de chat"; +"Multiple chat profiles" = "Plusieurs profils de messagerie"; /* notification label action */ "Mute" = "Muet"; @@ -3859,6 +4026,9 @@ servers warning */ /* swipe action */ "Name" = "Nom"; +/* No comment provided by engineer. */ +"Name not found" = "Nom introuvable"; + /* No comment provided by engineer. */ "Network & servers" = "Réseau et serveurs"; @@ -3905,13 +4075,13 @@ servers warning */ "New 1-time link" = "Nouveau lien unique"; /* No comment provided by engineer. */ -"New chat" = "Nouvelle discussion"; +"New chat" = "Nouvelle conversation"; /* No comment provided by engineer. */ -"New chat experience 🎉" = "Nouvelle expérience de discussion 🎉"; +"New chat experience 🎉" = "Nouvelle expérience de conversation 🎉"; /* No comment provided by engineer. */ -"New chat relay" = "Nouveau relais de discussion"; +"New chat relay" = "Nouveau relais de messagerie"; /* notification */ "New contact request" = "Nouvelle demande de contact"; @@ -3928,6 +4098,9 @@ servers warning */ /* notification */ "New events" = "Nouveaux événements"; +/* No comment provided by engineer. */ +"New group role: Moderator" = "Nouveau rôle de groupe : Modérateur"; + /* No comment provided by engineer. */ "New in %@" = "Nouveautés de la %@"; @@ -3980,22 +4153,22 @@ servers warning */ "No available relays" = "Aucun relais disponible"; /* No comment provided by engineer. */ -"No chat relays" = "Aucun relais de discussion"; +"No chat relays" = "Aucun relais de messagerie"; /* servers warning */ -"No chat relays enabled." = "Aucun relais de discussion disponible."; +"No chat relays enabled." = "Aucun relais de messagerie n'est activé."; /* No comment provided by engineer. */ -"No chats" = "Aucune discussion"; +"No chats" = "Aucune conversation"; /* No comment provided by engineer. */ -"No chats found" = "Aucune discussion trouvée"; +"No chats found" = "Aucune conversation trouvée"; /* No comment provided by engineer. */ -"No chats in list %@" = "Aucune discussion dans la liste %@"; +"No chats in list %@" = "Aucune conversation dans la liste %@"; /* No comment provided by engineer. */ -"No chats with members" = "Aucune discussion avec les membres"; +"No chats with members" = "Aucune conversation avec les membres"; /* No comment provided by engineer. */ "No contacts selected" = "Aucun contact sélectionné"; @@ -4016,7 +4189,7 @@ servers warning */ "no e2e encryption" = "sans chiffrement de bout en bout"; /* No comment provided by engineer. */ -"No filtered chats" = "Aucune discussion filtrés"; +"No filtered chats" = "Aucune conversation filtrée"; /* No comment provided by engineer. */ "No group!" = "Groupe introuvable !"; @@ -4069,6 +4242,9 @@ servers warning */ /* servers error */ "No servers to receive messages." = "Pas de serveurs pour recevoir des messages."; +/* servers warning */ +"No servers to resolve names." = "Aucun serveur pour résoudre les noms."; + /* servers error */ "No servers to send files." = "Pas de serveurs pour envoyer des fichiers."; @@ -4082,7 +4258,10 @@ servers warning */ "No token!" = "Aucun jeton !"; /* No comment provided by engineer. */ -"No unread chats" = "Aucune discussion non lue"; +"No unread chats" = "Aucune conversation non lue"; + +/* No comment provided by engineer. */ +"No valid link" = "Aucun lien valide"; /* No comment provided by engineer. */ "Nobody tracked your conversations. No one drew a map of where you'd been. Privacy was never a feature - it was the way of life." = "Personne ne suivait vos conversations. Personne ne dessinait de carte d'où vous étiez. La vie privée n'était jamais une caractéristique – c'était le mode de vie."; @@ -4090,6 +4269,9 @@ servers warning */ /* No comment provided by engineer. */ "Non-profit governance" = "Gouvernance à but non lucratif"; +/* No comment provided by engineer. */ +"None of your servers are set to resolve SimpleX names. Configure servers, or use a connection link." = "Aucun serveur de résolution de noms SimpleX n'est configuré. Configurez des serveurs ou utilisez un lien de connexion."; + /* No comment provided by engineer. */ "Not a better lock on someone else's door. Not a nicer landlord that respects your privacy, but still keeps the record of all visitors. You are not a guest. You are home. No king can enter it - you are sovereign." = "Ce n’est pas une meilleure serrure sur la porte de quelqu’un d’autre. Ce n’est pas un propriétaire plus aimable qui respecte votre vie privée, mais qui tient tout de même un registre de tous les visiteurs. Vous n’êtes pas un·e invité·e, vous êtes chez vous. Aucun roi ne peut y entrer : c’est vous le souverain."; @@ -4256,10 +4438,10 @@ alert button */ "Open channel" = "Ouvrir le canal"; /* new chat action */ -"Open chat" = "Ouvrir le chat"; +"Open chat" = "Ouvrir la conversation"; /* authentication reason */ -"Open chat console" = "Ouvrir la console du chat"; +"Open chat console" = "Ouvrir la console de la messagerie"; /* alert action */ "Open clean link" = "Ouvrir le lien nettoyé"; @@ -4286,7 +4468,7 @@ alert button */ "Open new channel" = "Ouvrir un nouveau canal"; /* new chat action */ -"Open new chat" = "Ouvrir une nouvelle discussion"; +"Open new chat" = "Ouvrir une nouvelle conversation"; /* new chat action */ "Open new group" = "Ouvrir le nouveau groupe"; @@ -4343,7 +4525,7 @@ alert button */ "Or use this QR - print or show online." = "Ou utilisez ce code QR : imprimez-le ou affichez-le en ligne."; /* No comment provided by engineer. */ -"Organize chats into lists" = "Organisez des discussions en listes"; +"Organize chats into lists" = "Organisez des conversations en listes"; /* No comment provided by engineer. */ "other" = "autre"; @@ -4366,6 +4548,9 @@ alert button */ /* feature role */ "owners" = "propriétaires"; +/* No comment provided by engineer. */ +"Owners & contributors" = "Propriétaires et contributeurs"; + /* No comment provided by engineer. */ "Ownership: you can run your own relays." = "Propriétaire : vous pouvez exécuter vos propres relais."; @@ -4391,7 +4576,7 @@ alert button */ "Password to show" = "Mot de passe à entrer"; /* No comment provided by engineer. */ -"Paste desktop address" = "Coller l'adresse du bureau"; +"Paste desktop address" = "Coller l'adresse du PC"; /* No comment provided by engineer. */ "Paste image" = "Coller l'image"; @@ -4436,7 +4621,7 @@ alert button */ "PING interval" = "Intervalle de PING"; /* No comment provided by engineer. */ -"Play from the chat list." = "Aperçu depuis la liste de conversation."; +"Play from the chat list." = "Lire depuis la liste des conversations."; /* No comment provided by engineer. */ "Please ask your contact to enable calls." = "Veuillez demander à votre contact d'autoriser les appels."; @@ -4445,7 +4630,7 @@ alert button */ "Please ask your contact to enable sending voice messages." = "Veuillez demander à votre contact de permettre l'envoi de messages vocaux."; /* No comment provided by engineer. */ -"Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Veuillez vérifier que le téléphone portable et l'ordinateur de bureau sont connectés au même réseau local et que le pare-feu de l'ordinateur de bureau autorise la connexion.\nVeuillez faire part de tout autre problème aux développeurs."; +"Please check that mobile and desktop are connected to the same local network, and that desktop firewall allows the connection.\nPlease share any other issues with the developers." = "Vérifiez que le mobile et l’ordinateur sont connectés au même réseau local et que le pare-feu de l’ordinateur autorise la connexion.\nVeuillez signaler tout autre problème aux développeurs."; /* No comment provided by engineer. */ "Please check that you used the correct link or ask your contact to send you another one." = "Veuillez vérifier que vous avez utilisé le bon lien ou demandez à votre contact de vous en envoyer un autre."; @@ -4481,7 +4666,7 @@ alert button */ "Please restart the app and migrate the database to enable push notifications." = "Veuillez redémarrer l'app et migrer la base de données pour activer les notifications push."; /* No comment provided by engineer. */ -"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez."; +"Please store passphrase securely, you will NOT be able to access chat if you lose it." = "Conservez votre phrase secrète en lieu sûr, vous ne pourrez PAS accéder à vos conversations si vous la perdez."; /* No comment provided by engineer. */ "Please store passphrase securely, you will NOT be able to change it if you lose it." = "Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS la changer si vous la perdez."; @@ -4490,7 +4675,7 @@ alert button */ "Please try to disable and re-enable notfications." = "Veuillez essayer de désactiver et réactiver les notifications."; /* snd group event chat item */ -"Please wait for group moderators to review your request to join the group." = "Veuillez attendre que les modérateur·ices de groupe examinent votre demande pour rejoindre le groupe."; +"Please wait for group moderators to review your request to join the group." = "Veuillez attendre que les modérateurs de groupe examinent votre demande pour rejoindre le groupe."; /* token info */ "Please wait for token activation to complete." = "Veuillez attendre la fin de l'activation du jeton."; @@ -4596,7 +4781,7 @@ alert title */ "Prohibit messages reactions." = "Interdire les réactions aux messages."; /* No comment provided by engineer. */ -"Prohibit reporting messages to moderators." = "Interdire de signaler des messages aux modérateur·ices."; +"Prohibit reporting messages to moderators." = "Interdire de signaler des messages aux modérateurs."; /* No comment provided by engineer. */ "Prohibit sending direct messages to members." = "Interdire l'envoi de messages directs aux membres."; @@ -4623,7 +4808,7 @@ alert title */ "Protect IP address" = "Protéger l'adresse IP"; /* No comment provided by engineer. */ -"Protect your chat profiles with a password!" = "Protégez vos profils de discussion par un mot de passe !"; +"Protect your chat profiles with a password!" = "Protégez vos profils de messagerie par un mot de passe !"; /* No comment provided by engineer. */ "Protect your IP address from the messaging relays chosen by your contacts.\nEnable in *Network & servers* settings." = "Protégez votre adresse IP des relais de messagerie choisis par vos contacts.\nActivez-le dans les paramètres *Réseau et serveurs*."; @@ -4649,6 +4834,9 @@ alert title */ /* No comment provided by engineer. */ "Public channels - speak freely 🚀" = "Les canaux publics – parlez librement 🚀"; +/* No comment provided by engineer. */ +"Public names for your channel or business." = "Noms publics pour votre canal ou votre entreprise."; + /* No comment provided by engineer. */ "Push notifications" = "Notifications push"; @@ -4665,7 +4853,7 @@ alert title */ "Rate the app" = "Évaluer l'appli"; /* No comment provided by engineer. */ -"Reachable chat toolbar" = "Barre d'outils accessible"; +"Reachable chat toolbar" = "Barre d’outils de conversation accessible"; /* chat item menu */ "React…" = "Réagissez…"; @@ -4813,9 +5001,24 @@ swipe action */ /* No comment provided by engineer. */ "Relay server protects your IP address, but it can observe the duration of the call." = "Le serveur relais protège votre adresse IP, mais il peut observer la durée de l'appel."; +/* No comment provided by engineer. */ +"Relay test failed!" = "Échec du test de relais !"; + +/* alert message */ +"Relay will be removed from channel - this cannot be undone!" = "Le relais sera supprimé du canal ; cette action est irréversible !"; + +/* alert message */ +"Relays added: %@." = "Relais ajoutés : %@."; + +/* No comment provided by engineer. */ +"Reliability: many relays per channel." = "Fiabilité : plusieurs relais par canal."; + /* alert action */ "Remove" = "Supprimer"; +/* alert action */ +"Remove and delete messages" = "Retirer et supprimer des messages"; + /* No comment provided by engineer. */ "Remove archive?" = "Supprimer l'archive ?"; @@ -4831,9 +5034,21 @@ swipe action */ /* alert title */ "Remove member?" = "Retirer ce membre ?"; +/* No comment provided by engineer. */ +"Remove name" = "Retirer le nom"; + /* No comment provided by engineer. */ "Remove passphrase from keychain?" = "Supprimer la phrase secrète de la keychain ?"; +/* No comment provided by engineer. */ +"Remove relay" = "Retirer le relais"; + +/* alert title */ +"Remove relay?" = "Retirer le relais ?"; + +/* alert title */ +"Remove subscriber?" = "Supprimer l'abonné ?"; + /* No comment provided by engineer. */ "removed" = "retiré"; @@ -4858,6 +5073,9 @@ swipe action */ /* rcv group event chat item */ "removed you" = "vous a retiré"; +/* No comment provided by engineer. */ +"Removes messages and blocks members." = "Supprime les messages et bloque les membres."; + /* No comment provided by engineer. */ "Renegotiate" = "Renégocier"; @@ -4907,7 +5125,7 @@ swipe action */ "Report: %@" = "Signalement : %@"; /* No comment provided by engineer. */ -"Reporting messages to moderators is prohibited." = "Signaler des messages aux modérateur·ices est interdit."; +"Reporting messages to moderators is prohibited." = "Signaler des messages aux modérateurs est interdit."; /* No comment provided by engineer. */ "Reports" = "Signalements"; @@ -4927,6 +5145,9 @@ swipe action */ /* chat list item title */ "requested to connect" = "demande à se connecter"; +/* No comment provided by engineer. */ +"Require signing messages." = "Exiger la signature des messages."; + /* No comment provided by engineer. */ "Required" = "Requis"; @@ -4955,10 +5176,13 @@ swipe action */ "Reset to user theme" = "Réinitialisation au thème de l'utilisateur"; /* No comment provided by engineer. */ -"Restart the app to create a new chat profile" = "Redémarrez l'appli pour créer un nouveau profil de discussion"; +"Resolver error: %@" = "Erreur de résolution : %@"; /* No comment provided by engineer. */ -"Restart the app to use imported chat database" = "Redémarrez l'application pour utiliser la base de données de chat importée"; +"Restart the app to create a new chat profile" = "Redémarrez l'appli pour créer un nouveau profil de messagerie"; + +/* No comment provided by engineer. */ +"Restart the app to use imported chat database" = "Redémarrez l’application pour utiliser la base de données de conversations importée"; /* No comment provided by engineer. */ "Restore" = "Restaurer"; @@ -5009,16 +5233,19 @@ swipe action */ "Role" = "Rôle"; /* No comment provided by engineer. */ -"Role will be changed to \"%@\". All chat members will be notified." = "Le rôle du membre sera modifié pour « %@ ». Tous les membres du chat seront notifiés."; +"Role will be changed to \"%@\". All chat members will be notified." = "Le rôle du membre sera modifié pour « %@ ». Tous les membres de la conversation seront notifiés."; /* No comment provided by engineer. */ "Role will be changed to \"%@\". All group members will be notified." = "Le rôle du membre sera changé pour \"%@\". Tous les membres du groupe en seront informés."; +/* No comment provided by engineer. */ +"Role will be changed to \"%@\". All subscribers will be notified." = "Le rôle sera remplacé par « %@ ». Tous les abonnés en seront informés."; + /* No comment provided by engineer. */ "Role will be changed to \"%@\". The member will receive a new invitation." = "Le rôle du membre sera changé pour \"%@\". Ce membre recevra une nouvelle invitation."; /* No comment provided by engineer. */ -"Run chat" = "Exécuter le chat"; +"Run chat" = "Lancer la messagerie"; /* No comment provided by engineer. */ "Safe web links" = "Liens Web sûrs"; @@ -5080,7 +5307,7 @@ chat item action */ "Save list" = "Enregistrer la liste"; /* No comment provided by engineer. */ -"Save passphrase and open chat" = "Enregistrer la phrase secrète et ouvrir le chat"; +"Save passphrase and open chat" = "Enregistrer la phrase secrète et ouvrir la messagerie"; /* No comment provided by engineer. */ "Save passphrase in Keychain" = "Enregistrer la phrase secrète dans la Keychain"; @@ -5097,6 +5324,9 @@ chat item action */ /* alert title */ "Save servers?" = "Enregistrer les serveurs ?"; +/* alert title */ +"Save SimpleX name?" = "Enregistrer le nom SimpleX ?"; + /* alert title */ "Save webpage settings?" = "Enregistrer les paramètres de la page Web ?"; @@ -5116,7 +5346,7 @@ chat item action */ "Saved from" = "Enregistré depuis"; /* No comment provided by engineer. */ -"saved from %@" = "enregistré à partir de %@"; +"saved from" = "enregistré à partir de"; /* message info title */ "Saved message" = "Message enregistré"; @@ -5140,7 +5370,7 @@ chat item action */ "Scan QR code" = "Scanner un code QR"; /* No comment provided by engineer. */ -"Scan QR code from desktop" = "Scannez le code QR du bureau"; +"Scan QR code from desktop" = "Scannez le QR code du PC"; /* No comment provided by engineer. */ "Scan security code from your contact's app." = "Scannez le code de sécurité depuis l'application de votre contact."; @@ -5209,13 +5439,13 @@ chat item action */ "Select" = "Choisir"; /* No comment provided by engineer. */ -"Select chat profile" = "Sélectionner un profil de discussion"; +"Select chat profile" = "Sélectionner un profil de messagerie"; /* No comment provided by engineer. */ "Selected %lld" = "%lld sélectionné(s)"; /* No comment provided by engineer. */ -"Selected chat preferences prohibit this message." = "Les préférences de chat sélectionnées interdisent ce message."; +"Selected chat preferences prohibit this message." = "Les préférences de conversation sélectionnées interdisent ce message."; /* No comment provided by engineer. */ "Self-destruct" = "Autodestruction"; @@ -5305,7 +5535,7 @@ chat item action */ "Sending a link preview may reveal your IP address to the website. You can change this in Privacy settings later." = "L'envoi d'un aperçu de lien peut révéler votre adresse IP au site Web. Vous pouvez modifier ceci dans les paramètres de confidentialité plus tard."; /* No comment provided by engineer. */ -"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "L'envoi d'accusés de réception sera activé pour tous les contacts dans tous les profils de chat visibles."; +"Sending delivery receipts will be enabled for all contacts in all visible chat profiles." = "L'envoi d'accusés de réception sera activé pour tous les contacts dans tous les profils de messagerie visibles."; /* No comment provided by engineer. */ "Sending delivery receipts will be enabled for all contacts." = "L'envoi d'accusés de réception sera activé pour tous les contacts."; @@ -5358,6 +5588,9 @@ chat item action */ /* No comment provided by engineer. */ "Server" = "Serveur"; +/* No comment provided by engineer. */ +"Server %@ does not support name resolution. Configure servers, or use a connection link." = "Le serveur %@ ne prend pas en charge la résolution de noms. Configurez des serveurs ou utilisez un lien de connexion."; + /* alert message */ "Server added to operator %@." = "Serveur ajouté à l'opérateur %@."; @@ -5419,7 +5652,7 @@ chat item action */ "Set 1 day" = "Définir 1 jour"; /* No comment provided by engineer. */ -"Set chat name…" = "Paramétrer le nom de la discussion…"; +"Set chat name…" = "Définir le nom de la conversation…"; /* No comment provided by engineer. */ "Set contact name…" = "Définir le nom du contact…"; @@ -5437,7 +5670,7 @@ chat item action */ "Set member admission" = "Paramétrer l'admission des membres"; /* No comment provided by engineer. */ -"Set message expiration in chats." = "Paramétrer l'expiration des messages dans les discussions."; +"Set message expiration in chats." = "Définir l’expiration des messages dans les conversations."; /* profile update event chat item */ "set new contact address" = "a changé d'adresse de contact"; @@ -5528,7 +5761,7 @@ chat item action */ "Share to SimpleX" = "Partager sur SimpleX"; /* No comment provided by engineer. */ -"Share via chat" = "Partager via la discussion"; +"Share via chat" = "Partager via la conversation"; /* No comment provided by engineer. */ "Share with SimpleX contacts" = "Partager avec les contacts SimpleX"; @@ -5554,6 +5787,9 @@ chat item action */ /* No comment provided by engineer. */ "Show developer options" = "Afficher les options pour les développeurs"; +/* No comment provided by engineer. */ +"Show encryption" = "Afficher le chiffrement"; + /* No comment provided by engineer. */ "Show last messages" = "Aperçu des derniers messages"; @@ -5572,6 +5808,25 @@ chat item action */ /* No comment provided by engineer. */ "Show:" = "Afficher :"; +/* No comment provided by engineer. */ +"Sign message" = "Signer le message"; + +/* chat feature */ +"Sign messages" = "Signer les messages"; + +/* alert title +copied message info */ +"Signature missing" = "Signature manquante"; + +/* copied message info */ +"Signed" = "Signé"; + +/* copied message info */ +"Signed & verified" = "Signé et vérifié"; + +/* No comment provided by engineer. */ +"Signing proves you authored this message and can't be denied later." = "La signature atteste que vous êtes l'auteur de ce message, de manière irrévocable."; + /* No comment provided by engineer. */ "SimpleX" = "SimpleX"; @@ -5594,7 +5849,7 @@ chat item action */ "SimpleX channel link" = "Lien de canal SimpleX"; /* No comment provided by engineer. */ -"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l'application."; +"SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app." = "SimpleX Chat et Flux ont conclu un accord pour intégrer les serveurs opérés par Flux à l’application."; /* No comment provided by engineer. */ "SimpleX Chat security was audited by Trail of Bits." = "La sécurité de SimpleX Chat a été auditée par Trail of Bits."; @@ -5629,12 +5884,24 @@ chat item action */ /* No comment provided by engineer. */ "SimpleX Lock turned on" = "SimpleX Lock activé"; +/* No comment provided by engineer. */ +"SimpleX name" = "Nom SimpleX"; + +/* No comment provided by engineer. */ +"SimpleX name error" = "Échec du nom SimpleX"; + +/* alert title */ +"SimpleX name not verified" = "Nom SimpleX non vérifié"; + /* simplex link type */ "SimpleX one-time invitation" = "Invitation unique SimpleX"; /* No comment provided by engineer. */ "SimpleX protocols reviewed by Trail of Bits." = "Protocoles SimpleX audité par Trail of Bits."; +/* No comment provided by engineer. */ +"SimpleX public names (BETA)" = "Noms publics SimpleX (BETA)"; + /* simplex link type */ "SimpleX relay address" = "Adresse relais SimpleX"; @@ -5645,7 +5912,7 @@ chat item action */ "Size" = "Taille"; /* No comment provided by engineer. */ -"Skip" = "Passer"; +"Skip" = "Ignorer"; /* No comment provided by engineer. */ "Skipped messages" = "Messages manqués"; @@ -5669,7 +5936,7 @@ chat item action */ "Some file(s) were not exported:" = "Certains fichiers n'ont pas été exportés :"; /* No comment provided by engineer. */ -"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Des erreurs non fatales se sont produites lors de l'importation - vous pouvez consulter la console de chat pour plus de détails."; +"Some non-fatal errors occurred during import - you may see Chat console for more details." = "Certaines erreurs non fatales sont survenues lors de l’importation : consultez la console des conversations pour plus de détails."; /* No comment provided by engineer. */ "Some non-fatal errors occurred during import:" = "L'importation a entraîné des erreurs non fatales :"; @@ -5694,10 +5961,10 @@ report reason */ "Star on GitHub" = "Donnez une étoile sur GitHub"; /* No comment provided by engineer. */ -"Start chat" = "Démarrer la discussion"; +"Start chat" = "Démarrer la messagerie"; /* No comment provided by engineer. */ -"Start chat?" = "Démarrer la discussion ?"; +"Start chat?" = "Démarrer la messagerie ?"; /* No comment provided by engineer. */ "Start migration" = "Démarrer la migration"; @@ -5718,13 +5985,13 @@ report reason */ "Stop" = "Arrêter"; /* No comment provided by engineer. */ -"Stop chat" = "Arrêter la discussion"; +"Stop chat" = "Arrêter la messagerie"; /* No comment provided by engineer. */ -"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Arrêtez la discussion pour exporter, importer ou supprimer la base de données de la discussion. Vous ne pourrez pas recevoir et envoyer de messages pendant que la discussion est arrêtée."; +"Stop chat to export, import or delete chat database. You will not be able to receive and send messages while the chat is stopped." = "Arrêtez la conversation pour exporter, importer ou supprimer la base de données des conversations. Vous ne pourrez pas recevoir ni envoyer de messages tant que la conversation est arrêtée."; /* No comment provided by engineer. */ -"Stop chat?" = "Arrêter la discussion ?"; +"Stop chat?" = "Arrêter la messagerie ?"; /* cancel file action */ "Stop file" = "Arrêter le fichier"; @@ -5736,16 +6003,16 @@ report reason */ "Stop sending file?" = "Arrêter l'envoi du fichier ?"; /* alert action */ -"Stop sharing" = "Cesser le partage"; +"Stop sharing" = "Arrêter le partage"; /* alert title */ -"Stop sharing address?" = "Cesser le partage d'adresse ?"; +"Stop sharing address?" = "Arrêter le partage d'adresse ?"; /* authentication reason */ "Stop SimpleX" = "Arrêter SimpleX"; /* No comment provided by engineer. */ -"Stopping chat" = "Arrêt du chat"; +"Stopping chat" = "Arrêt de la messagerie"; /* No comment provided by engineer. */ "Storage" = "Stockage"; @@ -5762,20 +6029,65 @@ report reason */ /* No comment provided by engineer. */ "Subscribed" = "Inscriptions"; +/* member role */ +"subscriber" = "abonné"; + /* No comment provided by engineer. */ "Subscriber" = "Abonné·e"; +/* chat feature */ +"Subscriber reports" = "Signalements d'abonnés"; + +/* alert message */ +"Subscriber will be removed from channel - this cannot be undone!" = "L'abonné sera supprimé du canal ; cette action est irréversible !"; + +/* No comment provided by engineer. */ +"Subscribers" = "Abonnés"; + +/* No comment provided by engineer. */ +"Subscribers can add message reactions." = "Les abonnés peuvent ajouter des réactions aux messages."; + +/* No comment provided by engineer. */ +"Subscribers can chat with admins." = "Les abonnés peuvent discuter avec les admins."; + +/* No comment provided by engineer. */ +"Subscribers can irreversibly delete sent messages. (24 hours)" = "Les abonnés peuvent supprimer définitivement les messages envoyés. (24 heures)"; + +/* No comment provided by engineer. */ +"Subscribers can report messsages to moderators." = "Les abonnés peuvent signaler les messages aux modérateurs."; + +/* No comment provided by engineer. */ +"Subscribers can send direct messages." = "Les abonnés peuvent envoyer des messages directs."; + +/* No comment provided by engineer. */ +"Subscribers can send disappearing messages." = "Les abonnés peuvent envoyer des messages éphémères."; + +/* No comment provided by engineer. */ +"Subscribers can send files and media." = "Les abonnés peuvent envoyer des fichiers et des médias."; + +/* No comment provided by engineer. */ +"Subscribers can send SimpleX links." = "Les abonnés peuvent envoyer des liens SimpleX."; + +/* No comment provided by engineer. */ +"Subscribers can send voice messages." = "Les abonnés peuvent envoyer des messages vocaux."; + +/* No comment provided by engineer. */ +"Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel." = "Les abonnés utilisent le lien du relais pour se connecter au canal.\nL'adresse du relais a été utilisée pour configurer ce relais pour le canal."; + /* No comment provided by engineer. */ "Subscription errors" = "Erreurs d'inscription"; /* No comment provided by engineer. */ "Subscriptions ignored" = "Inscriptions ignorées"; +/* No comment provided by engineer. */ +"Support the project" = "Soutenez le projet"; + /* No comment provided by engineer. */ "Switch audio and video during the call." = "Passer de l'audio à la vidéo pendant l'appel."; /* No comment provided by engineer. */ -"Switch chat profile for 1-time invitations." = "Changer de profil de chat pour les invitations à usage unique."; +"Switch chat profile for 1-time invitations." = "Changer de profil de messagerie pour les invitations à usage unique."; /* No comment provided by engineer. */ "System" = "Système"; @@ -5795,6 +6107,21 @@ report reason */ /* No comment provided by engineer. */ "Tap button " = "Appuyez sur le bouton "; +/* No comment provided by engineer. */ +"Tap Connect to chat" = "Appuyez sur « Se connecter » pour discuter"; + +/* No comment provided by engineer. */ +"Tap Connect to send request" = "Appuyez sur « Se connecter » pour envoyer la requête"; + +/* No comment provided by engineer. */ +"Tap Connect to use bot" = "Appuyez sur « Se connecter » pour utiliser le bot"; + +/* No comment provided by engineer. */ +"Tap Join channel" = "Appuyez sur Rejoindre le canal"; + +/* No comment provided by engineer. */ +"Tap Join group" = "Appuyez sur Rejoindre le groupe"; + /* No comment provided by engineer. */ "Tap to activate profile." = "Appuyez pour activer un profil."; @@ -5807,6 +6134,9 @@ report reason */ /* No comment provided by engineer. */ "Tap to join incognito" = "Appuyez pour rejoindre incognito"; +/* No comment provided by engineer. */ +"Tap to open" = "Appuyez pour ouvrir"; + /* No comment provided by engineer. */ "Tap to paste link" = "Appuyez pour coller le lien"; @@ -5816,9 +6146,15 @@ report reason */ /* No comment provided by engineer. */ "TCP connection" = "Connexion TCP"; +/* No comment provided by engineer. */ +"TCP connection bg timeout" = "Connexion TCP en arrière-plan : délai dépassé"; + /* No comment provided by engineer. */ "TCP connection timeout" = "Délai de connexion TCP"; +/* No comment provided by engineer. */ +"TCP port for messaging" = "Port TCP pour la messagerie"; + /* No comment provided by engineer. */ "TCP_KEEPCNT" = "TCP_KEEPCNT"; @@ -5835,6 +6171,12 @@ report reason */ server test failure */ "Test failed at step %@." = "Échec du test à l'étape %@."; +/* No comment provided by engineer. */ +"Test notifications" = "Notifications de test"; + +/* No comment provided by engineer. */ +"Test relay" = "Tester le relais"; + /* No comment provided by engineer. */ "Test server" = "Tester le serveur"; @@ -5848,26 +6190,41 @@ server test failure */ "Thank you for installing SimpleX Chat!" = "Merci d'avoir installé SimpleX Chat !"; /* No comment provided by engineer. */ -"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Merci aux utilisateurs - [contribuer via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; +"Thanks to the users – [contribute via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat)!" = "Merci aux utilisateurs - [contribuer via Weblate](https://github.com/simplex-chat/simplex-chat/tree/stable#help-translating-simplex-chat) !"; /* No comment provided by engineer. */ "Thanks to the users – contribute via Weblate!" = "Merci aux utilisateurs - contribuez via Weblate !"; +/* alert message */ +"The address will be short, and your profile will be shared via the address." = "L'adresse sera courte et votre profil sera partagé via cette adresse."; + /* No comment provided by engineer. */ "The app can notify you when you receive messages or contact requests - please open settings to enable." = "L'application peut vous avertir lorsque vous recevez des messages ou des demandes de contact - veuillez ouvrir les paramètres pour les activer."; /* No comment provided by engineer. */ "The app protects your privacy by using different operators in each conversation." = "L'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation."; +/* No comment provided by engineer. */ +"The app removed this message after %lld attempts to receive it." = "L'application a supprimé ce message après %lld tentatives de réception."; + /* No comment provided by engineer. */ "The app will ask to confirm downloads from unknown file servers (except .onion)." = "L'application demandera de confirmer les téléchargements à partir de serveurs de fichiers inconnus (sauf .onion)."; /* No comment provided by engineer. */ "The attempt to change database passphrase was not completed." = "La tentative de modification de la phrase secrète de la base de données n'a pas abouti."; +/* badge alert */ +"The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge." = "Le badge est signé avec une clé que cette version de l'application ne reconnaît pas. Mettez l'application à jour pour vérifier ce badge."; + +/* alert message */ +"The channel required this message to be signed, but the signature is missing." = "Le canal exige que ce message soit signé, mais la signature est absente."; + /* No comment provided by engineer. */ "The code you scanned is not a SimpleX link QR code." = "Le code scanné n'est pas un code QR de lien SimpleX."; +/* conn error description */ +"The connection reached the limit of undelivered messages" = "La connexion a atteint la limite de messages non distribués"; + /* No comment provided by engineer. */ "The connection reached the limit of undelivered messages, your contact may be offline." = "La connexion a atteint la limite des messages non délivrés, votre contact est peut-être hors ligne."; @@ -5883,12 +6240,18 @@ server test failure */ /* No comment provided by engineer. */ "The encryption is working and the new encryption agreement is not required. It may result in connection errors!" = "Le chiffrement fonctionne et le nouvel accord de chiffrement n'est pas nécessaire. Cela peut provoquer des erreurs de connexion !"; +/* No comment provided by engineer. */ +"The first network where you own\nyour contacts and groups." = "Le premier réseau où vous possédez\nvos contacts et vos groupes."; + /* No comment provided by engineer. */ "The hash of the previous message is different." = "Le hash du message précédent est différent."; /* No comment provided by engineer. */ "The ID of the next message is incorrect (less or equal to the previous).\nIt can happen because of some bug or when the connection is compromised." = "L'ID du message suivant est incorrect (inférieur ou égal au précédent).\nCela peut se produire en raison d'un bug ou lorsque la connexion est compromise."; +/* alert message */ +"The link will be short, and group profile will be shared via the link." = "Le lien sera court et le profil du groupe sera partagé via ce lien."; + /* No comment provided by engineer. */ "The message will be deleted for all members." = "Le message sera supprimé pour tous les membres."; @@ -5904,6 +6267,9 @@ server test failure */ /* No comment provided by engineer. */ "The old database was not removed during the migration, it can be deleted." = "L'ancienne base de données n'a pas été supprimée lors de la migration, elle peut être supprimée."; +/* No comment provided by engineer. */ +"The oldest human freedom - to speak to another person without being watched - built on infrastructure that cannot betray it." = "La plus ancienne des libertés humaines : parler à une autre personne sans être surveillé, sur une infrastructure qui ne peut pas la trahir."; + /* No comment provided by engineer. */ "The same conditions will apply to operator **%@**." = "Les mêmes conditions s'appliquent à l'opérateur **%@**."; @@ -5920,10 +6286,22 @@ server test failure */ "The sender will NOT be notified" = "L'expéditeur N'en sera PAS informé"; /* No comment provided by engineer. */ -"The servers for new connections of your current chat profile **%@**." = "Les serveurs pour les nouvelles connexions de votre profil de chat actuel **%@**."; +"The servers for new connections of your current chat profile **%@**." = "Les serveurs pour les nouvelles connexions de votre profil de messagerie actuel **%@**."; /* No comment provided by engineer. */ -"The servers for new files of your current chat profile **%@**." = "Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel **%@**."; +"The servers for new files of your current chat profile **%@**." = "Les serveurs pour les nouveaux fichiers de votre profil de messagerie actuel **%@**."; + +/* alert message */ +"The SimpleX name @%@ is registered without SimpleX address. Add your SimpleX address to the name via the registration page." = "Le nom SimpleX @%@ est enregistré sans adresse SimpleX. Ajoutez votre adresse à ce nom via la page d'enregistrement."; + +/* alert message */ +"The SimpleX name #%@ is registered without channel link. Add channel link to the name via the registration page." = "Le nom SimpleX #%@ est enregistré sans lien de canal. Ajoutez un lien de canal via la page d'enregistrement."; + +/* No comment provided by engineer. */ +"The SimpleX name %@ is registered, but it has no valid link." = "Le nom SimpleX %@ est enregistré, mais ne possède aucun lien valide."; + +/* No comment provided by engineer. */ +"The SimpleX name %@ is registered, but not added to profile. Please add it to your address or channel profile, if you are the owner." = "Le nom SimpleX %@ est enregistré, mais n'a pas été ajouté au profil. Ajoutez-le à votre adresse ou au profil du canal si vous en êtes le propriétaire."; /* No comment provided by engineer. */ "The text you pasted is not a SimpleX link." = "Le texte collé n'est pas un lien SimpleX."; @@ -5937,6 +6315,9 @@ server test failure */ /* No comment provided by engineer. */ "Then we moved online, and every platform asked for a piece of you - your name, your number, your friends. We accepted that the price of talking to others is letting someone know who we talk to. Every generation, people and tech, had it this way - telephone, email, messengers, social media. It seemed the only way possible." = "Puis nous avons déménagé en ligne, et chaque plateforme a demandé un morceau de vous - votre nom, votre numéro, vos amis. Nous avons accepté que le prix à payer pour parler aux autres est de faire savoir à quelqu'un à qui nous parlons. À chaque génération, les gens et la technique le faisaient de cette manière : téléphone, courriels, messagers, médias sociaux. C'était le seul moyen possible."; +/* No comment provided by engineer. */ +"There is another way. A network with no phone numbers. No usernames. No accounts. No user identities of any kind. A network that connects people and carries encrypted messages without knowing who is connected." = "Il existe une autre voie. Un réseau sans numéro de téléphone. Sans nom d'utilisateur. Sans compte. Sans aucune identité utilisateur. Un réseau qui relie les personnes et transporte des messages chiffrés sans savoir qui est connecté."; + /* No comment provided by engineer. */ "These conditions will also apply for: **%@**." = "Ces conditions s'appliquent également aux : **%@**."; @@ -5952,14 +6333,20 @@ server test failure */ /* No comment provided by engineer. */ "This action cannot be undone - the messages sent and received earlier than selected will be deleted. It may take several minutes." = "Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes."; +/* alert message */ +"This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted." = "Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés."; + /* No comment provided by engineer. */ "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost." = "Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus."; -/* E2EE info chat item */ -"This chat is protected by end-to-end encryption." = "Cette discussion est protégée par un chiffrement de bout en bout."; +/* badge alert */ +"This badge could not be verified and may not be genuine." = "Ce badge n'a pas pu être vérifié et pourrait ne pas être authentique."; /* E2EE info chat item */ -"This chat is protected by quantum resistant end-to-end encryption." = "Cette discussion est protégée par un chiffrement de bout en bout résistant aux technologies quantiques."; +"This chat is protected by end-to-end encryption." = "Cette conversation est protégée par un chiffrement de bout en bout."; + +/* E2EE info chat item */ +"This chat is protected by quantum resistant end-to-end encryption." = "Cette conversation est protégée par un chiffrement de bout en bout résistant aux attaques quantiques."; /* notification title */ "this contact" = "ce contact"; @@ -5976,11 +6363,33 @@ server test failure */ /* No comment provided by engineer. */ "This group no longer exists." = "Ce groupe n'existe plus."; +/* alert message +alert subtitle */ +"This group requires a newer version of the app. Please update the app to join." = "Ce groupe nécessite une version plus récente. Mettez l'application à jour pour le rejoindre."; + +/* alert message */ +"This is a chat relay address, it cannot be used to connect." = "C'est une adresse de relais de messagerie, elle ne permet pas de se connecter."; + +/* alert message */ +"This is the last active relay. Removing it will prevent message delivery to subscribers." = "Il s'agit du dernier relais actif. Le supprimer empêchera la distribution des messages aux abonnés."; + +/* new chat action */ +"This is your link for channel %@!" = "Voici votre lien pour le canal %@ !"; + +/* No comment provided by engineer. */ +"This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." = "Ce lien nécessite une version plus récente. Mettez l'application à jour ou demandez à votre contact d'envoyer un lien compatible."; + /* No comment provided by engineer. */ "This link was used with another mobile device, please create a new link on the desktop." = "Ce lien a été utilisé avec un autre appareil mobile, veuillez créer un nouveau lien sur le bureau."; /* No comment provided by engineer. */ -"This setting applies to messages in your current chat profile **%@**." = "Ce paramètre s'applique aux messages de votre profil de chat actuel **%@**."; +"This message was deleted or not received yet." = "Ce message a été supprimé ou n'a pas encore été reçu."; + +/* No comment provided by engineer. */ +"This setting applies to messages in your current chat profile **%@**." = "Ce paramètre s'applique aux messages de votre profil de messagerie actuel **%@**."; + +/* No comment provided by engineer. */ +"This SimpleX name is not registered. Please check the name." = "Ce nom SimpleX n'est pas enregistré. Vérifiez-le."; /* No comment provided by engineer. */ "Time to disappear is set only for new contacts." = "Le délai de disparition est défini seulement pour les nouveaux contacts."; @@ -6000,6 +6409,9 @@ server test failure */ /* No comment provided by engineer. */ "To make a new connection" = "Pour établir une nouvelle connexion"; +/* No comment provided by engineer. */ +"To make SimpleX Network last." = "Pour que le réseau SimpleX perdure."; + /* No comment provided by engineer. */ "To protect against your link being replaced, you can compare contact security codes." = "Pour vous protéger contre le remplacement de votre lien, vous pouvez comparer les codes de sécurité des contacts."; @@ -6028,13 +6440,22 @@ server test failure */ "To record voice message please grant permission to use Microphone." = "Pour enregistrer un message vocal, veuillez accorder la permission d'utiliser le microphone."; /* No comment provided by engineer. */ -"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de discussion**."; +"To resolve names" = "Pour résoudre les noms"; + +/* No comment provided by engineer. */ +"To reveal your hidden profile, enter a full password into a search field in **Your chat profiles** page." = "Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page **Vos profils de messagerie**."; /* No comment provided by engineer. */ "To send" = "Pour envoyer"; +/* alert message */ +"To send commands you must be connected." = "Vous devez être connecté pour envoyer des commandes."; + /* No comment provided by engineer. */ -"To support instant push notifications the chat database has to be migrated." = "Pour prendre en charge les notifications push instantanées, la base de données du chat doit être migrée."; +"To support instant push notifications the chat database has to be migrated." = "Pour prendre en charge les notifications push instantanées, la base de données de messagerie doit être migrée."; + +/* alert message */ +"To use another profile after connection attempt, delete the chat and use the link again." = "Pour utiliser un autre profil après une tentative de connexion, supprimez la conversation et utilisez à nouveau le lien."; /* No comment provided by engineer. */ "To use the servers of **%@**, accept conditions of use." = "Pour utiliser les serveurs de **%@**, acceptez les conditions d'utilisation."; @@ -6042,9 +6463,15 @@ server test failure */ /* No comment provided by engineer. */ "To verify end-to-end encryption with your contact compare (or scan) the code on your devices." = "Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils."; +/* No comment provided by engineer. */ +"To verify keys with this subscriber, compare (or scan) the code on your devices." = "Pour vérifier les clés avec cet abonné, comparez (ou scannez) le code sur vos appareils."; + /* No comment provided by engineer. */ "Toggle incognito when connecting." = "Basculer en mode incognito lors de la connexion."; +/* token status */ +"Token status: %@." = "Statut du jeton : %@."; + /* No comment provided by engineer. */ "Toolbar opacity" = "Opacité de la barre d'outils"; @@ -6060,6 +6487,9 @@ server test failure */ /* No comment provided by engineer. */ "Transport sessions" = "Sessions de transport"; +/* subscription status explanation */ +"Trying to connect to the server used to receive messages from this connection." = "Tentative de connexion au serveur utilisé pour recevoir les messages de cette connexion."; + /* No comment provided by engineer. */ "Turkish interface" = "Interface en turc"; @@ -6087,9 +6517,15 @@ server test failure */ /* No comment provided by engineer. */ "Unblock member?" = "Débloquer ce membre ?"; +/* No comment provided by engineer. */ +"Unblock subscriber for all?" = "Débloquer l'abonné pour tout le monde ?"; + /* rcv group event chat item */ "unblocked %@" = "%@ débloqué"; +/* No comment provided by engineer. */ +"Unconfirmed name" = "Nom non confirmé"; + /* No comment provided by engineer. */ "Undelivered messages" = "Messages non distribués"; @@ -6103,7 +6539,7 @@ server test failure */ "Unhide" = "Dévoiler"; /* No comment provided by engineer. */ -"Unhide chat profile" = "Dévoiler le profil de chat"; +"Unhide chat profile" = "Afficher le profil de messagerie"; /* No comment provided by engineer. */ "Unhide profile" = "Dévoiler le profil"; @@ -6139,7 +6575,7 @@ server test failure */ "Unlink" = "Délier"; /* No comment provided by engineer. */ -"Unlink desktop?" = "Délier le bureau ?"; +"Unlink desktop?" = "Dissocier le PC ?"; /* No comment provided by engineer. */ "Unlock" = "Déverrouiller"; @@ -6156,9 +6592,18 @@ server test failure */ /* swipe action */ "Unread" = "Non lu"; +/* conn error description */ +"Unsupported connection link" = "Lien de connexion non pris en charge"; + +/* badge alert title */ +"Unverified badge" = "Badge non vérifié"; + /* No comment provided by engineer. */ "Up to 100 last messages are sent to new members." = "Les 100 derniers messages sont envoyés aux nouveaux membres."; +/* No comment provided by engineer. */ +"Up to 100 last messages are sent to new subscribers." = "Jusqu'à 100 messages récents sont envoyés aux nouveaux abonnés."; + /* No comment provided by engineer. */ "Update" = "Mise à jour"; @@ -6174,6 +6619,9 @@ server test failure */ /* rcv group event chat item */ "updated channel profile" = "profil du canal mis à jour"; +/* No comment provided by engineer. */ +"Updated conditions" = "Conditions mises à jour"; + /* rcv group event chat item */ "updated group profile" = "mise à jour du profil de groupe"; @@ -6183,8 +6631,27 @@ server test failure */ /* No comment provided by engineer. */ "Updating settings will re-connect the client to all servers." = "La mise à jour des ces paramètres reconnectera le client à tous les serveurs."; +/* alert button */ +"Upgrade" = "Mettre à jour"; + /* No comment provided by engineer. */ -"Upgrade and open chat" = "Mettre à niveau et ouvrir le chat"; +"Upgrade address" = "Mettre à jour l'adresse"; + +/* alert message +alert title */ +"Upgrade address?" = "Mettre à jour l'adresse ?"; + +/* No comment provided by engineer. */ +"Upgrade and open chat" = "Mettre à jour et ouvrir la messagerie"; + +/* alert message */ +"Upgrade group link?" = "Mettre à jour le lien du groupe ?"; + +/* No comment provided by engineer. */ +"Upgrade link" = "Lien de mise à jour"; + +/* No comment provided by engineer. */ +"Upgrade your address" = "Mettre à jour votre adresse"; /* No comment provided by engineer. */ "Upload errors" = "Erreurs de téléversement"; @@ -6219,11 +6686,17 @@ server test failure */ /* No comment provided by engineer. */ "Use for messages" = "Utiliser pour les messages"; +/* No comment provided by engineer. */ +"Use for new channels" = "Utiliser pour les nouveaux canaux"; + /* No comment provided by engineer. */ "Use for new connections" = "Utiliser pour les nouvelles connexions"; /* No comment provided by engineer. */ -"Use from desktop" = "Accès au bureau"; +"Use from desktop" = "Utiliser depuis un ordinateur"; + +/* No comment provided by engineer. */ +"Use incognito profile" = "Utiliser le profil incognito"; /* No comment provided by engineer. */ "Use iOS call interface" = "Utiliser l'interface d'appel d'iOS"; @@ -6240,6 +6713,9 @@ server test failure */ /* No comment provided by engineer. */ "Use private routing with unknown servers." = "Utiliser le routage privé avec des serveurs inconnus."; +/* No comment provided by engineer. */ +"Use relay" = "Utiliser un relais"; + /* No comment provided by engineer. */ "Use server" = "Utiliser ce serveur"; @@ -6252,6 +6728,12 @@ server test failure */ /* No comment provided by engineer. */ "Use SOCKS proxy" = "Utiliser un proxy SOCKS"; +/* No comment provided by engineer. */ +"Use TCP port %@ when no port is specified." = "Utiliser le port TCP %@ lorsqu'aucun port n'est spécifié."; + +/* No comment provided by engineer. */ +"Use TCP port 443 for preset servers only." = "Utiliser le port TCP 443 uniquement pour les serveurs prédéfinis."; + /* No comment provided by engineer. */ "Use the app while in the call." = "Utiliser l'application pendant l'appel."; @@ -6261,6 +6743,12 @@ server test failure */ /* No comment provided by engineer. */ "Use this address in your social media profile, website, or email signature." = "Utilisez cette adresse dans votre profil, votre site Web ou votre signature de courriel."; +/* No comment provided by engineer. */ +"Use web port" = "Utiliser le port Web"; + +/* No comment provided by engineer. */ +"Used chat relays do not support webpages." = "Les relais de messagerie utilisés ne prennent pas en charge les pages Web."; + /* No comment provided by engineer. */ "User selection" = "Sélection de l'utilisateur"; @@ -6277,7 +6765,7 @@ server test failure */ "Verify" = "Vérifier"; /* No comment provided by engineer. */ -"Verify code with desktop" = "Vérifier le code avec le bureau"; +"Verify code with desktop" = "Vérifier le code avec le PC"; /* No comment provided by engineer. */ "Verify connection" = "Vérifier la connexion"; @@ -6291,12 +6779,18 @@ server test failure */ /* No comment provided by engineer. */ "Verify database passphrase" = "Vérifier la phrase secrète de la base de données"; +/* No comment provided by engineer. */ +"Verify name" = "Vérifier le nom"; + /* No comment provided by engineer. */ "Verify passphrase" = "Vérifier la phrase secrète"; /* No comment provided by engineer. */ "Verify security code" = "Vérifier le code de sécurité"; +/* No comment provided by engineer. */ +"Verify SimpleX names" = "Vérifier les noms SimpleX"; + /* relay hostname */ "via %@" = "via %@"; @@ -6358,7 +6852,7 @@ server test failure */ "Voice messages" = "Messages vocaux"; /* No comment provided by engineer. */ -"Voice messages are prohibited in this chat." = "Les messages vocaux sont interdits dans ce chat."; +"Voice messages are prohibited in this chat." = "Les messages vocaux sont interdits dans cette conversation."; /* No comment provided by engineer. */ "Voice messages are prohibited." = "Les messages vocaux sont interdits dans ce groupe."; @@ -6385,7 +6879,7 @@ server test failure */ "waiting for confirmation…" = "en attente de confirmation…"; /* No comment provided by engineer. */ -"Waiting for desktop..." = "En attente du bureau..."; +"Waiting for desktop..." = "En attente du PC..."; /* No comment provided by engineer. */ "Waiting for file" = "En attente du fichier"; @@ -6406,11 +6900,20 @@ server test failure */ "wants to connect to you!" = "veut établir une connexion !"; /* No comment provided by engineer. */ -"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Attention : démarrer une session de discussion sur plusieurs appareils n'est pas pris en charge et entraînera des dysfonctionnements au niveau de la transmission des messages"; +"Warning: starting chat on multiple devices is not supported and will cause message delivery failures" = "Avertissement : démarrer la messagerie sur plusieurs appareils n’est pas pris en charge et entraînera des échecs de distribution des messages"; /* No comment provided by engineer. */ "Warning: you may lose some data!" = "Attention : vous risquez de perdre des données !"; +/* No comment provided by engineer. */ +"We made connecting simpler for new users." = "Nous avons simplifié la connexion pour les nouveaux utilisateurs."; + +/* No comment provided by engineer. */ +"Webpage code" = "Code de la page Web"; + +/* alert message */ +"Webpage settings were changed. If you save, the updated settings will be sent to subscribers." = "Les paramètres de la page Web ont été modifiés. Si vous enregistrez, ils seront envoyés aux abonnés."; + /* No comment provided by engineer. */ "WebRTC ICE servers" = "Serveurs WebRTC ICE"; @@ -6447,11 +6950,14 @@ server test failure */ /* No comment provided by engineer. */ "When you share an incognito profile with somebody, this profile will be used for the groups they invite you to." = "Lorsque vous partagez un profil incognito avec quelqu'un, ce profil sera utilisé pour les groupes auxquels il vous invite."; +/* No comment provided by engineer. */ +"Why SimpleX is built." = "Pourquoi SimpleX a été créé."; + /* No comment provided by engineer. */ "WiFi" = "WiFi"; /* No comment provided by engineer. */ -"Will be enabled in direct chats!" = "Activé dans les discussions directes !"; +"Will be enabled in direct chats!" = "Activé dans les conversations directes !"; /* No comment provided by engineer. */ "Wired ethernet" = "Ethernet câblé"; @@ -6505,7 +7011,7 @@ server test failure */ "You allow" = "Vous autorisez"; /* No comment provided by engineer. */ -"You already have a chat profile with the same display name. Please choose another name." = "Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom."; +"You already have a chat profile with the same display name. Please choose another name." = "Vous avez déjà un profil de messagerie avec le même nom d’affichage. Veuillez choisir un autre nom."; /* No comment provided by engineer. */ "You are already connected to %@." = "Vous êtes déjà connecté·e à %@ via ce lien."; @@ -6531,15 +7037,42 @@ server test failure */ /* new chat sheet title */ "You are already joining the group!\nRepeat join request?" = "Vous êtes déjà membre de ce groupe !\nRépéter la demande d'adhésion ?"; +/* subscription status explanation */ +"You are connected to the server used to receive messages from this connection." = "Vous êtes connecté au serveur utilisé pour recevoir les messages de cette connexion."; + /* No comment provided by engineer. */ "You are invited to group" = "Vous êtes invité·e au groupe"; +/* subscription status explanation */ +"You are not connected to the server used to receive messages from this connection (no subscription)." = "Vous n'êtes pas connecté au serveur utilisé pour recevoir les messages de cette connexion (aucun abonnement)."; + /* No comment provided by engineer. */ "You are not connected to these servers. Private routing is used to deliver messages to them." = "Vous n'êtes pas connecté à ces serveurs. Le routage privé est utilisé pour leur délivrer des messages."; /* No comment provided by engineer. */ "you are observer" = "vous êtes observateur"; +/* new chat alert */ +"You are an observer" = "Vous êtes observateur"; + +/* new chat alert */ +"You are a member" = "Vous êtes membre"; + +/* new chat alert */ +"You are a moderator" = "Vous êtes modérateur"; + +/* new chat alert */ +"You are an admin" = "Vous êtes admin"; + +/* new chat alert */ +"You are an owner" = "Vous êtes propriétaire"; + +/* new chat alert */ +"You are a subscriber" = "Vous êtes abonné·e"; + +/* new chat alert */ +"You are a contributor" = "Vous êtes contributeur"; + /* No comment provided by engineer. */ "you are subscriber" = "vous êtes abonné·e"; @@ -6561,6 +7094,9 @@ server test failure */ /* No comment provided by engineer. */ "You can enable later via Settings" = "Vous pouvez l'activer ultérieurement via Paramètres"; +/* No comment provided by engineer. */ +"You can enable them later via app Your privacy settings." = "Vous pourrez les activer plus tard dans les paramètres « Votre vie privée »."; + /* No comment provided by engineer. */ "You can give another try." = "Vous pouvez faire un nouvel essai."; @@ -6582,6 +7118,9 @@ server test failure */ /* No comment provided by engineer. */ "You can set lock screen notification preview via settings." = "Vous pouvez configurer l'aperçu des notifications sur l'écran de verrouillage via les paramètres."; +/* No comment provided by engineer. */ +"You can share a link or a QR code - anybody will be able to join the channel." = "Vous pouvez partager un lien ou un QR code : n'importe qui pourra rejoindre le canal."; + /* No comment provided by engineer. */ "You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it." = "Vous pouvez partager un lien ou un code QR - n'importe qui pourra rejoindre le groupe. Vous ne perdrez pas les membres du groupe si vous le supprimez par la suite."; @@ -6589,10 +7128,13 @@ server test failure */ "You can share this address with your contacts to let them connect with **%@**." = "Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec **%@**."; /* No comment provided by engineer. */ -"You can start chat via app Settings / Database or by restarting the app" = "Vous pouvez lancer le chat via Paramètres / Base de données ou en redémarrant l'app"; +"You can start chat via app Settings / Database or by restarting the app" = "Vous pouvez lancer la messagerie via Paramètres / Base de données ou en redémarrant l'app"; /* No comment provided by engineer. */ -"You can still view conversation with %@ in the list of chats." = "Vous pouvez toujours voir la conversation avec %@ dans la liste des discussions."; +"You can still view conversation with %@ in the list of chats." = "Vous pouvez toujours voir la conversation avec %@ dans la liste des conversations."; + +/* badge alert */ +"You can support SimpleX starting from v7 of the app." = "Vous pouvez soutenir SimpleX à partir de la version 7 de l'application."; /* No comment provided by engineer. */ "You can turn on SimpleX Lock via Settings." = "Vous pouvez activer SimpleX Lock dans les Paramètres."; @@ -6603,6 +7145,9 @@ server test failure */ /* alert message */ "You can view invitation link again in connection details." = "Vous pouvez à nouveau consulter le lien d'invitation dans les détails de la connexion."; +/* alert message */ +"You can view your reports in Chat with admins." = "Vous pouvez consulter vos rapports dans la section Discuter avec les admins."; + /* alert title */ "You can't send messages!" = "Vous ne pouvez pas envoyer de messages !"; @@ -6618,6 +7163,12 @@ server test failure */ /* snd group event chat item */ "you changed role of %@ to %@" = "vous avez modifié le rôle de %1$@ pour %2$@"; +/* No comment provided by engineer. */ +"You commit to:\n- Only legal content in public groups\n- Respect other users - no spam" = "Vous vous engagez à :\n- Ne publier que du contenu légal dans les groupes publics\n- Respecter les autres utilisateurs ; pas de spam"; + +/* No comment provided by engineer. */ +"You connected to the channel via this relay link." = "Vous vous êtes connecté au canal via ce lien du relais."; + /* No comment provided by engineer. */ "You could not be verified; please try again." = "Vous n'avez pas pu être vérifié·e ; veuillez réessayer."; @@ -6646,7 +7197,7 @@ server test failure */ "You may save the exported archive." = "Vous pouvez enregistrer l'archive exportée."; /* No comment provided by engineer. */ -"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts."; +"You must use the most recent version of your chat database on one device ONLY, otherwise you may stop receiving the messages from some contacts." = "Vous devez utiliser la version la plus récente de votre base de données de messagerie sur UN SEUL appareil, sinon vous risquez de ne plus recevoir les messages de certains contacts."; /* No comment provided by engineer. */ "You need to allow your contact to call to be able to call them." = "Vous devez autoriser votre contact à appeler pour pouvoir l'appeler."; @@ -6669,9 +7220,18 @@ server test failure */ /* chat list item description */ "you shared one-time link incognito" = "vous avez partagé un lien unique en incognito"; +/* token info */ +"You should receive notifications." = "Vous devriez recevoir des notifications."; + /* snd group event chat item */ "you unblocked %@" = "vous avez débloqué %@"; +/* No comment provided by engineer. */ +"You were born without an account" = "Vous êtes né sans compte"; + +/* No comment provided by engineer. */ +"You will be able to send messages **only after your request is accepted**." = "Vous pourrez envoyer des messages **uniquement après l'acceptation de votre demande**."; + /* No comment provided by engineer. */ "You will be connected to group when the group host's device is online, please wait or check later!" = "Vous serez connecté·e au groupe lorsque l'appareil de l'hôte sera en ligne, veuillez attendre ou vérifier plus tard !"; @@ -6691,10 +7251,13 @@ server test failure */ "You will still receive calls and notifications from muted profiles when they are active." = "Vous continuerez à recevoir des appels et des notifications des profils mis en sourdine lorsqu'ils sont actifs."; /* No comment provided by engineer. */ -"You will stop receiving messages from this chat. Chat history will be preserved." = "Vous ne recevrez plus de messages de cette discussion. L'historique sera préservé."; +"You will stop receiving messages from this channel. Chat history will be preserved." = "Vous ne recevrez plus de messages provenant de ce canal. L'historique des conversations sera conservé."; /* No comment provided by engineer. */ -"You will stop receiving messages from this group. Chat history will be preserved." = "Vous ne recevrez plus de messages de ce groupe. L'historique du chat sera conservé."; +"You will stop receiving messages from this chat. Chat history will be preserved." = "Vous ne recevrez plus de messages de cette conversation. L'historique sera préservé."; + +/* No comment provided by engineer. */ +"You will stop receiving messages from this group. Chat history will be preserved." = "Vous ne recevrez plus de messages de ce groupe. L'historique de la conversation sera conservé."; /* No comment provided by engineer. */ "You won't lose your contacts if you later delete your address." = "Vous ne perdrez pas vos contacts si vous la supprimez par la suite."; @@ -6715,13 +7278,19 @@ server test failure */ "Your calls" = "Vos appels"; /* No comment provided by engineer. */ -"Your chat database is not encrypted - set passphrase to encrypt it." = "Votre base de données de chat n'est pas chiffrée - définisez une phrase secrète."; - -/* alert title */ -"Your chat preferences" = "Vos préférences de discussion"; +"Your channel" = "Votre canal"; /* No comment provided by engineer. */ -"Your chat profiles" = "Vos profils de discussion"; +"Your chat database is not encrypted - set passphrase to encrypt it." = "Votre base de données de la messagerie n'est pas chiffrée : définissez une phrase secrète."; + +/* alert title */ +"Your chat preferences" = "Vos préférences de conversation"; + +/* No comment provided by engineer. */ +"Your chat profiles" = "Vos profils de messagerie"; + +/* alert message */ +"Your chat was moved to %@ but an unexpected error occurred while redirecting you to the profile." = "Votre conversation a été déplacée vers %@, mais une erreur inattendue s'est produite lors de la redirection vers le profil."; /* No comment provided by engineer. */ "Your connection was moved to %@ but an error happened when switching profile." = "Votre connexion a été déplacée vers %@ mais une erreur inattendue s'est produite lors de la redirection vers le profil."; @@ -6741,11 +7310,14 @@ server test failure */ /* No comment provided by engineer. */ "Your contacts will remain connected." = "Vos contacts resteront connectés."; +/* No comment provided by engineer. */ +"Your conversations belong to you, as it had always been before the Internet. The network is not a place you visit. It is a place you create and own. And nobody can take it from you, whether you make it private or public." = "Vos conversations vous appartiennent, comme avant Internet. Le réseau n'est pas un lieu que vous visitez, mais un lieu que vous créez et possédez. Et personne ne peut vous le retirer, que vous le rendiez privé ou public."; + /* No comment provided by engineer. */ "Your credentials may be sent unencrypted." = "Vos informations d'identification peuvent être envoyées non chiffrées."; /* No comment provided by engineer. */ -"Your current chat database will be DELETED and REPLACED with the imported one." = "Votre base de données de chat actuelle va être SUPPRIMEE et REMPLACEE par celle importée."; +"Your current chat database will be DELETED and REPLACED with the imported one." = "Votre base de données de messagerie actuelle va être SUPPRIMÉE et REMPLACÉE par celle importée."; /* No comment provided by engineer. */ "Your current profile" = "Votre profil actuel"; @@ -6759,6 +7331,9 @@ server test failure */ /* No comment provided by engineer. */ "Your network" = "Votre réseau"; +/* alert message */ +"Your new channel %@ is connected to %d of %d relays.\nIf you cancel, the channel will be deleted - you can create it again." = "Votre nouveau canal %1$@ est connecté à %2$d relais sur %3$d.\nSi vous annulez, le canal sera supprimé : vous pourrez le recréer."; + /* No comment provided by engineer. */ "Your preferences" = "Vos préférences"; @@ -6768,6 +7343,9 @@ server test failure */ /* No comment provided by engineer. */ "Your profile" = "Votre profil"; +/* No comment provided by engineer. */ +"Your profile **%@** will be shared with channel relays and subscribers.\nRelays can access channel messages." = "Votre profil **%@** sera partagé avec les relais et les abonnés du canal.\nLes relais peuvent accéder aux messages du canal."; + /* No comment provided by engineer. */ "Your profile **%@** will be shared." = "Votre profil **%@** sera partagé."; @@ -6804,3 +7382,6 @@ server test failure */ /* No comment provided by engineer. */ "Your SimpleX address" = "Votre adresse SimpleX"; +/* No comment provided by engineer. */ +"Your SimpleX name" = "Votre nom SimpleX"; + diff --git a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings index e5bfdf09df..70c1b7c11d 100644 --- a/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings +++ b/apps/ios/fr.lproj/SimpleX--iOS--InfoPlist.strings @@ -8,7 +8,7 @@ "NSFaceIDUsageDescription" = "SimpleGroup not found!X utilise Face ID pour l'authentification locale"; /* Privacy - Local Network Usage Description */ -"NSLocalNetworkUsageDescription" = "SimpleX utilise un accès au réseau local pour permettre l'utilisation du profil de chat de l'utilisateur via l'application de bureau au sein de ce même réseau."; +"NSLocalNetworkUsageDescription" = "SimpleX utilise l’accès au réseau local pour permettre l’utilisation du profil de messagerie sur l’app de bureau du même réseau."; /* Privacy - Microphone Usage Description */ "NSMicrophoneUsageDescription" = "SimpleX a besoin d'un accès au microphone pour les appels audio et vidéo ainsi que pour enregistrer des messages vocaux."; diff --git a/apps/ios/hu.lproj/Localizable.strings b/apps/ios/hu.lproj/Localizable.strings index 330688163e..004111f914 100644 --- a/apps/ios/hu.lproj/Localizable.strings +++ b/apps/ios/hu.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Partnerbeállítások"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Partneri kapcsolatkérések a csoportokból"; +"Contact requests in groups" = "Partneri kapcsolatkérések a csoportokból"; /* No comment provided by engineer. */ "contact should accept…" = "a partnernek el kell fogadnia…"; @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Mentve innen"; /* No comment provided by engineer. */ -"saved from %@" = "mentve innen: %@"; +"saved from" = "mentve innen:"; /* message info title */ "Saved message" = "Mentett üzenet"; @@ -6388,9 +6388,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Ez a beállítás csak az Ön jelenlegi **%@** nevű csevegési profiljában lévő üzenetekre vonatkozik."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Ez a beállítás csak a jelenlegi **%@** nevű csevegési profiljára vonatkozik."; - /* No comment provided by engineer. */ "This SimpleX name is not registered. Please check the name." = "Ez a SimpleX-név nincs regisztrálva. Ellenőrizze a nevet."; @@ -7055,6 +7052,27 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "Ön megfigyelő"; +/* new chat alert */ +"You are an observer" = "Ön megfigyelő"; + +/* new chat alert */ +"You are a member" = "Ön tag"; + +/* new chat alert */ +"You are a moderator" = "Ön moderátor"; + +/* new chat alert */ +"You are an admin" = "Ön adminisztrátor"; + +/* new chat alert */ +"You are an owner" = "Ön tulajdonos"; + +/* new chat alert */ +"You are a subscriber" = "Ön feliratkozó"; + +/* new chat alert */ +"You are a contributor" = "Ön közreműködő"; + /* No comment provided by engineer. */ "you are subscriber" = "Ön feliratkozó"; diff --git a/apps/ios/it.lproj/Localizable.strings b/apps/ios/it.lproj/Localizable.strings index 3544dc1813..dd364b27ce 100644 --- a/apps/ios/it.lproj/Localizable.strings +++ b/apps/ios/it.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Preferenze del contatto"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Richieste di contatto dai gruppi"; +"Contact requests in groups" = "Richieste di contatto dai gruppi"; /* No comment provided by engineer. */ "contact should accept…" = "il contatto deve accettare…"; @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Salvato da"; /* No comment provided by engineer. */ -"saved from %@" = "salvato da %@"; +"saved from" = "salvato da"; /* message info title */ "Saved message" = "Messaggio salvato"; @@ -6388,9 +6388,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Questa impostazione si applica ai messaggi del profilo di chat attuale **%@**."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Questa impostazione è per il tuo profilo attuale **%@**."; - /* No comment provided by engineer. */ "This SimpleX name is not registered. Please check the name." = "Questo nome SimpleX non è registrato. Controlla il nome."; @@ -7055,6 +7052,27 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "sei un osservatore"; +/* new chat alert */ +"You are an observer" = "Sei un osservatore"; + +/* new chat alert */ +"You are a member" = "Sei un membro"; + +/* new chat alert */ +"You are a moderator" = "Sei un moderatore"; + +/* new chat alert */ +"You are an admin" = "Sei un amministratore"; + +/* new chat alert */ +"You are an owner" = "Sei un proprietario"; + +/* new chat alert */ +"You are a subscriber" = "Sei iscritto/a"; + +/* new chat alert */ +"You are a contributor" = "Sei un collaboratore"; + /* No comment provided by engineer. */ "you are subscriber" = "sei iscritto/a"; diff --git a/apps/ios/ja.lproj/Localizable.strings b/apps/ios/ja.lproj/Localizable.strings index 60ef7e2d36..3eb5fa5d8b 100644 --- a/apps/ios/ja.lproj/Localizable.strings +++ b/apps/ios/ja.lproj/Localizable.strings @@ -3484,6 +3484,18 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "あなたはオブザーバーです"; +/* new chat alert */ +"You are an observer" = "あなたはオブザーバーです"; + +/* new chat alert */ +"You are a member" = "あなたはメンバーです"; + +/* new chat alert */ +"You are an admin" = "あなたは管理者です"; + +/* new chat alert */ +"You are an owner" = "あなたはオーナーです"; + /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "デバイスやアプリの認証を行わずに、ロック画面から通話を受けることができます。"; diff --git a/apps/ios/nl.lproj/Localizable.strings b/apps/ios/nl.lproj/Localizable.strings index 6715a09b80..7164633090 100644 --- a/apps/ios/nl.lproj/Localizable.strings +++ b/apps/ios/nl.lproj/Localizable.strings @@ -4411,7 +4411,7 @@ chat item action */ "Saved from" = "Opgeslagen van"; /* No comment provided by engineer. */ -"saved from %@" = "opgeslagen van %@"; +"saved from" = "opgeslagen van"; /* message info title */ "Saved message" = "Opgeslagen bericht"; @@ -5739,6 +5739,21 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "je bent waarnemer"; +/* new chat alert */ +"You are an observer" = "Je bent waarnemer"; + +/* new chat alert */ +"You are a member" = "Je bent lid"; + +/* new chat alert */ +"You are a moderator" = "Je bent moderator"; + +/* new chat alert */ +"You are an admin" = "Je bent beheerder"; + +/* new chat alert */ +"You are an owner" = "Je bent eigenaar"; + /* snd group event chat item */ "you blocked %@" = "je hebt %@ geblokkeerd"; diff --git a/apps/ios/pl.lproj/Localizable.strings b/apps/ios/pl.lproj/Localizable.strings index 07c407416a..eb5074dc00 100644 --- a/apps/ios/pl.lproj/Localizable.strings +++ b/apps/ios/pl.lproj/Localizable.strings @@ -1416,7 +1416,7 @@ server test step */ "Contact preferences" = "Preferencje kontaktu"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Prośby o kontakt od grup"; +"Contact requests in groups" = "Prośby o kontakt od grup"; /* No comment provided by engineer. */ "contact should accept…" = "kontakt powinien zaakceptować…"; @@ -4646,7 +4646,7 @@ chat item action */ "Saved from" = "Zapisane od"; /* No comment provided by engineer. */ -"saved from %@" = "zapisane od %@"; +"saved from" = "zapisane od"; /* message info title */ "Saved message" = "Zachowano wiadomość"; @@ -5506,9 +5506,6 @@ server test failure */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "To ustawienie dotyczy wiadomości Twojego bieżącego profilu czatu **%@**."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "To ustawienie jest dla Twojego obecnego profilu **%@**."; - /* No comment provided by engineer. */ "Time to disappear is set only for new contacts." = "Czas zniknięcia jest ustawiony tylko dla nowych kontaktów."; @@ -6098,6 +6095,21 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "jesteś obserwatorem"; +/* new chat alert */ +"You are an observer" = "Jesteś obserwatorem"; + +/* new chat alert */ +"You are a member" = "Jesteś członkiem"; + +/* new chat alert */ +"You are a moderator" = "Jesteś moderatorem"; + +/* new chat alert */ +"You are an admin" = "Jesteś administratorem"; + +/* new chat alert */ +"You are an owner" = "Jesteś właścicielem"; + /* snd group event chat item */ "you blocked %@" = "zablokowałeś %@"; diff --git a/apps/ios/product/flows/connection.md b/apps/ios/product/flows/connection.md index 115e420f7c..7073e07070 100644 --- a/apps/ios/product/flows/connection.md +++ b/apps/ios/product/flows/connection.md @@ -113,7 +113,7 @@ Establishing contact between two SimpleX Chat users. SimpleX uses no user identi 1. When connecting to a channel link (`GroupShortLinkInfo.direct == false`): 2. `apiPrepareGroup(connLink:directLink:groupShortLinkData:)` is called with `directLink: false`, preparing the channel locally. 3. `groupShortLinkInfo.groupRelays` (hostnames) stored in `ChatModel.shared.channelRelayHostnames[groupId]`. -4. Pre-join UI shows channel icon and "Open new channel" (not "Open new group"). +4. Pre-join UI shows channel icon and "Open channel" (not "Open group"). 5. `apiConnectPreparedGroup(groupId:incognito:msg:)` returns `(GroupInfo, [RelayConnectionResult])`. 6. `RelayConnectionResult` contains `relayMember: GroupMember` and optional `relayError: ChatError?` per relay. 7. Relay members are upserted to `chatModel.groupMembers`; `channelRelayHostnames` entry is cleared. diff --git a/apps/ios/product/views/new-chat.md b/apps/ios/product/views/new-chat.md index 1ab84c098a..0d1e384325 100644 --- a/apps/ios/product/views/new-chat.md +++ b/apps/ios/product/views/new-chat.md @@ -118,10 +118,18 @@ When `planAndConnect` encounters a `.simplexLink(_, .relay, _, _)`, it shows a " | Context | Channel behavior | Group behavior | |---|---|---| | Prepare alert icon | `antenna.radiowaves.left.and.right.circle.fill` | `person.2.circle.fill` | -| Prepare alert title | "Open new channel" | "Open new group" | +| Prepare alert title | "Open channel" | "Open group" | | Error text | "Error opening channel" | "Error opening group" | | Own-link confirm | "This is your link for channel" with only "Open channel" + "Cancel" (no incognito/profile options) | Full incognito/profile selection | -| Known group alert | "Open channel" / "Open new channel" | "Open group" / "Open new group" | +| Known group alert | "Open channel", with the membership role line | "Open group", with the membership role line | + +The known group alert shows an information line with the user's role, in the +secondary color (matching the subscriber count): "You are a subscriber" / +"You are a contributor" for channels, "You are an observer" / "You are a +member" for groups, and "You are a moderator" / "You are an admin" / +"You are an owner" for both. The line is omitted for prepared chats +(`nextConnectPrepared`) and business chats, so the prepare and known alerts +differ only by this line. ### Pre-Join Relay Info diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings index ce7e446211..92bb8f4590 100644 --- a/apps/ios/ru.lproj/Localizable.strings +++ b/apps/ios/ru.lproj/Localizable.strings @@ -1692,7 +1692,7 @@ server test step */ "Contact preferences" = "Предпочтения контакта"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Запросы на соединение из групп"; +"Contact requests in groups" = "Запросы на соединение из групп"; /* No comment provided by engineer. */ "contact should accept…" = "контакт должен принять…"; @@ -5346,7 +5346,7 @@ chat item action */ "Saved from" = "Сохранено из"; /* No comment provided by engineer. */ -"saved from %@" = "сохранено из %@"; +"saved from" = "сохранено из"; /* message info title */ "Saved message" = "Сохранённое сообщение"; @@ -6388,9 +6388,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Эта настройка применяется к сообщениям в Вашем текущем профиле чата **%@**."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Эта настройка применяется к Вашему текущему профилю чата **%@**."; - /* No comment provided by engineer. */ "This SimpleX name is not registered. Please check the name." = "Это SimpleX имя не зарегистрировано. Пожалуйста, проверьте имя."; @@ -7058,6 +7055,27 @@ alert title */ /* No comment provided by engineer. */ "you are subscriber" = "Вы подписчик"; +/* new chat alert */ +"You are an observer" = "Вы читатель"; + +/* new chat alert */ +"You are a member" = "Вы член группы"; + +/* new chat alert */ +"You are a moderator" = "Вы модератор"; + +/* new chat alert */ +"You are an admin" = "Вы админ"; + +/* new chat alert */ +"You are an owner" = "Вы владелец"; + +/* new chat alert */ +"You are a subscriber" = "Вы подписчик"; + +/* new chat alert */ +"You are a contributor" = "Вы соавтор"; + /* snd group event chat item */ "you blocked %@" = "Вы заблокировали %@"; diff --git a/apps/ios/spec/client/navigation.md b/apps/ios/spec/client/navigation.md index 920780cc0f..64d0940e39 100644 --- a/apps/ios/spec/client/navigation.md +++ b/apps/ios/spec/client/navigation.md @@ -344,7 +344,7 @@ Similarly, in `planAndConnect()` (`NewChatView.swift`), `.simplexLink(_, .relay, When `groupShortLinkInfo?.direct == false` (channel relay link), the prepare alert uses: - Channel icon: `antenna.radiowaves.left.and.right.circle.fill` -- Title: "Open new channel" +- Title: "Open channel" - Error: "Error opening channel" - `apiPrepareGroup` call passes `directLink: false` - Stores `groupShortLinkInfo.groupRelays` in `ChatModel.shared.channelRelayHostnames` @@ -355,7 +355,7 @@ For channels: shows "This is your link for channel" with only "Open channel" + " ### Known Group Alert (`showOpenKnownGroupAlert`) -For channels (`groupInfo.useRelays`): titles become "Open channel" / "Open new channel". +For channels (`groupInfo.useRelays`): the title is "Open channel"; for groups, "Open group"; business chats keep "Open chat" / "Open new chat". Unless the chat is merely prepared (`nextConnectPrepared`) or a business chat, the alert shows an information line with the user's membership role (`memberRoleInformation`) in the secondary color: subscriber/contributor for channels, observer/member for groups, moderator/admin/owner for both. --- diff --git a/apps/ios/th.lproj/Localizable.strings b/apps/ios/th.lproj/Localizable.strings index 8114685292..d92174d281 100644 --- a/apps/ios/th.lproj/Localizable.strings +++ b/apps/ios/th.lproj/Localizable.strings @@ -3054,6 +3054,18 @@ server test failure */ /* No comment provided by engineer. */ "you are observer" = "คุณเป็นผู้สังเกตการณ์"; +/* new chat alert */ +"You are an observer" = "คุณเป็นผู้สังเกตการณ์"; + +/* new chat alert */ +"You are a member" = "คุณเป็นสมาชิก"; + +/* new chat alert */ +"You are an admin" = "คุณเป็นผู้ดูแลระบบ"; + +/* new chat alert */ +"You are an owner" = "คุณเป็นเจ้าของ"; + /* No comment provided by engineer. */ "You can accept calls from lock screen, without device and app authentication." = "คุณสามารถรับสายจากหน้าจอล็อกโดยไม่ต้องมีการตรวจสอบสิทธิ์อุปกรณ์และแอป"; diff --git a/apps/ios/tr.lproj/Localizable.strings b/apps/ios/tr.lproj/Localizable.strings index b0dd32c383..c5251f482e 100644 --- a/apps/ios/tr.lproj/Localizable.strings +++ b/apps/ios/tr.lproj/Localizable.strings @@ -1454,7 +1454,7 @@ server test step */ "Contact preferences" = "Kişi tercihleri"; /* No comment provided by engineer. */ -"Contact requests from groups" = "Gruplardan gelen iletişim talepleri"; +"Contact requests in groups" = "Gruplardan gelen iletişim talepleri"; /* No comment provided by engineer. */ "contact should accept…" = "kişi kabul etmeli…"; @@ -4629,7 +4629,7 @@ chat item action */ "Saved from" = "Tarafından kaydedildi"; /* No comment provided by engineer. */ -"saved from %@" = "%@ tarafından kaydedildi"; +"saved from" = "kaydedildi:"; /* message info title */ "Saved message" = "Kaydedilmiş mesaj"; @@ -5465,9 +5465,6 @@ server test failure */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "Bu ayar, geçerli sohbet profiliniz **%@** deki mesajlara uygulanır."; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "Bu ayar, mevcut profiliniz içindir."; - /* No comment provided by engineer. */ "Time to disappear is set only for new contacts." = "Kaybolma süresi yalnızca yeni kişiler için ayarlanır."; @@ -6045,6 +6042,21 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "gözlemcisiniz"; +/* new chat alert */ +"You are an observer" = "Gözlemcisiniz"; + +/* new chat alert */ +"You are a member" = "Üyesiniz"; + +/* new chat alert */ +"You are a moderator" = "Moderatörsünüz"; + +/* new chat alert */ +"You are an admin" = "Yöneticisiniz"; + +/* new chat alert */ +"You are an owner" = "Sahipsiniz"; + /* snd group event chat item */ "you blocked %@" = "engelledin %@"; diff --git a/apps/ios/uk.lproj/Localizable.strings b/apps/ios/uk.lproj/Localizable.strings index 55cce558f9..513b7c34b6 100644 --- a/apps/ios/uk.lproj/Localizable.strings +++ b/apps/ios/uk.lproj/Localizable.strings @@ -4650,7 +4650,7 @@ chat item action */ "Saved from" = "Збережено з"; /* No comment provided by engineer. */ -"saved from %@" = "збережено з %@"; +"saved from" = "збережено з"; /* message info title */ "Saved message" = "Збережене повідомлення"; @@ -6057,6 +6057,21 @@ alert title */ /* No comment provided by engineer. */ "you are observer" = "ви спостерігач"; +/* new chat alert */ +"You are an observer" = "Ви спостерігач"; + +/* new chat alert */ +"You are a member" = "Ви учасник"; + +/* new chat alert */ +"You are a moderator" = "Ви модератор"; + +/* new chat alert */ +"You are an admin" = "Ви адмін"; + +/* new chat alert */ +"You are an owner" = "Ви власник"; + /* snd group event chat item */ "you blocked %@" = "ви заблокували %@"; diff --git a/apps/ios/zh-Hans.lproj/Localizable.strings b/apps/ios/zh-Hans.lproj/Localizable.strings index e5d35b7426..7340147bf7 100644 --- a/apps/ios/zh-Hans.lproj/Localizable.strings +++ b/apps/ios/zh-Hans.lproj/Localizable.strings @@ -1680,7 +1680,7 @@ server test step */ "Contact preferences" = "联系人偏好设置"; /* No comment provided by engineer. */ -"Contact requests from groups" = "来自群的联络请求"; +"Contact requests in groups" = "来自群的联络请求"; /* No comment provided by engineer. */ "contact should accept…" = "联系人应当接受…"; @@ -5271,7 +5271,7 @@ chat item action */ "Saved from" = "保存自"; /* No comment provided by engineer. */ -"saved from %@" = "保存自 %@"; +"saved from" = "保存自"; /* message info title */ "Saved message" = "已保存的消息"; @@ -6267,9 +6267,6 @@ alert subtitle */ /* No comment provided by engineer. */ "This setting applies to messages in your current chat profile **%@**." = "此设置适用于您当前聊天资料 **%@** 中的消息。"; -/* No comment provided by engineer. */ -"This setting is for your current profile **%@**." = "此设置用于当前个人资料 **%@**。"; - /* No comment provided by engineer. */ "Time to disappear is set only for new contacts." = "只为新联系人设置了消失时间。"; @@ -6919,6 +6916,24 @@ alert title */ /* No comment provided by engineer. */ "you are subscriber" = "你是订阅者"; +/* new chat alert */ +"You are an observer" = "你是观察者"; + +/* new chat alert */ +"You are a member" = "你是成员"; + +/* new chat alert */ +"You are a moderator" = "你是协管"; + +/* new chat alert */ +"You are an admin" = "你是管理员"; + +/* new chat alert */ +"You are an owner" = "你是群主"; + +/* new chat alert */ +"You are a subscriber" = "你是订阅者"; + /* snd group event chat item */ "you blocked %@" = "你阻止了%@"; diff --git a/apps/multiplatform/.gitignore b/apps/multiplatform/.gitignore index bc00225c87..4762a9f2f4 100644 --- a/apps/multiplatform/.gitignore +++ b/apps/multiplatform/.gitignore @@ -16,6 +16,7 @@ android/build android/release common/build desktop/build +external/nanohttpd/build release # Generated SimpleX assets diff --git a/apps/multiplatform/CODE.md b/apps/multiplatform/CODE.md index 26a36e75bb..67fa676414 100644 --- a/apps/multiplatform/CODE.md +++ b/apps/multiplatform/CODE.md @@ -289,6 +289,7 @@ desktop/src/jvmMain/kotlin/chat/simplex/desktop/ -- Desktop app (1 file) | common/.../common/StoreWindowState.kt (desktopMain) | spec/architecture.md | product/views/settings.md | | common/.../common/model/NtfManager.desktop.kt (desktopMain) | spec/services/notifications.md | product/flows/messaging.md | | common/.../common/views/helpers/AppUpdater.kt (desktopMain) | spec/architecture.md | product/views/settings.md | +| common/.../common/platform/AnimatedImage.desktop.kt (desktopMain) | spec/client/chat-view.md | product/views/chat.md | ### Haskell Core Sources (at `../../src/Simplex/Chat/` relative to `apps/multiplatform/`) diff --git a/apps/multiplatform/README.md b/apps/multiplatform/README.md index eef1048ada..0d3c5b24f4 100644 --- a/apps/multiplatform/README.md +++ b/apps/multiplatform/README.md @@ -6,14 +6,31 @@ This is a guide to contributing to the develop of the SimpleX android and deskto This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX Chat, sharing code between Android and Desktop (JVM) platforms using Compose Multiplatform for UI. +## Setup + +The desktop app builds nanohttpd from a submodule, Android does not use it. Before building +the desktop app on a fresh checkout: + +```bash +git submodule update --init --recursive +``` + ## Build Commands ```bash -# Android debug APK -./gradlew assembleDebug +# Android debug APK, assembleGoogleDebug builds the flavor with the Play Billing dependency +./gradlew assembleFossDebug -# Android release APK -./gradlew assembleRelease +# Android release APK, distributed via F-Droid and GitHub +./gradlew assembleFossRelease + +# Android app bundle, distributed via Google Play, includes Play Billing +./gradlew bundleGoogleRelease + +# Always name the flavor for releases. The aggregate tasks (build, assemble, assembleRelease, +# bundle, bundleRelease) fail on purpose: they would package a release APK with Play Billing, +# or an app bundle without it. +# The fdroiddata recipe defaults to assembleRelease and must be changed to assembleFossRelease. # Desktop distribution (current OS) ./gradlew :desktop:packageDistributionForCurrentOS @@ -22,7 +39,7 @@ This is the **Kotlin Multiplatform (KMP)** mobile and desktop client for SimpleX ./gradlew desktopTest # Run Android instrumented tests (requires connected device/emulator) -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest # Build native libraries for all platforms ./gradlew common:cmakeBuild -PcrossCompile diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index 5255319194..419fef90b9 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -35,6 +35,21 @@ android { manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0 } + // `google` is distributed via Google Play as an app bundle and includes Play Billing. + // `foss` is distributed via F-Droid and as APKs on GitHub, without Play dependencies. + flavorDimensions += "store" + productFlavors { + create("google") { + dimension = "store" + buildConfigField("boolean", "PLAY_STORE", "true") + } + create("foss") { + dimension = "store" + isDefault = true + buildConfigField("boolean", "PLAY_STORE", "false") + } + } + buildTypes { debug { applicationIdSuffix = rootProject.extra["application_id.suffix"] as String @@ -128,8 +143,28 @@ android { } } +// The graph is checked rather than the requested task, because every aggregate task +// (assemble, assembleRelease, build, bundle, ...) packages these variants too. +val projectPath = project.path +val apkTasks = setOf("packageFossDebug", "packageGoogleDebug", "packageFossRelease", "packageGoogleRelease") +val apkTaskPaths = apkTasks.map { "$projectPath:$it" }.toSet() +val bundleTaskPaths = apkTaskPaths.map { it + "Bundle" }.toSet() +gradle.taskGraph.whenReady { + if (hasTask("$projectPath:packageGoogleRelease")) { + throw GradleException("A release apk must not include Play Billing, use assembleFossRelease or bundleGoogleRelease") + } + if (hasTask("$projectPath:packageFossReleaseBundle")) { + throw GradleException("An app bundle must include Play Billing, use bundleGoogleRelease or assembleFossRelease") + } + // `isBundle` above is derived from the whole invocation, so a bundle in it disables abi splits + if (apkTaskPaths.any { hasTask(it) } && bundleTaskPaths.any { hasTask(it) }) { + throw GradleException("Build the apks and the bundle in separate invocations, the bundle disables abi splits") + } +} + dependencies { implementation(project(":common")) + "googleImplementation"("com.android.billingclient:billing:9.1.0") implementation("androidx.core:core-ktx:1.13.1") //implementation("androidx.compose.ui:ui:${rootProject.extra["compose.version"] as String}") //implementation("androidx.compose.material:material:$compose_version") @@ -160,58 +195,61 @@ dependencies { tasks { val compressApk by creating { doLast { - val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null - val buildType: String = if (isRelease) "release" else "debug" val javaHome = System.getProperties()["java.home"] ?: org.gradle.internal.jvm.Jvm.current().javaHome val sdkDir = android.sdkDirectory.absolutePath - val keyAlias: String - val keyPassword: String - val storeFile: String - val storePassword: String - if (project.properties["android.injected.signing.key.alias"] != null) { - keyAlias = project.properties["android.injected.signing.key.alias"] as String - keyPassword = project.properties["android.injected.signing.key.password"] as String - storeFile = project.properties["android.injected.signing.store.file"] as String - storePassword = project.properties["android.injected.signing.store.password"] as String - } else { - try { - val gradleConfig = android.signingConfigs.getByName(buildType) - keyAlias = gradleConfig.keyAlias!! - keyPassword = gradleConfig.keyPassword!! - storeFile = gradleConfig.storeFile!!.absolutePath - storePassword = gradleConfig.storePassword!! - } catch (e: UnknownDomainObjectException) { - // There is no signing config for current build type, can"t sign the apk - println("No signing configs for this build type: $buildType") - return@doLast + // A single invocation can package more than one variant, for example assembleDebug + gradle.taskGraph.allTasks.filter { it.path in apkTaskPaths }.forEach { packageTask -> + val variant = packageTask.name.removePrefix("package") + val buildType: String = if (variant.endsWith("Release")) "release" else "debug" + val keyAlias: String + val keyPassword: String + val storeFile: String + val storePassword: String + if (project.properties["android.injected.signing.key.alias"] != null) { + keyAlias = project.properties["android.injected.signing.key.alias"] as String + keyPassword = project.properties["android.injected.signing.key.password"] as String + storeFile = project.properties["android.injected.signing.store.file"] as String + storePassword = project.properties["android.injected.signing.store.password"] as String + } else { + try { + val gradleConfig = android.signingConfigs.getByName(buildType) + keyAlias = gradleConfig.keyAlias!! + keyPassword = gradleConfig.keyPassword!! + storeFile = gradleConfig.storeFile!!.absolutePath + storePassword = gradleConfig.storePassword!! + } catch (e: UnknownDomainObjectException) { + // There is no signing config for current build type, can"t sign the apk + println("No signing configs for this build type: $buildType") + return@forEach + } + } + val outputDir = packageTask.outputs.files.files.last() + exec { + workingDir("../../scripts/android") + environment = mapOf( + "JAVA_HOME" to "$javaHome", + "PATH" to "${System.getenv("PATH")}:$javaHome/bin" + ) + commandLine = listOf( + "./compress-and-sign-apk.sh", + "${rootProject.extra["compression.level"]}", + "$outputDir", + sdkDir, + storeFile, + storePassword, + keyAlias, + keyPassword + ) } - } - lateinit var outputDir: File - named(if (isRelease) "packageRelease" else "packageDebug") { - outputDir = outputs.files.files.last() - } - exec { - workingDir("../../scripts/android") - environment = mapOf( - "JAVA_HOME" to "$javaHome", - "PATH" to "${System.getenv("PATH")}:$javaHome/bin" - ) - commandLine = listOf( - "./compress-and-sign-apk.sh", - "${rootProject.extra["compression.level"]}", - "$outputDir", - sdkDir, - storeFile, - storePassword, - keyAlias, - keyPassword - ) - } - if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { - File(outputDir, "android-release.apk").renameTo(File(outputDir, "simplex.apk")) - File(outputDir, "android-armeabi-v7a-release.apk").renameTo(File(outputDir, "simplex-armv7a.apk")) - File(outputDir, "android-arm64-v8a-release.apk").renameTo(File(outputDir, "simplex.apk")) + if (project.properties["android.injected.signing.key.alias"] != null && buildType == "release") { + val flavor = variant.removeSuffix("Release").lowercase() + mapOf("arm64-v8a" to "simplex.apk", "armeabi-v7a" to "simplex-armv7a.apk").forEach { (abi, name) -> + if (!File(outputDir, "android-$flavor-$abi-release.apk").renameTo(File(outputDir, name))) { + logger.warn("No $abi apk to rename to $name") + } + } + } } // View all gradle properties set // project.properties.each { k, v -> println "$k -> $v" } @@ -221,9 +259,7 @@ tasks { // Don"t do anything if no compression is needed if (rootProject.extra["compression.level"] as Int != 0) { whenTaskAdded { - if (name == "packageDebug") { - finalizedBy(compressApk) - } else if (name == "packageRelease") { + if (name in apkTasks) { finalizedBy(compressApk) } } diff --git a/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..181fe42389 --- /dev/null +++ b/apps/multiplatform/android/src/foss/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,4 @@ +package chat.simplex.app + +// Play Billing is only in the google flavor, so the Play country stays unknown here +fun loadPlayStoreCountry() {} diff --git a/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt new file mode 100644 index 0000000000..a0e7734ff0 --- /dev/null +++ b/apps/multiplatform/android/src/google/java/chat/simplex/app/PlayStore.kt @@ -0,0 +1,31 @@ +package chat.simplex.app + +import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.androidPlayStoreCountry +import com.android.billingclient.api.* + +// Requests the country of the Google Play account into [androidPlayStoreCountry]. +// It stays null when Play is unavailable or the user is not signed in. +fun loadPlayStoreCountry() { + val client = BillingClient.newBuilder(androidAppContext) + .setListener { _, _ -> } + .enablePendingPurchases(PendingPurchasesParams.newBuilder().enableOneTimeProducts().build()) + .build() + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + client.endConnection() + return + } + client.getBillingConfigAsync(GetBillingConfigParams.newBuilder().build()) { configResult, config -> + if (configResult.responseCode == BillingClient.BillingResponseCode.OK) { + androidPlayStoreCountry.value = config?.countryCode + } + client.endConnection() + } + } + + // The connection is only used for this one request, it is not retried + override fun onBillingServiceDisconnected() = client.endConnection() + }) +} diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt index 83767f90d7..ce47d2c5de 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/SimplexApp.kt @@ -341,6 +341,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidIsXiaomiDevice(): Boolean = setOf("xiaomi", "redmi", "poco").contains(Build.BRAND.lowercase()) + override fun androidLoadPlayStoreCountry() = loadPlayStoreCountry() + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { @@ -370,6 +372,8 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun androidCreateActiveCallState(): Closeable = ActiveCallState() override val androidApiLevel: Int get() = Build.VERSION.SDK_INT + + override val androidIsPlayStoreBuild: Boolean get() = BuildConfig.PLAY_STORE } } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 98845365fc..ec4235d344 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -72,7 +72,6 @@ kotlin { api("org.jetbrains.compose.ui:ui-text:${rootProject.extra["compose.version"] as String}") implementation("org.jetbrains.compose.material:material-icons-core:1.7.3") implementation("org.jetbrains.compose.material:material-icons-extended:1.7.3") - implementation("org.jetbrains.compose.components:components-animatedimage:${rootProject.extra["compose.version"] as String}") //Barcode api("org.boofcv:boofcv-core:1.1.3") implementation("com.godaddy.android.colorpicker:compose-color-picker-jvm:0.7.0") @@ -148,8 +147,7 @@ kotlin { implementation("org.slf4j:slf4j-simple:2.0.12") implementation("uk.co.caprica:vlcj:4.8.3") implementation("net.java.dev.jna:jna:5.14.0") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf") - implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf") + implementation(project(":external:nanohttpd")) implementation("com.squareup.okhttp3:okhttp:4.12.0") } } @@ -189,7 +187,6 @@ buildConfig { buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"") buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}") buildConfigField("String", "DATABASE_BACKEND", "\"${extra["database.backend"]}\"") - buildConfigField("Boolean", "ANDROID_BUNDLE", "${extra["android.bundle"]}") buildConfigField("Boolean", "SIMPLEX_ASSETS", "$hasSimplexAssets") } } diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt index ae5b8043ed..5538655a92 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.android.kt @@ -3,6 +3,7 @@ package chat.simplex.common.views.chat.item import android.os.Build.VERSION.SDK_INT import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.State import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -24,6 +25,7 @@ actual fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, // coil drives the animation itself here, so there is nothing to pause ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { val context = LocalContext.current diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt index c98f8f9f89..de6834ba41 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/helpers/Utils.android.kt @@ -233,7 +233,8 @@ actual fun getFileName(uri: URI): String? { val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) cursor.moveToFirst() // Can make an exception - cursor.getString(nameIndex) + // the provider controls this value, and callers use it as a bare file name + cursor.getString(nameIndex)?.let { File(it).name } } } catch (e: Exception) { null @@ -341,6 +342,19 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea VideoPlayerInterface.PreviewAndDuration(null, 0, 0) } +actual suspend fun hasVideoTrack(uri: URI): Boolean { + val mmr = MediaMetadataRetriever() + return try { + mmr.setDataSource(androidAppContext, uri.toUri()) + mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO) == "yes" + } catch (e: Exception) { + Log.e(TAG, "Utils.android hasVideoTrack error: ${e.message}") + false + } finally { + mmr.release() + } +} + actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encodeToString(this, Base64.DEFAULT) actual fun String.toByteArrayFromBase64ForPassphrase(): ByteArray = Base64.decode(this, Base64.DEFAULT) 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 bc4816de2f..aaa754bd61 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 @@ -1307,6 +1307,7 @@ data class User( val sendRcptsContacts: Boolean, val sendRcptsSmallGroups: Boolean, val autoAcceptMemberContacts: Boolean, + val autoAcceptGroupInvitations: Boolean, val viewPwdHash: UserPwdHash?, val uiThemes: ThemeModeOverrides? = null, val userChatRelay: Boolean, @@ -1339,6 +1340,7 @@ data class User( sendRcptsContacts = true, sendRcptsSmallGroups = false, autoAcceptMemberContacts = false, + autoAcceptGroupInvitations = false, viewPwdHash = null, uiThemes = null, userChatRelay = false, @@ -3937,13 +3939,15 @@ enum class MsgDirection { sealed class CIForwardedFrom { @Serializable @SerialName("unknown") object Unknown: CIForwardedFrom() @Serializable @SerialName("contact") class Contact(override val chatName: String, val msgDir: MsgDirection, val contactId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() - @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null): CIForwardedFrom() + @Serializable @SerialName("group") class Group(override val chatName: String, val msgDir: MsgDirection, val groupId: Long? = null, val chatItemId: Long? = null, val memberId: String? = null, val sharedMsgId_: String? = null, val groupType: GroupType? = null): CIForwardedFrom() + @Serializable @SerialName("groupLink") class GroupLink(override val chatName: String, val msgDir: MsgDirection, val groupLink: String, val publicGroupId: String, val memberId: String? = null, val sharedMsgId: String, val groupType: GroupType? = null): CIForwardedFrom() open val chatName: String get() = when (this) { Unknown -> "" is Contact -> chatName is Group -> chatName + is GroupLink -> chatName } val chatTypeApiIdMsgId: Triple? @@ -3951,18 +3955,15 @@ sealed class CIForwardedFrom { Unknown -> null is Contact -> if (contactId != null) Triple(ChatType.Direct, contactId, chatItemId) else null is Group -> if (groupId != null) Triple(ChatType.Group, groupId, chatItemId) else null + is GroupLink -> null } + val sourceGroupLink: String? + get() = if (this is GroupLink) groupLink else null + fun text(chatType: ChatType): String = - if (chatType == ChatType.Local) { - if (chatName.isEmpty()) { - generalGetString(MR.strings.saved_description) - } else { - generalGetString(MR.strings.saved_from_description).format(chatName) - } - } else { - generalGetString(MR.strings.forwarded_description) - } + if (chatType == ChatType.Local) generalGetString(MR.strings.saved_description) + else generalGetString(MR.strings.forwarded_description) } @Serializable 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 16c5db62b9..83d4470eb3 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 @@ -941,6 +941,12 @@ object ChatController { throw Exception("failed to set auto-accept ${r.responseType} ${r.details}") } + suspend fun apiSetUserAutoAcceptGroupInvitations(u: User, enable: Boolean) { + val r = sendCmd(u.remoteHostId, CC.ApiSetUserAutoAcceptGroupInvitations(u.userId, enable)) + if (r.result is CR.CmdOk) return + throw Exception("failed to set auto-accept group invitations ${r.responseType} ${r.details}") + } + suspend fun apiHideUser(u: User, viewPwd: String): User = setUserPrivacy(u.remoteHostId, CC.ApiHideUser(u.userId, viewPwd)) @@ -3786,6 +3792,7 @@ sealed class CC { class ApiSetUserContactReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiSetUserGroupReceipts(val userId: Long, val userMsgReceiptSettings: UserMsgReceiptSettings): CC() class ApiSetUserAutoAcceptMemberContacts(val userId: Long, val enable: Boolean): CC() + class ApiSetUserAutoAcceptGroupInvitations(val userId: Long, val enable: Boolean): CC() class ApiHideUser(val userId: Long, val viewPwd: String): CC() class ApiUnhideUser(val userId: Long, val viewPwd: String): CC() class ApiMuteUser(val userId: Long): CC() @@ -3977,6 +3984,7 @@ sealed class CC { "/_set receipts groups $userId ${onOff(mrs.enable)} clear_overrides=${onOff(mrs.clearOverrides)}" } is ApiSetUserAutoAcceptMemberContacts -> "/_set accept member contacts $userId ${onOff(enable)}" + is ApiSetUserAutoAcceptGroupInvitations -> "/_set accept group invitations $userId ${onOff(enable)}" is ApiHideUser -> "/_hide user $userId ${json.encodeToString(viewPwd)}" is ApiUnhideUser -> "/_unhide user $userId ${json.encodeToString(viewPwd)}" is ApiMuteUser -> "/_mute user $userId" @@ -4192,6 +4200,7 @@ sealed class CC { is ApiSetUserContactReceipts -> "apiSetUserContactReceipts" is ApiSetUserGroupReceipts -> "apiSetUserGroupReceipts" is ApiSetUserAutoAcceptMemberContacts -> "apiSetUserAutoAcceptMemberContacts" + is ApiSetUserAutoAcceptGroupInvitations -> "apiSetUserAutoAcceptGroupInvitations" is ApiHideUser -> "apiHideUser" is ApiUnhideUser -> "apiUnhideUser" is ApiMuteUser -> "apiMuteUser" diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt index 7a96bd99d2..140c1951ee 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/AppCommon.kt @@ -1,5 +1,7 @@ package chat.simplex.common.platform +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.ui.theme.DefaultTheme @@ -30,6 +32,9 @@ else val databaseBackend: String = if (appPlatform == AppPlatform.ANDROID) "sqlite" else BuildConfigCommon.DATABASE_BACKEND +// Country of the Google Play account, only set in the google flavor of the Android app +val androidPlayStoreCountry: MutableState = mutableStateOf(null) + class FifoQueue(private var capacity: Int) : LinkedList() { override fun add(element: E): Boolean { if (size > capacity) removeFirstOrNull() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt index 448100bc17..b46123c9cf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Platform.kt @@ -29,7 +29,11 @@ interface PlatformInterface { fun androidRestartNetworkObserver() {} fun androidCreateActiveCallState(): Closeable = Closeable { } fun androidIsXiaomiDevice(): Boolean = false + // Requests the Google Play account country into [androidPlayStoreCountry] + fun androidLoadPlayStoreCountry() {} val androidApiLevel: Int? get() = null + // The build distributed via Google Play, which has to follow its policies + val androidIsPlayStoreBuild: Boolean get() = false @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopShowAppUpdateNotice() {} 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 12f13a426f..9bbfda558f 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 @@ -502,14 +502,17 @@ fun ChatView( groupMembersJob = scope.launch(Dispatchers.Default) { val r = chatModel.controller.apiGroupMemberInfo(chatRh, groupInfo.groupId, member.groupMemberId) val stats = r?.second - val (_, code) = if (member.memberActive) { + val (updatedMember, code) = if (member.memberActive) { val memCode = chatModel.controller.apiGetGroupMemberCode(chatRh, groupInfo.apiId, member.groupMemberId) - member to memCode?.second + (memCode?.first ?: r?.first ?: member) to memCode?.second } else { - member to null + (r?.first ?: member) to null + } + if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch + // members are not loaded in large groups, so only the opened member is added to the model + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) } - setGroupMembers(chatRh, groupInfo, chatModel) - if (!isActive) return@launch if (chatsCtx.secondaryContextFilter == null) { ModalManager.end.closeModals() 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 ff393a3c30..6bfbad52ef 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 @@ -285,7 +285,22 @@ expect fun AttachmentSelection( ) fun MutableState.onFilesAttached(uris: List) { - val groups = uris.groupBy { isImage(it) || isVideoUri(it) } + // The extension is enough to classify every format except .webm, which is just as commonly an + // audio-only container as a video one. An audio-only file has no frame to embed and is sent as a file, + // but that can only be told from the content, so reading it is deferred to a background thread. + // Only done here, where files arrive without the user saying how to send them (drag & drop, paste) - + // an explicitly picked video is still sent as one. + if (uris.none { isWebmUri(it) }) { + attachFiles(uris, emptySet()) + } else { + CoroutineScope(Dispatchers.IO).launch { + attachFiles(uris, uris.filter { isWebmUri(it) && hasVideoTrack(it) }.toSet()) + } + } +} + +private fun MutableState.attachFiles(uris: List, webmVideos: Set) { + val groups = uris.groupBy { isImage(it) || (isVideoUri(it) && (!isWebmUri(it) || it in webmVideos)) } val media = groups[true] ?: emptyList() val files = groups[false] ?: emptyList() if (media.isNotEmpty()) { @@ -298,9 +313,12 @@ fun MutableState.onFilesAttached(uris: List) { private fun isVideoUri(uri: URI): Boolean { val name = getFileName(uri)?.lowercase() ?: return false return name.endsWith(".mov") || name.endsWith(".avi") || name.endsWith(".mp4") || - name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") + name.endsWith(".mpg") || name.endsWith(".mpeg") || name.endsWith(".mkv") || + name.endsWith(".webm") } +private fun isWebmUri(uri: URI): Boolean = getFileName(uri)?.lowercase()?.endsWith(".webm") == true + fun MutableState.processPickedFile(uri: URI?, text: String?) { if (uri != null) { val maxFileSize = value.maxFileSize 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 7ce44475b5..67fc0a038c 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 @@ -210,7 +210,7 @@ fun CIImageView( val loaded = res.value if (loaded != null && file != null) { val (imageBitmap, data, _) = loaded - SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) + SimpleAndAnimatedImageView(data, imageBitmap, file, imageProvider, smallView, blurred, @Composable { painter, onClick -> ImageView(painter, image, file.fileSource, onClick) }) } else { imageView(previewBitmap, onClick = { if (file != null) { @@ -281,5 +281,6 @@ expect fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..c919859b1f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -23,6 +23,7 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* import chat.simplex.common.views.helpers.* +import chat.simplex.common.views.chatlist.openChat import chat.simplex.common.views.newchat.planAndConnect import chat.simplex.res.MR import kotlinx.coroutines.Dispatchers @@ -101,14 +102,35 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + fun HeaderText(caption: String, italic: Boolean, fontSize: TextUnit = 12.sp, modifier: Modifier = Modifier) { + Text( + modifier = modifier, + text = buildAnnotatedString { + withStyle(SpanStyle(fontSize = fontSize, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { + append(caption) + } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + + @Composable + fun headerModifier(pad: Boolean, onClick: (() -> Unit)? = null): Modifier { val sentColor = MaterialTheme.appColors.sentQuote val receivedColor = MaterialTheme.appColors.receivedQuote + return Modifier + .background(if (sent) sentColor else receivedColor) + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + } + + @Composable + fun HeaderRow(modifier: Modifier, caption: String, italic: Boolean, icon: Painter?, iconColor: Color?) { Row( - Modifier - .background(if (sent) sentColor else receivedColor) - .fillMaxWidth() - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp), + modifier, horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { @@ -120,19 +142,15 @@ fun FramedItemView( tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontSize = 12.sp, fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal, color = MaterialTheme.colors.secondary)) { - append(caption) - } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + HeaderText(caption, italic) } } + @Composable + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) { + HeaderRow(headerModifier(pad), caption, italic, icon, iconColor) + } + @Composable fun ciQuoteView(qi: CIQuote) { val sentColor = MaterialTheme.appColors.sentQuote @@ -293,8 +311,38 @@ fun FramedItemView( } } else { Header() - if (ci.meta.itemForwarded != null) { - FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + val forwarded = ci.meta.itemForwarded + if (forwarded != null) { + val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { + forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null + } else { + when (forwarded) { + is CIForwardedFrom.Group -> forwarded.groupType != null + is CIForwardedFrom.GroupLink -> true + else -> false + } + } + if (twoRowHeader) { + val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) + Column( + headerModifier(pad = true, onClick = { + val target = forwarded.chatTypeApiIdMsgId + val link = forwarded.sourceGroupLink + if (target != null) { + val (chatType, apiId, itemId) = target + withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } + } else if (link != null) { + withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } + } + }), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) + HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } + } else { + FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } } } if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt index 8d96102daa..b1604adc84 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.kt @@ -148,8 +148,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> ) } .fillMaxSize() - // LALAL - // https://github.com/JetBrains/compose-multiplatform/pull/2015/files#diff-841b3825c504584012e1d1c834d731bae794cce6acad425d81847c8bbbf239e0R24 if (media is ProviderMedia.Image) { val (data: ByteArray, imageBitmap: ImageBitmap) = media FullScreenImageView(modifier, data, imageBitmap) 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 77b4c40d7d..68fa25d553 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 @@ -183,6 +183,8 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow WhatsNewView(close = close, updatedConditions = showUpdatedConditions) } } 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 80f97d1caf..241826ac41 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 @@ -753,6 +753,11 @@ private fun saveArchiveFromURI(importedArchiveURI: URI): String? { if (inputStream != null && archiveName != null) { val archivePath = "$databaseExportDir${File.separator}$archiveName" val destFile = File(archivePath) + // resolves symlinks, so it also catches a final component linking outside the folder + if (destFile.canonicalFile.parentFile != databaseExportDir.canonicalFile) { + Log.e(TAG, "saveArchiveFromURI path outside of export folder") + return null + } Files.copy(inputStream, destFile.toPath(), StandardCopyOption.REPLACE_EXISTING) archivePath } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt index f70e4d0048..62ba2c10f3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AlertManager.kt @@ -294,6 +294,7 @@ class AlertManager { nameCaption: String? = null, subtitle: String? = null, information: String? = null, + secondaryInformation: Boolean = false, confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat), onConfirm: (() -> Unit)? = null, connectOtherButton: String? = null, @@ -378,6 +379,7 @@ class AlertManager { information, textAlign = TextAlign.Center, style = MaterialTheme.typography.body2, + color = if (secondaryInformation) MaterialTheme.colors.secondary else Color.Unspecified, maxLines = 3, modifier = Modifier.fillMaxWidth() ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt index ee63846657..cf2ceaf2d6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/AppBarTitle.kt @@ -31,7 +31,8 @@ fun AppBarTitle( val connection = if (enableAlphaChanges) handler?.connection else null LaunchedEffect(title) { if (enableAlphaChanges) { - handler?.title?.value = title + // the app bar shows a single line, so the line breaks of the large title are replaced with spaces + handler?.title?.value = title.replace("\n", " ") } else { handler?.connection?.scrollTrackingEnabled = false } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt index 70f4a1759b..3128c63234 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Utils.kt @@ -495,6 +495,9 @@ fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration +// Whether the file really contains a video track. Reads container metadata only, without decoding a frame. +expect suspend fun hasVideoTrack(uri: URI): Boolean + fun showWrongUriAlert() { AlertManager.shared.showAlertMsg( title = generalGetString(MR.strings.non_content_uri_alert_title), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt index 161681c91d..5e5769891a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/ConnectPlan.kt @@ -656,11 +656,24 @@ private fun showOpenKnownGroupAlert(chatModel: ChatModel, rhId: Long?, close: (( }, nameCaption = planSimplexName?.shortStr, subtitle = subscriberCount, + information = if (groupInfo.nextConnectPrepared || groupInfo.businessChat != null) { + null + } else { + val isChannel = groupInfo.useRelays + generalGetString(when (groupInfo.membership.memberRole) { + GroupMemberRole.Observer -> if (isChannel) MR.strings.connect_plan_you_are_subscriber else MR.strings.connect_plan_you_are_observer + GroupMemberRole.Moderator -> MR.strings.connect_plan_you_are_moderator + GroupMemberRole.Admin -> MR.strings.connect_plan_you_are_admin + GroupMemberRole.Owner -> MR.strings.connect_plan_you_are_owner + else -> if (isChannel) MR.strings.connect_plan_you_are_contributor else MR.strings.connect_plan_you_are_member + }) + }, + secondaryInformation = true, confirmText = generalGetString( if (groupInfo.useRelays) { - if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_channel + MR.strings.connect_plan_open_channel } else if (groupInfo.businessChat == null) { - if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_group else MR.strings.connect_plan_open_group + MR.strings.connect_plan_open_group } else { if (groupInfo.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat } @@ -761,7 +774,7 @@ fun showPrepareGroupAlert( nameCaption = planSimplexName?.shortStr, subtitle = subscriberCount, information = ownerVerificationMessage(ownerVerification), - confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_new_channel else MR.strings.connect_plan_open_new_group), + confirmText = generalGetString(if (isChannel) MR.strings.connect_plan_open_channel else MR.strings.connect_plan_open_group), onConfirm = { AlertManager.privacySensitive.hideAlert() withBGApi { 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 d3bca178aa..f3006d221b 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 @@ -836,7 +836,8 @@ fun strConnectTarget(str: String): ConnectTarget? { val links = parsedMd.filter { it.format?.isSimplexLink ?: false } if (links.size == 1) { val fmt = links[0].format as Format.SimplexLink - return ConnectTarget.Link(links[0].text, fmt.linkType, fmt.simplexLinkText) + val text = if (fmt.showText != null) fmt.simplexUri else links[0].text + return ConnectTarget.Link(text, fmt.linkType, fmt.simplexLinkText) } if (links.isEmpty()) { val nameFt = parsedMd.firstOrNull { it.format is Format.SimplexName } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt index f95ffc1961..ea7d935efe 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/onboarding/WhatsNewView.kt @@ -1,6 +1,10 @@ package chat.simplex.common.views.onboarding import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculatePan +import androidx.compose.foundation.gestures.calculateZoom import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* @@ -8,17 +12,41 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.toRect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalUriHandler import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink +import androidx.compose.ui.text.withStyle import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel import chat.simplex.common.model.* @@ -34,6 +62,7 @@ import chat.simplex.common.views.usersettings.showAddShortLinkAlert import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.StringResource +import kotlin.math.absoluteValue @Composable fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Boolean = false, close: () -> Unit) { @@ -913,9 +942,15 @@ private val versionDescriptions: List = listOf( ) ), VersionDescription( - version = "v7.0", - post = null, + // the trailing space differs from the previously released "v7.0", so that What's new is shown again + version = if (isInUs()) "v7.0.1" else "v7.0", + post = "https://simplex.chat/blog/20260819-simplex-chat-crowdfunding.html", features = listOf( + VersionFeature.FeatureView( + icon = null, + titleId = MR.strings.v7_0_invest, + view = { modalManager -> InvestInSimpleXChatView(modalManager) } + ), VersionFeature.FeatureDescription( icon = MR.images.ic_alternate_email, titleId = MR.strings.v7_0_simplex_names, @@ -950,6 +985,295 @@ fun shouldShowWhatsNew(m: ChatModel): Boolean { return v != lastVersion } +private const val WEFUNDER_URL = "https://wefunder.com/simplex.chat" + +private const val CROWDFUNDING_CONTACT_URI = "simplex:/a#JxGcOA1_QhlmVFzYYabloMbvMZk5Y9d9iS3ITDnhzYo?h=smp11.simplex.im" + +// the center modal takes the remaining width of the window, so the image is limited to its design width +private val MAX_CROWDFUNDING_IMAGE_WIDTH = DEFAULT_MIN_CENTER_MODAL_WIDTH + +// the width of the page images shipped with the desktop app, so that they are never upscaled +private val CROWDFUNDING_PAGE_IMAGE_WIDTH = DEFAULT_MIN_CENTER_MODAL_WIDTH + +// the corner radius the images are designed with, and the same radius as a share of their design width +private val CROWDFUNDING_IMAGE_CORNER_RADIUS = 12.dp +private const val CROWDFUNDING_IMAGE_CORNER_RADIUS_RATIO = 0.03f + +private class CrowdfundingLayout( + val maxImageWidth: Dp, + val imageShape: Shape, + // the modal manager that shows the page in the center of the window, or null when nothing does + private val centerOfWindow: ModalManager? +) { + fun inCenterOfWindow(modalManager: ModalManager) = modalManager === centerOfWindow +} + +// the images are designed for the width of a phone screen, which Android always gives them. On desktop +// they are limited to their own width, and their radius is scaled with them, as they are still shown +// wider than designed: a fixed radius would not only look almost square, but would also leave the corners +// baked into the jpegs visible - they have black behind them, as jpegs have no transparency +private val crowdfundingLayout = if (appPlatform.isDesktop) + CrowdfundingLayout(CROWDFUNDING_PAGE_IMAGE_WIDTH, object : Shape { + override fun createOutline(size: Size, layoutDirection: LayoutDirection, density: Density): Outline = + Outline.Rounded(RoundRect(size.toRect(), CornerRadius(size.width * CROWDFUNDING_IMAGE_CORNER_RADIUS_RATIO))) + }, ModalManager.center) +else + CrowdfundingLayout(Dp.Unspecified, RoundedCornerShape(CROWDFUNDING_IMAGE_CORNER_RADIUS), null) + +// Google Play policy restricts promoting investments, so Play builds only show it in the US +@Composable +fun crowdfundingAvailable(): Boolean { + if (!platform.androidIsPlayStoreBuild) return true + if (androidPlayStoreCountry.value == null) { + LaunchedEffect(Unit) { + if (androidPlayStoreCountry.value == null) platform.androidLoadPlayStoreCountry() + } + } + return isInUs() +} + +fun isInUs(): Boolean = + androidPlayStoreCountry.value == "US" + || androidPlayStoreCountry.value == "" + || androidPlayStoreCountry.value == null + +@Composable +private fun InvestInSimpleXChatView(modalManager: ModalManager) { + if (!crowdfundingAvailable()) return + val showGetStake = { modalManager.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = false, inCenterOfWindow = crowdfundingLayout.inCenterOfWindow(modalManager), close = close) } } + Column(modifier = Modifier.padding(bottom = 12.dp)) { + Text( + generalGetString(MR.strings.v7_0_invest), + style = MaterialTheme.typography.h4, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(bottom = 6.dp) + ) + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.v7_0_invest_descr)) + append(" ") + withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { + append(generalGetString(MR.strings.learn_more)) + } + }, + fontSize = 15.sp, + modifier = Modifier + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = showGetStake + ) + ) + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(MR.images.crowdfunding_1), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .padding(top = 8.dp) + .widthIn(max = MAX_CROWDFUNDING_IMAGE_WIDTH) + .fillMaxWidth() + .clip(crowdfundingLayout.imageShape) + .pointerHoverIcon(PointerIcon.Hand) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = showGetStake + ) + ) + } + } +} + +private class CrowdfundingSlide( + val image: ImageResource, + val heading: String, + val info: String?, + val text: String, +) + +// not localized: the page is only shown to US investors, and the text duplicates the images +private val getStakeSlides: List = listOf( + CrowdfundingSlide( + MR.images.crowdfunding_1, + "The first and the only messaging network without any user IDs", + null, + "By investing, you can benefit from the company growth, and help us build the future of private and secure communications." + ), + CrowdfundingSlide( + MR.images.crowdfunding_2, + "480,000+ users joined on their own", + null, + "SimpleX users have been more than doubling every year without any paid marketing, and donated over \$650,000." + ), + CrowdfundingSlide( + MR.images.crowdfunding_3, + "Developers already bet on SimpleX success", + "Independent developers created moderation and AI bots, Telegram bridges, and a public server registry.", + "Every service developers build on SimpleX Network may increase its value, and bring new users to SimpleX Chat." + ), + CrowdfundingSlide( + MR.images.crowdfunding_4, + "Revenue plan: free for users, channels & businesses pay", + "SimpleX Chat plans to earn from the infrastructure and services that creators, businesses and large communities need as they grow.", + "Read about how we plan to make SimpleX Chat and network profitable, and about all the investment terms on Wefunder." + ), +) + +@Composable +fun GetStakeView(fromSettings: Boolean, inCenterOfWindow: Boolean = false, close: () -> Unit) { + val uriHandler = LocalUriHandler.current + val stopped = chatModel.chatRunning.value == false + + @Composable + fun slideImage(slide: CrowdfundingSlide) { + if (BuildConfigCommon.SIMPLEX_ASSETS) { + Image( + painterResource(slide.image), + contentDescription = null, + contentScale = ContentScale.FillWidth, + modifier = Modifier + .widthIn(max = crowdfundingLayout.maxImageWidth) + .fillMaxWidth() + .clip(crowdfundingLayout.imageShape) + .fullScreenOnClick(slide.image) + ) + } else { + Text(slide.heading, style = MaterialTheme.typography.h4, fontWeight = FontWeight.Medium) + if (slide.info != null) { + Text(slide.info, Modifier.padding(top = 4.dp), lineHeight = 24.sp) + } + } + } + + ColumnWithScrollBar(Modifier.pinchZoom().padding(horizontal = DEFAULT_PADDING)) { + // in the center of the window the page is wide enough for the title to fit on one line + val title = "Get a stake in\nSimpleX Chat" + AppBarTitle(if (inCenterOfWindow) title.replace("\n", " ") else title, withPadding = false) + // What's new already shows the image of the first slide, above the link that opens this page + if (fromSettings) { + slideImage(getStakeSlides[0]) + } + Text( + buildAnnotatedString { + append(getStakeSlides[0].text) + // only the link is clickable, the rest of the paragraph is not + withLink(LinkAnnotation.Url(WEFUNDER_URL) { uriHandler.openUriCatching(WEFUNDER_URL) }) { + withStyle(SpanStyle(color = MaterialTheme.colors.primary, fontWeight = FontWeight.Bold)) { + append(" Learn more and invest on Wefunder.") + } + } + }, + Modifier.padding(top = if (fromSettings) 8.dp else 0.dp), + lineHeight = 24.sp + ) + + getStakeSlides.drop(1).forEach { slide -> + Column(Modifier.padding(top = DEFAULT_PADDING * 1.5f)) { + slideImage(slide) + Text(slide.text, Modifier.padding(top = 8.dp), lineHeight = 24.sp) + } + } + + Column( + Modifier.fillMaxWidth().padding(top = DEFAULT_PADDING * 2), + horizontalAlignment = Alignment.CenterHorizontally + ) { + OnboardingActionButton( + if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier.widthIn(min = 300.dp), + labelId = MR.strings.v7_0_invest_learn_more, + onboarding = null, + onclick = { uriHandler.openUriCatching(WEFUNDER_URL) } + ) + if (!chatModel.desktopNoUserNoRemote) { + TextButtonBelowOnboardingButton( + "or ask SimpleX team", + onClick = if (stopped) null else ({ + close() + uriHandler.openVerifiedSimplexUri(CROWDFUNDING_CONTACT_URI) + }) + ) + } + } + } +} + +// there is no pinch gesture with a mouse, so on desktop a slide is opened full screen instead +@Composable +private fun Modifier.fullScreenOnClick(image: ImageResource): Modifier { + if (!appPlatform.isDesktop) return this + return pointerHoverIcon(PointerIcon.Hand).clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { + ModalManager.fullscreen.showCustomModal { close -> + BackHandler(onBack = close) + Box( + Modifier + .fillMaxSize() + .background(Color.Black) + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = close), + contentAlignment = Alignment.Center + ) { + Image(painterResource(image), contentDescription = null, contentScale = ContentScale.Fit, modifier = Modifier.fillMaxSize()) + } + } + } +} + +private const val MAX_PAGE_ZOOM = 5f + +/** + * The slide images contain small text that is unreadable at screen width, so the page can be pinch-zoomed. + * Android only: pinch is unavailable with a mouse. + */ +@Composable +private fun Modifier.pinchZoom(): Modifier { + if (!appPlatform.isAndroid) return this + var scale by remember { mutableStateOf(1f) } + var offsetX by remember { mutableStateOf(0f) } + var offsetY by remember { mutableStateOf(0f) } + var size by remember { mutableStateOf(IntSize.Zero) } + return this + .onGloballyPositioned { size = it.size } + .graphicsLayer { + scaleX = scale + scaleY = scale + translationX = offsetX + translationY = offsetY + } + .pointerInput(Unit) { + awaitEachGesture { + // the initial pass, as the scroll of the same column is applied after this modifier and would take the gesture first + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + var taken: Boolean? = null + do { + val event = awaitPointerEvent(PointerEventPass.Initial) + val multiTouch = event.changes.count { it.pressed } > 1 + if (multiTouch || scale > 1f) { + scale = (scale * event.calculateZoom()).coerceIn(1f, MAX_PAGE_ZOOM) + val pan = event.calculatePan() + // the page is scaled around its center, so it can be panned by half of the overflow in each direction + val maxX = size.width * (scale - 1f) / 2 + val maxY = size.height * (scale - 1f) / 2 + val pannedY = offsetY + pan.y * scale + // the clamp is applied even when the gesture is not taken: at scale 1 both bounds + // are 0, which resets the offsets after zooming back out + offsetX = (offsetX + pan.x * scale).coerceIn(-maxX, maxX) + offsetY = pannedY.coerceIn(-maxY, maxY) + // two fingers always mean zoom, taken without a touch slop: waiting for one would let + // the scroll reach its own slop first and scroll the page. A one finger drag is left + // to the scroll at the edges, decided once so it cannot alternate mid drag + if (multiTouch) taken = true + else if (taken == null && pan.y != 0f) taken = pannedY.absoluteValue < maxY + if (taken == true) event.changes.forEach { if (it.pressed) it.consume() } + } + } while (event.changes.any { it.pressed }) + } + } +} + @Composable fun CreateUpdateAddressShortLinkView(modalManager: ModalManager) { val clipboard = LocalClipboardManager.current 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 59136e90d2..314537f579 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 @@ -87,13 +87,19 @@ fun PrivacySettingsView( val currentUser = chatModel.currentUser.value if (currentUser != null && !chatModel.desktopNoUserNoRemote) { SectionDividerSpaced() - ContacRequestsFromGroupsSection( + AutoAcceptSection( currentUser = currentUser, - setAutoAcceptGrpDirectInvs = { enable -> + setAutoAcceptMemberContacts = { enable -> withApi { chatModel.controller.apiSetUserAutoAcceptMemberContacts(currentUser, enable) chatModel.currentUser.value = currentUser.copy(autoAcceptMemberContacts = enable) } + }, + setAutoAcceptGroupInvitations = { enable -> + withApi { + chatModel.controller.apiSetUserAutoAcceptGroupInvitations(currentUser, enable) + chatModel.currentUser.value = currentUser.copy(autoAcceptGroupInvitations = enable) + } } ) } @@ -333,16 +339,26 @@ expect fun PrivacyDeviceSection( ) @Composable -private fun ContacRequestsFromGroupsSection( +private fun AutoAcceptSection( currentUser: User, - setAutoAcceptGrpDirectInvs: (Boolean) -> Unit + setAutoAcceptMemberContacts: (Boolean) -> Unit, + setAutoAcceptGroupInvitations: (Boolean) -> Unit ) { - SectionView(stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) { - SettingsActionItemWithContent(painterResource(MR.images.ic_check), stringResource(MR.strings.auto_accept_contact)) { + // legacy string key names, reused for their values so this section stays translated + SectionView(stringResource(MR.strings.auto_accept_contact)) { + SettingsActionItemWithContent(painterResource(MR.images.ic_person), stringResource(MR.strings.settings_section_title_contact_requests_from_groups)) { DefaultSwitch( checked = currentUser.autoAcceptMemberContacts, onCheckedChange = { enable -> - setAutoAcceptGrpDirectInvs(enable) + setAutoAcceptMemberContacts(enable) + } + ) + } + SettingsActionItemWithContent(painterResource(MR.images.ic_group), stringResource(MR.strings.group_invitations)) { + DefaultSwitch( + checked = currentUser.autoAcceptGroupInvitations, + onCheckedChange = { enable -> + setAutoAcceptGroupInvitations(enable) } ) } @@ -350,7 +366,7 @@ private fun ContacRequestsFromGroupsSection( SectionTextFooter( remember(currentUser.displayName) { buildAnnotatedString { - append(generalGetString(MR.strings.this_setting_is_for_your_current_profile) + " ") + append(generalGetString(MR.strings.these_settings_are_for_your_current_profile) + " ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(currentUser.displayName) } @@ -387,7 +403,7 @@ private fun DeliveryReceiptsSection( SectionTextFooter( remember(currentUser.displayName) { buildAnnotatedString { - append(generalGetString(MR.strings.receipts_section_description) + " ") + append(generalGetString(MR.strings.these_settings_are_for_your_current_profile) + " ") withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(currentUser.displayName) } 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 c8e040c592..8c134cb361 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 @@ -22,7 +22,6 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.* -import chat.simplex.common.BuildConfigCommon import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.platform.* @@ -30,8 +29,10 @@ import chat.simplex.common.ui.theme.* import chat.simplex.common.views.database.DatabaseView import chat.simplex.common.views.helpers.* import chat.simplex.common.views.migration.MigrateFromDeviceView +import chat.simplex.common.views.onboarding.GetStakeView import chat.simplex.common.views.onboarding.SimpleXInfo import chat.simplex.common.views.onboarding.WhatsNewView +import chat.simplex.common.views.onboarding.crowdfundingAvailable import chat.simplex.common.views.usersettings.networkAndServers.NetworkAndServersView import chat.simplex.res.MR @@ -111,6 +112,17 @@ fun SettingsLayout( AppShutdownItem() AppVersionItem(showVersion) } + + if (crowdfundingAvailable()) { + SectionDividerSpaced() + SectionView(stringResource(MR.strings.v7_0_invest)) { + SettingsActionItem( + painterResource(MR.images.ic_redeem), + stringResource(MR.strings.v7_0_crowdfunding), + { ModalManager.start.showModalCloseable(cardScreen = true) { close -> GetStakeView(fromSettings = true, close = close) } } + ) + } + } SectionBottomSpacer() } } @@ -143,7 +155,7 @@ fun HelpAndSupportView( SectionDividerSpaced() SectionView(stringResource(MR.strings.settings_section_title_support_project)) { - if (!BuildConfigCommon.ANDROID_BUNDLE) { + if (!platform.androidIsPlayStoreBuild) { ContributeItem(uriHandler) } if (appPlatform.isAndroid) { 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 9d9ea0cadd..0df6dafacb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ar/strings.xml @@ -29,7 +29,7 @@ اسمح أضِف خوادم مُعدة مسبقًا أضِف إلى جهاز آخر - سيتم حذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! + ستُحذف جميع الدردشات والرسائل - لا يمكن التراجع عن هذا! الوصول إلى الخوادم عبر وسيط SOCKS على المنفذ %d؟ يجب بدء تشغيل الوسيط قبل تفعيل هذا الخيار. أضف خادم إعدادات الشبكة المتقدّمة @@ -44,7 +44,7 @@ أضف الخوادم عن طريق مسح رموز QR. يمكن للمُدراء إنشاء روابط للانضمام إلى المجموعات. قبول طلب الاتصال؟ - سيتم حذف جميع الرسائل - لا يمكن التراجع عن هذا! سيتم حذف الرسائل فقط من أجلك. + ستُحذف كل الرسائل - لا يمكن التراجع عن هذا! ستُحذف الرسائل فقط من أجلك. قُبلت المكالمة اسمح بالمكالمات فقط إذا سمحت جهة اتصالك بذلك. اسمح بردود الفعل على الرسائل فقط إذا سمحت جهة اتصالك بذلك. @@ -686,7 +686,7 @@ تصفية الدردشات غير المقروءة والمفضلة. البحث عن الدردشات بشكل أسرع فعّل - حتى عندما يتم تعطيله في المحادثة. + حتى عندما تُعطّل في المحادثة. إصلاح التعمية بعد استعادة النُسخ الاحتياطية. اجعل رسالة واحدة تختفي خطأ في تفعيل إيصالات التسليم! @@ -1106,7 +1106,7 @@ انقر لتنشيط ملف التعريف. عزل النقل هذه السلسلة ليست رابط اتصال! - هذه الإعدادات لملف تعريفك الحالي + هذه الإعدادات لملف تعريفك الحالي يمكن تجاوزها في إعدادات الاتصال والمجموعة. انتهت مهلة اتصال TCP لحماية المنطقة الزمنية، تستخدم ملفات الصور / الصوت التوقيت العالمي المنسق (UTC). @@ -1219,6 +1219,13 @@ تحديث إعدادات الشبكة؟ سيؤدي تحديث الإعدادات إلى إعادة توصيل العميل بجميع الخوادم. أنت المراقب + أنت المراقب + أنت عضو + أنت مُشرف + أنت المُدير + أنت المالك + أنت مشترك + أنت مساهم أنت مدعو إلى المجموعة في انتظار التأكيد… خطأ غير معروف في قاعدة البيانات: %s @@ -1566,7 +1573,7 @@ يقبل شريط البحث روابط الدعوة. تحسّن تسليم الرسائل مع انخفاض استخدام البطارية. - سيتم حذف كافة الرسائل - لا يمكن التراجع عن هذا! + ستُحذف كل الرسائل - لا يمكن التراجع عن هذا! أُنشئ في واجهة المستخدم المجرية والتركية الصق الرابط للاتصال! @@ -1710,7 +1717,7 @@ لا يستطيع المُستلم/ون معرفة مَن أرسل هذه الرسالة. حُفظت حُفظت مِن - حُفظت مِن %s + حُفظت مِن السماعة سماعة الأذن سماعات الرأس @@ -2184,7 +2191,7 @@ أضف أعضاء فريقك إلى المحادثات. يُمنع إرسال الرسائل المباشرة بين الأعضاء في هذه الدردشة. أجهزة Xiaomi: يُرجى تفعيل التشغيل التلقائي (Autostart) في إعدادات النظام لكي تعمل الإشعارات.]]> - مُعمَّاة بين الطرفين، مع أمان ما بعد الكم في الرسائل المباشرة.]]> + مُعمَّاة بين الطرفين، مع أمان ما بعد الكم في الرسائل المباشرة.]]> تحقق من الرسائل كل 10 دقائق يُمنع إرسال الرسائل المباشرة بين الأعضاء. الدردشة @@ -2247,7 +2254,7 @@ خطأ في إنشاء قائمة الدردشة الشركات خطأ في تحميل قوائم الدردشة - سيتم إزالة جميع المحادثات من القائمة %s، وسيتم حذف القائمة + ستُزال جميع المحادثات من القائمة %s، وستُحذف القائمة أنشئ قائمة خطأ في تحديث قائمة الدردشة الملحوظات @@ -2255,7 +2262,7 @@ تغيير الترتيب خطأ في حفظ الإعدادات خطأ في إنشاء بلاغ - أنت والمشرفون فقط هم من يرون ذلك + أنت والمُشرفون فقط هم من يرون ذلك بلاغ مؤرشف لا يراه إلا المُرسِل والمُشرفين أرشف @@ -2269,15 +2276,15 @@ 1 بلاغ %d بلاغات بلاغات الأعضاء - بلّغ عن المحتوى: سيراه مشرفو المجموعة فقط. - بلّغ عن أُخرى: سيراه مشرفو المجموعة فقط. - مشرف + بلّغ عن المحتوى: سيراه مُشرفو المجموعة فقط. + بلّغ عن أُخرى: سيراه مُشرفو المجموعة فقط. + مُشرف بلاغ مؤرشف بواسطة %s - بلّغ عن ملف تعريف العضو: سيراه مشرفو المجموعة فقط. + بلّغ عن ملف تعريف العضو: سيراه مُشرفو المجموعة فقط. انتهاك إرشادات المجتمع محتوى غير لائق - بلّغ عن مخالفة: سيراه مشرفو المجموعة فقط. - بلّغ عن إزعاج (spam): سيراه مشرفو المجموعة فقط. + بلّغ عن مخالفة: سيراه مُشرفو المجموعة فقط. + بلّغ عن إزعاج (spam): سيراه مُشرفو المجموعة فقط. أرشفة البلاغ؟ سبب الإبلاغ؟ سيتم أرشفة البلاغ لك. @@ -2307,14 +2314,14 @@ اكتم الكل ذّكورات غير مقروءة يمكنك ذكر ما يصل إلى %1$s من الأعضاء في الرسالة الواحدة! - السماح بالإبلاغ عن الرسائل إلى المشرفين. - امنع الإبلاغ عن الرسائل للمشرفين. + السماح بالإبلاغ عن الرسائل إلى المُشرفين. + امنع الإبلاغ عن الرسائل للمُشرفين. أرشفة كافة البلاغات؟ أرشف البلاغات - لكل المشرفين + لكل المُشرفين لي بلاغ: %s - يمكن للأعضاء الإبلاغ عن الرسائل إلى المشرفين. + يمكن للأعضاء الإبلاغ عن الرسائل إلى المُشرفين. سيتم أرشفة كافة البلاغات لك. أرشفة %d بلاغ؟ يُمنع الإبلاغ عن الرسائل في هذه المجموعة. @@ -2343,7 +2350,7 @@ سيتم عرض رسائل من هؤلاء الأعضاء! لا يمكن قراءة عبارة المرور في Keystore، يُرجى إدخالها يدويًا. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطوِّرين. سيتم إزالة الأعضاء من المجموعة - لا يمكن التراجع عن هذا! - المشرفين + المُشرفين لا يمكن قراءة عبارة المرور في Keystore. قد يكون هذا قد حدث بعد تحديث النظام غير متوافق مع التطبيق. إذا لم يكن الأمر كذلك، فيُرجى التواصل مع المطوِّرين. موافقة الانتظار سياسة الخصوصية وشروط الاستخدام. @@ -2364,7 +2371,7 @@ %d دردشة/ات %d دردشات مع الأعضاء %d رسائل - أُرسِل البلاغ للمشرفين + أُرسِل البلاغ للمُشرفين يمكنك عرض تقاريرك في \"دردش مع المُدراء\". اقبل كمراقب حُذفت جهة الاتصال @@ -2375,7 +2382,7 @@ قبلت %1$s قبِلك لقد قبلت هذا العضو. - الرجاء الانتظار ريثما يراجع مشرفو المجموعة طلبك للانضمام إليها. + الرجاء الانتظار ريثما يراجع مُشرفو المجموعة طلبك للانضمام إليها. دردش مع المُدراء راجع الأعضاء غير مفعّل @@ -2445,7 +2452,7 @@ اتصل بشكل أسرع! 🚀 تقليل حركة البيانات على شبكات الجوّال. راسل فورًا بمجرد النقر على \"اتصل\". - دور جديد للمجموعة: مشرف + دور جديد للمجموعة: مُشرف لا توجد جلسة توجيه خاصة انتهت مهلة التوجيه الخاص انتهت مهلة خلفية البروتوكول @@ -2746,7 +2753,7 @@ يمكن للمشتركين إضافة ردود الفعل على الرسائل. يمكن للمشتركين الدردشة مع المُدراء. يمكن للمشتركين حذف الرسائل المُرسلة نهائيًا. (24 ساعة) - يمكن للمشتركين الإبلاغ عن الرسائل للمشرفين. + يمكن للمشتركين الإبلاغ عن الرسائل للمُشرفين. يمكن للمشتركين إرسال رسائل مباشرة. يمكن للمشتركين إرسال رسائل تختفي. يمكن للمشتركين إرسال الملفات والوسائط. @@ -2878,7 +2885,7 @@ انضم للقناة %s توقيع الرسالة ليس إلزاميًا. مطلوب توقيع الرسالة. - سجِّل اسم اختبار + كيفية تسجيل اسم اختبار أزِل الاسم تطلب توقيع الرسائل. احفظ اسم SimpleX؟ @@ -2902,8 +2909,8 @@ أنشئ معاينة الويب. أسهل في القراءة. أدِر مُرحلاتك. - أسماء SimpleX (تجريبي) - أسماء لقناتك أو لشركتك. + أسماء SimpleX العامة (تجريبي) + الأسماء العامة لقناتك أو لشركتك. خوادم الملفات خوادم الملفات: %s diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 7950e7cc1c..45f63d4cf9 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -20,6 +20,11 @@ Open new chat Open group Open new group + You are an observer + You are a member + You are a moderator + You are an admin + You are an owner Invalid link Please check that SimpleX link is correct. @@ -65,8 +70,9 @@ LIVE moderated forwarded + forwarded from saved - saved from %s + saved from invalid chat invalid data error showing message @@ -1206,6 +1212,7 @@ Stop sharing address? Stop sharing Auto-accept + Group invitations Sent to your contact after connection. Welcome message Enter welcome message… (optional) @@ -1557,7 +1564,7 @@ If you enter this passcode when opening the app, all app data will be irreversibly removed! Set passcode This setting is for your current profile - These settings are for your current profile + These settings are for your current profile They can be overridden in contact and group settings. Contacts Enable receipts? @@ -1602,7 +1609,7 @@ Chats Files Send delivery receipts to - Contact requests from groups + Contact requests in groups About Contact Support the project @@ -2736,6 +2743,10 @@ - opt-in to send link previews.\n- use SOCKS proxy if enabled.\n- prevent hyperlink phishing.\n- remove link tracking. Non-profit governance To make SimpleX Network last. + You can now invest in SimpleX Chat! 🚀 + Crowdfunding on Wefunder. + Crowdfunding on Wefunder + Learn more on Wefunder SimpleX public names (BETA) Public names for your channel or business. Better channels 📢 @@ -3165,6 +3176,8 @@ This is a chat relay address, it cannot be used to connect. Open channel Open new channel + You are a subscriber + You are a contributor Your channel %1$s!]]> Error opening channel 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 9676bc0199..c79aa07a1e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bg/strings.xml @@ -481,7 +481,7 @@ Въведи правилна парола. активирано за вас Въведи парола в търсенето - Тези настройки са за текущия ви профил + Тези настройки са за текущия ви профил Те могат да бъдат променени в настройките за всеки контакт и група. Инструменти за разработчици Деактивиране за всички @@ -1301,6 +1301,11 @@ Отключи Ще трябва да се идентифицирате, когато стартирате или възобновите приложението след 30 секунди във фонов режим. вие сте наблюдател + Вие сте наблюдател + Вие сте член + Вие сте модератор + Вие сте админ + Вие сте собственик Видео се свържете с разработчиците на SimpleX Chat, за да задавате въпроси и да получавате актуализации;.]]> иска да се свърже с вас! @@ -1723,7 +1728,7 @@ Препращане и запазване на съобщения Звуци по време на разговор запазено - запазено от %s + запазено от Запазено Запазено от Получателят(ите) не могат да видят от кого е това съобщение. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml index bb448339bd..dfcfb1685c 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/bn/strings.xml @@ -23,6 +23,7 @@ কলটি গৃহীত হয়েছে পূর্বনির্ধারিত সার্ভারগুলি যুক্ত করুন অ্যাডমিন + আপনি একজন অ্যাডমিন স্বাগত বার্তা যুক্ত করুন প্রোফাইল যুক্ত করুন আনুষঙ্গিক রং 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 ce38afb836..3d49c533ff 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ca/strings.xml @@ -1573,7 +1573,7 @@ Notes privades la recepció de fitxers encara no està suportada sol·licitada connexió - desat des de %s + desat des de Adreça de contacte SimpleX Enllaç de grup SimpleX Enllaços SimpleX @@ -1715,6 +1715,11 @@ La imatge no es pot descodificar. Si us plau, proveu amb una imatge diferent o contacteu amb els desenvolupadors. El vídeo no es pot descodificar. Si us plau, prova amb un vídeo diferent o contacta amb els desenvolupadors. ets observador + Ets observador + Ets membre + Ets moderador + Ets administrador + Ets propietari ets observador(a) Poseu-vos en contacte amb l\'administrador del grup. Només els propietaris del grup poden activar fitxers i mitjans. @@ -1946,7 +1951,7 @@ Si introduïu aquesta contrasenya en obrir l\'aplicació, totes les dades de l\'aplicació s\'eliminaran de manera irreversible. Si introduïu el vostre codi d\'autodestrucció mentre obriu l\'aplicació: Estableix codi - Aquesta configuració és per al vostre perfil actual + Aquesta configuració és per al vostre perfil actual Es pot canviar a la configuració de contacte i grup. No Configuració 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 5160d28feb..16241e07e2 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/cs/strings.xml @@ -931,6 +931,11 @@ Moderovat Kontaktujte prosím správce skupiny. jste pozorovatel + Jste pozorovatel + Jste člen + Jste moderátor + Jste správce + Jste vlastník pozorovatel Zpráva bude smazána pro všechny členy. Zpráva bude pro všechny členy označena jako moderovaná. @@ -1246,7 +1251,7 @@ vyžadováno opětovné vyjednávání šifrování pro %s Odesílání potvrzení o doručení je vypnuto pro %d kontakty. Odesílání potvrzení o doručení bude povoleno pro všechny kontakty ve všech viditelných profilech chatu. - Toto nastavení je pro váš aktuální profil + Toto nastavení je pro váš aktuální profil opětovné vyjednávání šifrování povoleno opětovné vyjednávání šifrování povoleno pro %s vyžadováno opětovné vyjednávání šifrování @@ -1693,7 +1698,7 @@ Spolehlivější síťové připojení. Povolit odesílat SimpleX odkazy. uloženo - Uloženo z %s + Uloženo z Uloženo Přeposláno Uloženo z 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 f23a95defb..82f8ae5770 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/da/strings.xml @@ -168,7 +168,7 @@ Åben gruppe Åbn ny gruppe Ugyldigt link - Kontroller at SimpleX-linket er korrekt. + Kontroller, at SimpleX-linket er korrekt. Åbner databasen… Databasemigrering er i gang.\nDet kan tage et par minutter. Ugyldig filsti @@ -201,7 +201,7 @@ modereret videresendt gemt - gemt fra %s + gemt fra ugyldig chat ugyldige data fejl ved visning af besked @@ -635,6 +635,8 @@ du forlod kan ikke sende beskeder du er observatør + Du er observatør + Du er administrator gennemgået af administratorer medlemmet har en gammel version Billede @@ -882,4 +884,64 @@ Appen kører allerede App\'en skal opdateres Virksomhedsadresse + Deltag i kanal %s + Forbind til %s + En anden instans af appen kører eller blev ikke lukket korrekt. Start alligevel? + %1$d ejere og bidragsydere + %1$d relays fejlede + %1$d relays ikke aktive + %1$d relays fjernet + %1$d abonnent + %1$d abonnenter + %1$s støttede SimpleX Chat. Mærket udløb den %2$s. + Om + accepteret + Tilføj bidragsydere. + Tilføj beskrivelse + Et link til at en enkelt person kan forbinde + Alle beskeder + Beskeder + Beskeder og filer + Tillad medlemmer at chatte med admins. + Tillad at sende direkte beskeder til abonnenter. + Tillad abonnenter at chatte med admins. + Alle relays fejlede + Alle relays fjernet + Enhver hjemmeside kan vise forhåndsvisningen. + Mærke kan ikke bekræftes + Vær fri\ni dit netværk + Vær fri i dit netværk. + Bedre kanaler 📢 + Blokér abonnent for alle? + Værktøjslinje nederst + intet abonnement + Du er ikke forbundet til den server, der bruges til at modtage meddelelser fra denne forbindelse (intet abonnement). + Stemmeoptagelse er ikke understøttet på din platform + ikke ende-til-ende-krypteret. Et chat-relay kan se disse beskeder.]]> + SimpleX relay-adresse + Ingen chat-relays aktiveret. + Ingen servere til at slå navne op. + Server-advarsel + Fingeraftrykket i serveradressen matcher ikke certifikatet: %1$s. + Fingeraftrykket i destinations-serveradressen matcher ikke certifikatet: %1$s. + Fejl ved markering som læst + Ikke-understøttet navn på kanal + Ikke-understøttet navn på kontakt + Forbindelse gennem kanal-navnet kræver en nyere version af appen. + Forbindelse gennem kontakt-navnet kræver en nyere version af appen. + Opgrader appen. + Fejl i SimpleX-navn + Ingen af dine servere er sat op til at slå SimpleX-navne op. Konfigurer serverne, eller anvend et forbindelseslink. + Navn ikke fundet + Dette SimpleX-navn er ikke registreret. Kontrollér navnet. + Intet gyldigt link + SimpleX-navnet %1$s er registreret, men det har ikke et gyldigt link. + Ubekræftet navn + SimpleX-navnet %1$s er registreret, men ikke tilføjet til profil. Tilføj det til din adresse eller kanal-profil, hvis du er indehaveren. + Kanal midlertidigt utilgængelig + Kanalen har ingen aktive relays. Vent og prøv igen senere. + Denne gruppe kræver en nyere version af appen. Opdater appen for at kunne deltage. + Fejl ved sletning af meddelelse + Gem SimpleX-navn? + Anskaf SimpleX-navn (BETA) 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 9774107fcf..26ddf2107d 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/de/strings.xml @@ -1012,6 +1012,13 @@ Moderieren Diese Nachricht wird für alle Mitglieder als moderiert gekennzeichnet. Sie sind Beobachter + Sie sind Beobachter + Sie sind Mitglied + Sie sind Moderator + Sie sind Admin + Sie sind Eigentümer + Sie sind Abonnent + Sie sind Mitwirkender Sie sind Beobachter Beobachter Anfängliche Rolle @@ -1364,7 +1371,7 @@ Nach ungelesenen und favorisierten Chats filtern. Das Senden von Empfangsbestätigungen an alle Kontakte in allen sichtbaren Chat-Profilen wird aktiviert. Das Senden von Bestätigungen an %d Kontakte ist deaktiviert - Diese Einstellungen gelten für Ihr aktuelles Chat-Profil + Diese Einstellungen gelten für Ihr aktuelles Chat-Profil Sie können in den Kontakt- und Gruppeneinstellungen überschrieben werden. Kontakte Bestätigungen deaktivieren\? @@ -1805,7 +1812,7 @@ Kopfhörer Gelijktijdige ontvangst Empfänger können nicht sehen, von wem die Nachricht stammt. - abgespeichert von %s + abgespeichert von Abgespeichert abgespeichert Weitergeleitet @@ -2812,7 +2819,7 @@ Wir haben das Verbinden für neue Nutzer vereinfacht. Ihre öffentliche Adresse Sie wurden ohne ein Benutzerkonto geboren. - Niemand verfolgte Ihre Gespräche. Niemand erstellte eine Karte, wo Sie sich aufgehalten haben. Privatsphäre war nie ein Feature - sie war selbstverständlich. + Niemand verfolgte Ihre Gespräche. Niemand hat eine Karte erstellt, wo Sie überall waren. Privatsphäre war nie ein Feature – sie war eine Selbstverständlichkeit. Dann sind wir online gegangen, und jede Plattform wollte Etwas von Ihnen - Ihren Namen, Ihre Nummer, Ihre Freunde. Wir akzeptierten, dass es der Preis mit Anderen zu kommunizieren ist, Jemandem preiszugeben, mit wem und wie wir miteinander kommunizieren. Jede Generation, Menschen und Technologien, kannten es nur so - Telefon, E-Mail, Messenger, soziale Medien. Es schien der einzig mögliche Weg zu sein. Es gibt einen anderen Weg. Ein Netzwerk ohne Telefonnummern, ohne Benutzerkonten, ohne Benutzerkennungen und ohne jegliche Benutzeridentität. Ein Netzwerk, welches Menschen verbindet und verschlüsselte Nachrichten überträgt, ohne zu wissen, wer mit wem verbunden ist. Nicht ein besseres Schloss an der Tür eines Anderen. Kein freundlicher Vermieter, der Ihre Privatsphäre respektiert, aber dennoch jeden Besucher registriert. Sie sind kein Gast. Sie sind zu Hause. Kein Vermieter, kein Fremder kann es betreten - Sie sind souverän. @@ -2959,7 +2966,7 @@ Fehler beim SimpleX-Namen SimpleX-Name ist nicht verifiziert Der SimpleX-Name %1$s wurde registriert, aber er hat keinen gültigen Link. - Der SimpleX‑Name %1$s wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte zu Ihrer Adresse oder zum Kanalprofil hinzufügen, sofern Sie der Besitzer sind. + Der SimpleX‑Name %1$s wurde registriert, jedoch nicht in Ihrem Profil hinterlegt. Bitte fügen Sie ihn zu Ihrer Adresse oder zum Kanalprofil hinzu, sofern Sie der Besitzer sind. Der SimpleX‑Name %1$s wurde ohne Kanal‑Link registriert. Fügen Sie den Kanal‑Link über die Registrierungsseite hinzu. Der SimpleX‑Name %1$s wurde ohne SimpleX-Adresse registriert. Fügen Sie die SimpleX-Adresse über die Registrierungsseite hinzu. Dieser SimpleX-Name wurde nicht registriert. Bitte überprüfen Sie den Namen. @@ -2984,7 +2991,7 @@ Nachrichten signieren Der Kanal verlangt für diese Nachricht eine Signatur, welche aber fehlt. Im Kanal genutzter SimpleX-Name - SimpleX-Name erhalten (BETA) + Einen SimpleX-Namen erhalten (BETA) Wie man einen Test-Namen registriert Name entfernen SimpleX-Name speichern? 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 390913c5f7..73abd7d876 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/el/strings.xml @@ -1350,7 +1350,7 @@ Ο αποστολέας ΔΕΝ θα ειδοποιηθεί. Οι διακομιστές για τις νέες συνδέσεις του τρέχοντος προφίλ συνομιλίας σου Οι διακομιστές για τα νέα αρχεία του τρέχοντος προφίλ συνομιλίας σου - Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου + Αυτές οι ρυθμίσεις ισχύουν για το τρέχον προφίλ σου Το κείμενο που επικόλλησες δεν είναι σύνδεσμος SimpleX. Το αρχείο της βάσης δεδομένων που μεταφορτώθηκε, θα διαγραφεί οριστικά από τους διακομιστές. Το βίντεο δεν μπορεί να αποκωδικοποιηθεί. Δοκίμασε ένα άλλο βίντεο ή επικοινώνησε με τους προγραμματιστές. @@ -2051,7 +2051,7 @@ αποθηκευμένο Αποθηκευμένο Αποθηκευμένο από - αποθηκευμένο από %s + αποθηκευμένο από Αποθηκευμένο μήνυμα Οι αποθηκευμένοι διακομιστές WebRTC ICE θα αφαιρεθούν. Αποθήκευση προφίλ ομάδας @@ -2429,6 +2429,11 @@ Δεν είσαι συνδεδεμένος στον διακομιστή που χρησιμοποιείται για τη λήψη μηνυμάτων από αυτή τη σύνδεση (δεν υπάρχει συνδρομή). Δεν είσαι συνδεδεμένος σε αυτούς τους διακομιστές. Για την παράδοση μηνυμάτων σε αυτούς, χρησιμοποιείται ιδιωτική δρομολόγηση. είσαι παρατηρητής + Είσαι παρατηρητής + Είσαι μέλος + Είσαι διαχειριστής + Είσαι διαχειριστής + Είσαι ιδιοκτήτης είσαι παρατηρητής μπλόκαρες %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 4e3ce8e810..4e63f2c76a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/es/strings.xml @@ -1283,7 +1283,7 @@ El envío de confirmaciones está desactivado para %d contactos El envío de confirmaciones está activado para %d contactos Enviar confirmaciones - Esta configuración afecta a tu perfil actual + Esta configuración afecta a tu perfil actual Activar ¿Desactivar confirmaciones\? ¿Activar confirmaciones\? @@ -1724,7 +1724,7 @@ todos los miembros Se permite enviar enlaces SimpleX. guardado - guardado desde %s + guardado desde Guardado Guardado desde Reenviado por @@ -2665,6 +2665,13 @@ Espera respuesta eres suscriptor + Eres observador + Eres miembro + Eres moderador + Eres administrador + Eres propietario + Eres suscriptor + Eres colaborador Puedes compartir el enlace o código QR. Cualquiera podrá unirse al canal. Te conectaste al canal mediante este enlace de servidor. Tu canal 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 263b36d404..b65fc5ab93 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fa/strings.xml @@ -156,7 +156,7 @@ اجازه دهید تا اعلان‌ها را فوری دریافت کنید.]]> SimpleX در پس‌زمینه اجرا می‌شود و به جای استفاده از پوش نوتیفیکیشن، کار می‌کند.]]> ذخیره شده - ذخیره شده از %s + ذخیره شده از ذخیره شده از فرستاده شده رمزنگاری انتها به انتها با محرمانگی پیشرو، مردودسازی و بازیابی ورود غیرمجاز محافظت شده‌اند.]]> @@ -437,6 +437,11 @@ تایید شما ممکن نیست؛ لطفا دوباره امتحان کنید. فایل شما ناظر هستید + شما ناظر هستید + شما عضو هستید + شما مدیر هستید + شما مدیر هستید + شما صاحب هستید چت پاک شود؟ تمام پیام‌ها حذف خواهند شد - این عمل قابل برگشت نیست! حذف @@ -756,7 +761,7 @@ نام نمایشی جدید: اگر کد عبور خودتخریبی خود را زمان باز کردن برنامه وارد کنید: تمام اطلاعات برنامه حذف می‌شود. - این تنظیمات برای پروفایل فعلی شما هستند + این تنظیمات برای پروفایل فعلی شما هستند ارسال رسید برای %d مخاطب فعال است غیرفعال برای همه فعال برای همه گروه‌ها 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 f3c7a95ef4..e5e7c525a5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fi/strings.xml @@ -1169,6 +1169,10 @@ SimpleX-taustapalvelu – se kuluttaa muutaman prosentin akusta päivässä.]]> Avaa olet tarkkailija + Olet tarkkailija + Olet jäsen + Olet ylläpitäjä + Olet omistaja Liikaa videoita! Ääniviesti Odottaa videota @@ -1272,7 +1276,7 @@ Uudelleenneuvottele Uudelleenneuvottele salaus\? Salaus toimii ja uutta salaussopimusta ei tarvita. Tämä voi johtaa yhteysvirheisiin! - Nämä asetukset koskevat nykyistä profiiliasi + Nämä asetukset koskevat nykyistä profiiliasi Ne voidaan ohittaa kontakti- ja ryhmäasetuksissa. salaus ok salauksen uudelleenneuvottelu sallittu 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 845fe28404..1997a54dca 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/fr/strings.xml @@ -33,13 +33,13 @@ Ouvrir le lien dans le navigateur peut réduire la confidentialité et la sécurité de la connexion. Les liens SimpleX non fiables seront en rouge. Vérifiez votre connexion réseau avec %1$s et réessayez. Erreur lors de la réception du fichier - L\'expéditeur a peut-être supprimé la demande de connexion. - Vous êtes connecté·e au serveur utilisé pour recevoir les messages de ce contact. + L\'expéditeur a supprimé la demande de connexion. + Vous êtes connecté·e au serveur utilisé pour recevoir les messages de cette connexion. l\'envoi de fichiers n\'est pas encore supporté vous mode incognito via le lien de groupe Adresse de contact SimpleX - Tentative de connexion au serveur utilisé pour recevoir les messages de ce contact. + Tentative de connexion au serveur utilisé pour recevoir les messages de cette connexion. la réception de fichiers n\'est pas encore supportée connexion %1$d vous avez partagé un lien unique @@ -62,14 +62,14 @@ Erreur lors de l\'envoi du message Vous êtes déjà connecté à %1$s. Erreur de connexion - A moins que votre contact ait supprimé la connexion ou que ce lien ait déjà été utilisé, il peut s\'agir d\'un bug - veuillez le signaler. \nPour vous connecter, veuillez demander à votre contact de créer un autre lien de connexion et vérifiez que vous disposez d\'une connexion réseau stable. + Votre contact a supprimé ce lien, ou il s\'agissait d\'un lien à usage unique déjà utilisé.\nPour vous connecter, demandez à votre contact de créer un nouveau lien. Erreur de validation de la demande de contact Erreur lors de la suppression du groupe Erreur lors de la suppression du contact Erreur lors de la suppression de la connexion en attente Erreur de changement d\'adresse Échec du test à l\'étape %s. - Il est possible que l\'empreinte du certificat dans l\'adresse du serveur soit incorrecte + L\'empreinte numérique de l\'adresse du serveur ne correspond pas au certificat. Se connecter Créer une file d\'attente File d\'attente sécurisée @@ -103,7 +103,7 @@ Désactiver SimpleX Lock Authentification indisponible L\'authentification de l\'appareil est désactivée. Désactivation de SimpleX Lock. - Ouvrir la console du chat + Ouvrir la console de messagerie Erreur de distribution du message Il est fort probable que ce contact ait supprimé la connexion avec vous. Répondre @@ -117,7 +117,7 @@ Autoriser Supprimer le message \? Supprimer pour moi - Discussions + Conversations Texte du message Caché Pour protéger vos informations, activez la fonction SimpleX Lock. @@ -130,8 +130,8 @@ Lancer périodiquement Exécuter lorsque l’app est ouverte Toujours activé - Échec du chargement du chat - Échec du chargement des discussions + Échec du chargement de la conversation + Échec du chargement des conversations Veuillez mettre à jour l’app et contacter les développeurs. Récupération des messages… Aperçu de notification @@ -139,11 +139,11 @@ Pour recevoir des notifications, veuillez entrer la phrase secrète de la base de données service SimpleX Chat Ce texte est disponible dans les paramètres - rejoindre en tant que %s - vous êtes invité·e au groupe + Rejoindre en tant que %s + Vous êtes invité·e au groupe Discuter avec les développeurs - Appuyez ici pour démarrer une nouvelle discussion - Vous n\'avez aucune discussion + Appuyez ici pour démarrer une nouvelle conversation + Vous n\'avez aucune conversation Trop d’images ! Partager le fichier… Joindre @@ -194,25 +194,25 @@ Lien d\'invitation unique Copié dans le presse-papiers Créer un lien d\'invitation unique - Commencer une nouvelle discussion + Commencer une nouvelle conversation Se connecter via un lien / code QR - Scanner un code QR + Scanner un QR code (à partager avec votre contact) Créer un groupe secret Depuis la Phototèque Fichier Image Vidéo - Pour démarrer une nouvelle discussion + Pour démarrer une nouvelle conversation Appuyez sur le bouton Pour se connecter via un lien Si vous avez reçu un lien d\'invitation SimpleX Chat, vous pouvez l\'ouvrir dans votre navigateur : - Scanner le code QR.]]> + Scanner le QR code.]]> Ouvrir dans l\'application, puis appuyez sur se connecter dans l\'app.]]> Accepter en incognito Rejeter Effacer la conversation \? - Tous les messages seront supprimés - impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. + Tous les messages seront supprimés : impossible de revenir en arrière ! Les messages seront supprimés UNIQUEMENT pour vous. Effacer Supprimer Supprimer @@ -238,8 +238,7 @@ Ce code QR n\'est pas un lien ! Vous serez connecté·e lorsque votre demande de connexion sera acceptée, veuillez attendre ou vérifier plus tard ! Vous serez connecté·e lorsque l\'appareil de votre contact sera en ligne, veuillez attendre ou vérifier plus tard ! - Votre profil de chat sera envoyé -\nà votre contact + Votre profil de messagerie sera envoyé\nà votre contact Partager un lien unique Coller Cette chaîne n\'est pas un lien de connexion ! @@ -250,7 +249,7 @@ En attente Accepter la demande de connexion \? Effacer - Effacer le chat + Effacer la conversation Se connecter via un lien Retirer la vérification Lien d\'invitation unique @@ -269,7 +268,7 @@ \nVous pouvez annuler la connexion et supprimer le contact (et réessayer plus tard avec un autre lien). veut établir une connexion ! image de profil (placeholder) - Code QR + QR Code Logo SimpleX E-mail Se connecter @@ -279,7 +278,7 @@ Masquer le contact et le message Connectez-vous en utilisant votre identifiant Confirmez vos identifiants - Arrêter le chat + Arrêter la messagerie Le message sera supprimé - impossible de revenir en arrière ! Le message sera marqué comme supprimé. Le·s destinataire·s pourrai·ent révéler ce message. modifié @@ -290,13 +289,13 @@ Message vocal Autorisation refusée ! Appareil\nphoto - Merci d\'avoir installé SimpleX Chat ! + Merci d\'avoir installé SimpleX Chat ! vous connecter aux développeurs de SimpleX Chat pour leur poser des questions et recevoir des réponses :.]]> ci-dessus, puis : Si vous choisissez de la rejeter, l\'expéditeur·rice NE sera PAS notifié·e. Accepter - Muet - Démute + Mettre en sourdine + Réactiver le son Le contact avec lequel vous avez partagé ce lien NE pourra PAS se connecter ! Lien invalide ! Ce lien n\'est pas un lien de connexion valide ! @@ -311,7 +310,7 @@ montrez le code QR lors d\'un appel vidéo, ou partagez le lien.]]> Scannez le code de sécurité depuis l\'application de votre contact. Pour vérifier le chiffrement de bout en bout avec votre contact, comparez (ou scannez) le code sur vos appareils. - scanner un code QR lors d\'un appel vidéo, ou votre contact peut partager un lien d\'invitation.]]> + scanner un QR code lors d\'un appel vidéo, ou votre contact peut partager un lien d\'invitation.]]> Ajouter un serveur Markdown dans les messages Ajouter des serveurs prédéfinis @@ -322,11 +321,11 @@ Utiliser les hôtes .onions Vos paramètres SimpleX Lock - Console du chat + Console de la messagerie Serveurs SMP Tester les serveurs Enregistrer les serveurs - Scanner un code QR de serveur + Scanner un QR code de serveur Utiliser ce serveur Utiliser pour les nouvelles connexions Ajouter à un autre appareil @@ -349,7 +348,7 @@ Les hôtes .onion seront utilisés lorsqu\'ils sont disponibles. Apparence Créer une adresse - Votre profil de chat + Votre profil actuel Modifier l\'image Enregistrer et en informer les contacts Enregistrer et en informer les membres du groupe @@ -371,7 +370,7 @@ N\'importe qui peut heberger un serveur. Pour protéger votre vie privée, SimpleX utilise des identifiants distincts pour chacun de vos contacts. Collez le lien que vous avez reçu - Utiliser le chat + Utiliser la messagerie Notifications privées Comment il affecte la batterie Quand l\'application fonctionne @@ -405,7 +404,7 @@ Votre adresse de serveur Adresse de serveur invalide ! Vérifiez l\'adresse du serveur et réessayez. - Vous utilisez les serveurs SimpleX. + Vous utilisez les serveurs SimpleX Chat. Comment faire Serveurs ICE (un par ligne) Erreur lors de la sauvegarde des serveurs ICE @@ -422,7 +421,7 @@ Votre profil est stocké sur votre appareil et partagé uniquement avec vos contacts. Les serveurs SimpleX ne peuvent pas voir votre profil. Supprimer l\'image Enregistrer les préférences ? - Vous maîtrisez vos discussions ! + Vous contrôlez votre messagerie ! La plateforme de messagerie et d\'applications qui protège votre vie privée et votre sécurité. Nous ne stockons aucun de vos contacts ou messages (une fois délivrés) sur les serveurs. Créer le profil @@ -453,16 +452,13 @@ %1$d message(s) manqué(s) ID du message incorrect 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. -\n3. La connexion a été compromise. + Cela peut arriver lorsque :\n1. Les messages ont expiré sur le client d\'envoi 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 la base de données.\n3. La connexion a été compromise. Appel rejeté a retiré %1$s vous avez retiré %1$s invité par votre lien de groupe vous avez changé d\'adresse - Arrêtez le chat pour exporter, importer ou supprimer la base de données du chat. Vous ne pourrez pas recevoir et envoyer de messages pendant que le chat est arrêté. + Arrêtez la messagerie pour exporter, importer ou supprimer sa base de données. Vous ne pourrez pas recevoir ni envoyer de messages tant que la messagerie est arrêtée. Cette action ne peut être annulée - les messages envoyés et reçus avant la date sélectionnée seront supprimés. Cela peut prendre plusieurs minutes. La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire, que vous pouvez modifier. Restaurer la sauvegarde de la base de données @@ -487,13 +483,13 @@ Appel terminé Votre vie privée Appareil - Discussions + Conversations Outils du développeur Icone de l\'app - Votre base de données de chat - Lancer le chat - Arrêter le chat \? - Redémarrez l\'application pour utiliser la base de données de chat importée. + Votre base de données de messagerie + Lancer la messagerie + Arrêter la messagerie ? + Redémarrez l\'application pour utiliser la base de données importée. 1 jour Supprimer les messages Enregistrer la phrase secrète dans le Keystore @@ -502,10 +498,10 @@ Mise à jour Chiffrer Veuillez entrer la phrase secrète actuelle correcte. - Votre base de données de chat n\'est pas chiffrée - définissez une phrase secrète pour la protéger. + La base de données de votre messagerie n\'est pas chiffrée : définissez une phrase secrète pour la protéger. Veuillez noter : vous NE pourrez PAS récupérer ou modifier la phrase secrète si vous la perdez.]]> Le Keystore d\'Android sera utilisé pour stocker en toute sécurité la phrase secrète après sa modification ou redémarrage de l\'app - cela permettra de recevoir les notifications. - Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PAS accéder au chat si vous la perdez. + Veuillez conserver votre phrase secrète en lieu sûr, vous NE pourrez PLUS accéder à la messagerie si vous la perdez. La phrase secrète de la base de données est différente de celle enregistrée dans le Keystore. Erreur inconnue Entrez la phrase secrète correcte. @@ -529,12 +525,12 @@ Nouvelle archive de base de données Archives de l\'ancienne base de données Supprimer la base de données - Erreur lors du démarrage du chat + Erreur lors du démarrage de la messagerie Importer - Cette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. - Base de données du chat supprimée - Redémarrez l\'application pour créer un nouveau profil de chat. - Vous devez utiliser la version la plus récente de votre base de données de chat sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts. + Cette action ne peut être annulée : votre profil, vos contacts, vos messages et vos fichiers seront irréversiblement perdus. + Base de données de la messagerie supprimée + Redémarrez l\'application pour créer un nouveau profil de messagerie. + Vous devez utiliser la version la plus récente de votre base de données de messagerie sur un seul appareil UNIQUEMENT, sinon vous risquez de ne plus recevoir les messages de certains contacts. %d fichier·s avec une taille totale de %s jamais 1 semaine @@ -550,7 +546,7 @@ Erreur de base de données inconnue : %s Mauvaise phrase secrète ! Quitter le groupe \? - Vous ne recevrez plus de messages de ce groupe. L\'historique du chat sera conservé. + Vous ne recevrez plus de messages de ce groupe. L\'historique de la conversation sera conservé. Inviter des membres Groupe inactif Invitation expirée ! @@ -562,12 +558,11 @@ a quitté Haut-parleur ON Aperçu des liens - Erreur lors de la suppression de la base de données du chat - Erreur lors de l\'arrêt du chat - Erreur lors de l\'exportation de la base de données du chat - Importer la base de données du chat \? - Votre base de données de chat actuelle sera SUPPRIMÉE et REMPLACÉE par celle qui a été importée. -\nCette action ne peut être annulée - votre profil, vos contacts, vos messages et vos fichiers seront irrémédiablement perdus. + Erreur lors de la suppression de la base de données de la messagerie + Erreur lors de l\'arrêt de la messagerie + Erreur lors de l\'exportation de la base de données de la messagerie + Importer la base de données de la messagerie ? + Votre base de données actuelle sera SUPPRIMÉE et REMPLACÉE par celle importée.\nCette action est irréversible : votre profil, vos contacts, vos messages et vos fichiers seront définitivement perdus. Entrez la phrase secrète… Appel audio entrant %1$s veut se connecter à vous via @@ -580,7 +575,7 @@ Serveurs WebRTC ICE Le serveur relais protège votre adresse IP, mais il peut observer la durée de l\'appel. Le serveur relais n\'est utilisé que si nécessaire. Un tiers peut observer votre adresse IP. - Ouvrez SimpleX Chat pour décrocher + Ouvrez SimpleX Chat pour accepter l\'appel sans chiffrement de bout en bout Ce contact a le chiffrement de bout en bout Ce contact n\'a pas le chiffrement de bout en bout @@ -604,28 +599,28 @@ 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 + La messagerie est en cours d\'exécution + La messagerie est arrêtée + Base de données de la messagerie Phrase secrète de la base de données Exporter la base de données Arrêter Définir la phrase secrète pour l\'export La base de données est chiffrée à l\'aide d\'une phrase secrète aléatoire. Veuillez la changer avant d\'exporter. - Erreur lors de l\'importation de la base de données du chat - Base de données du chat importée - Supprimer le profil du chat \? + Erreur lors de l\'importation de la base de données de la messagerie + Base de données de la messagerie importée + Supprimer le profil de messagerie ? Supprimer les fichiers et médias \? Cette action ne peut être annulée - tous les fichiers et médias reçus et envoyés seront supprimés. Les photos à faible résolution seront conservées. Aucun fichier reçu ou envoyé 1 mois - %s seconde·s + %s seconde(s) Supprimer les messages après Activer la suppression automatique des messages \? Erreur de changement de paramètre Retirer la phrase secrète du Keystore \? Les notifications seront délivrées jusqu\'à ce que l\'application s\'arrête ! - Supprimer + Retirer Phrase secrète actuelle… Nouvelle phrase secrète… Confirmer la nouvelle phrase secrète… @@ -637,13 +632,13 @@ La base de données sera chiffrée. Erreur de la keychain Fichier : %s - La phrase secrète de la base de données est nécessaire pour ouvrir le chat. - Enregistrer la phrase secrète et ouvrir le chat - Ouvrir le chat + La phrase secrète de la base de données est nécessaire pour ouvrir la messagerie. + Enregistrer la phrase secrète et ouvrir la messagerie + Ouvrir la messagerie La tentative de modification de la phrase secrète de la base de données n\'a pas abouti. Restaurer la sauvegarde de la base de données \? Restaurer - Le chat est arrêté + La messagerie est arrêtée Vous pouvez lancer le chat via les Paramètres / la Base de données de l\'app ou en la redémarrant. Invitation au groupe %1$s Rejoindre le groupe \? @@ -692,7 +687,7 @@ Lien du groupe Créer un lien Modifier le profil du groupe - Supprimer + Retirer Membre Message dynamique ! Envoyer un message dynamique @@ -708,12 +703,12 @@ 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 - Changer le rôle du groupe ? + Changer le rôle ? Son rôle est désormais %s. Tous les membres du groupe en seront informés. Contact vérifié⸱e Effacer %d contact·s sélectionné·e·s - Passer l’invitation de membres + Ignorer l’invitation de membres Sélectionnez des contacts Aucun contact sélectionné %1$s MEMBRES @@ -757,7 +752,7 @@ État du réseau Changer d\'adresse de réception Créer un groupe secret - Votre profil de chat sera envoyé aux membres du groupe + Votre profil de messagerie sera envoyé aux membres du groupe Activer le TCP keep-alive Enregistrer Mettre à jour les paramètres réseau \? @@ -878,7 +873,7 @@ Configuration de serveur améliorée Ajoutez des serveurs en scannant des codes QR. données invalides - chat invalide + conversation invalide Annuler le message dynamique propose %s propose %s : %2s @@ -891,34 +886,34 @@ Nombre de PING Toutes les discussions et tous les messages seront supprimés - il est impossible de revenir en arrière ! Effacer tous les fichiers - Supprimer le profil de chat pour - pour chaque profil de chat que vous avez dans l\'application.]]> - Supprimer le profil du chat \? + Supprimer le profil de messagerie pour + pour chaque profil de messagerie que vous avez dans l\'application.]]> + Supprimer le profil de messagerie ? Fichiers & médias Messages - Les serveurs pour les nouvelles connexions de votre profil de chat actuel - Ce paramètre s\'applique aux messages de votre profil de chat actuel + Les serveurs pour les nouvelles connexions de votre profil de messagerie actuel + Ce paramètre s\'applique aux messages de votre profil de messagerie actuel Connexion - Effacer les fichiers de tous les profils de chat + Effacer les fichiers de tous les profils de messagerie Profil et connexions au serveur Transport isolé Mettre à jour le mode d\'isolement du transport \? pour chaque contact et membre de groupe. \nVeuillez noter : si vous avez de nombreuses connexions, votre consommation de batterie et de réseau peut être nettement plus élevée et certaines liaisons peuvent échouer.]]> - Profil de chat + Profil de messagerie Ajouter un profil Données de profil local uniquement Erreur lors de la suppression du profil utilisateur - Vos profils de chat + Vos profils de messagerie Erreur lors du changement de profil ! Erreur lors de la création du profil ! - Vous avez déjà un profil de chat avec ce même nom affiché. Veuillez choisir un autre nom. + Vous avez déjà un profil de messagerie avec ce même nom affiché. Veuillez choisir un autre nom. Nom d\'affichage en double ! Interface en français - Par profil de discussion (par défaut) ou par connexion (BETA). + Par profil de messagerie (par défaut) ou par connexion (BETA). Interface en italien Brouillon de message D\'autres améliorations sont à venir ! - Profils de discussion multiples + Profils de messagerie multiples Conserver le brouillon du dernier message, avec les pièces jointes. Réduction de la consommation de batterie Noms de fichiers privés @@ -934,10 +929,17 @@ Le message sera supprimé pour tous les membres. Le message sera marqué comme modéré pour tous les membres. vous êtes observateur + Vous êtes observateur + Vous êtes membre + Vous êtes modérateur(trice) + Vous êtes admin + Vous êtes propriétaire + Vous êtes abonné + Vous êtes contributeur Erreur lors de la mise à jour du lien de groupe Rôle initial Veuillez contacter l\'administrateur du groupe. - Vous ne pouvez pas envoyer de messages ! + vous êtes observateur observateur Système Enregistrer les serveurs ? @@ -955,7 +957,7 @@ Erreur d\'enregistrement du mot de passe de l\'utilisateur Erreur de mise à jour de la confidentialité de l\'utilisateur Mot de passe de profil caché - Profils de chat cachés + Profils de messagerie cachés Désormais, les administrateurs peuvent : \n- supprimer les messages des membres. \n- désactiver des membres (rôle "observateur") @@ -966,11 +968,11 @@ Mot de passe à afficher Rendre un profil privé ! Mute - Protégez vos profils de chat par un mot de passe ! + Protégez vos profils de messagerie par un mot de passe ! Appuyez pour activer un profil. Enregistrer et mettre à jour le profil du groupe Enregistrer le mot de passe du profil - Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page Profils de chat. + Pour révéler votre profil caché, entrez le mot de passe dans le champ de recherche de la page Profils de messagerie. Prise en charge du Bluetooth et autres améliorations. Merci aux utilisateurs - contribuez via Weblate ! Dévoiler @@ -982,9 +984,9 @@ Rétrogradation de la base de données Mise à niveau de la base de données Version de la base de données incompatible - Rétrograder et ouvrir le chat + Rétrograder et ouvrir la messagerie Confirmation de migration invalide - Mettre à niveau et ouvrir le chat + Mettre à jour et ouvrir la messagerie Migrations : %s Attention : vous risquez de perdre des données ! Confirmer la mise à niveau de la base de données @@ -997,9 +999,9 @@ IDs de base de données et option d\'isolement du transport. Expérimentale Cacher : - Dévoiler le profil de chat + Afficher le profil de messagerie Dévoiler le profil - Supprimer le profil de chat + Supprimer le profil de messagerie Supprimer le profil Mot de passe de profil Trop de vidéos ! @@ -1086,7 +1088,7 @@ Arrêter de recevoir le fichier \? Vous n\'avez pas pu être vérifié·e ; veuillez réessayer. %1$d messages n\'ont pas pu être déchiffrés. - %1$d messages sautés. + %1$d messages ignorés. Vous pouvez activer SimpleX Lock dans les Paramètres. Merci aux utilisateurs - contribuez via Weblate ! Vidéos et fichiers jusqu\'à 1Go @@ -1103,8 +1105,8 @@ Vous pouvez accepter ou refuser les demandes de contacts. Couleurs de l\'interface Vos contacts resteront connectés. - Partager l\'adresse avec vos contacts ? - Partager avec vos contacts + Partager l\'adresse avec les contacts SimpleX ? + Partager avec les contacts SimpleX Entrez un message de bienvenue… (facultatif) Cesser le partage Cesser le partage d\'adresse \? @@ -1113,7 +1115,7 @@ Enregistrer les paramètres ? Ne pas créer d\'adresse Adresse - Partager l\'adresse + Partager l\'adresse… Vous pouvez partager cette adresse avec vos contacts pour leur permettre de se connecter avec %s. Aperçu Arrière-plan @@ -1127,7 +1129,7 @@ Titre Lien à usage unique Accentuation supplémentaire - Ajoutez une adresse à votre profil, afin que vos contacts puissent la partager avec d\'autres personnes. La mise à jour du profil sera envoyée à vos contacts. + Ajoutez votre adresse à votre profil afin que vos contacts SimpleX puissent la partager avec d\'autres personnes. La mise à jour du profil sera envoyée à vos contacts SimpleX. Secondaire supplémentaire Tous vos contacts resteront connectés. La mise à jour du profil sera envoyée à vos contacts. Acceptation automatique @@ -1138,20 +1140,19 @@ Vous pouvez créer une adresse pour permettre aux autres utilisateurs de vous contacter. Entrez un message de bienvenue… Vous pouvez la créer plus tard - Bonjour ! -\nContactez-moi via SimpleX Chat : %s + Salut !\nConnectez-vous avec moi via SimpleX Chat : %s Si vous ne pouvez pas vous rencontrer en personne, montrez le code QR lors d\'un appel vidéo ou partagez le lien. - Changer de profil de discussion + Modifier les profils de messagerie Menus et alertes Message reçu Assurez-vous que le fichier a une syntaxe YAML correcte. Exporter le thème pour avoir un exemple de la structure du fichier du thème. - La mise à jour du profil sera envoyée à vos contacts. + La mise à jour du profil sera envoyée à vos contacts SimpleX. Guide de l\'utilisateur.]]> Enregistrer les paramètres de l\'adresse SimpleX - Pour se connecter, votre contact peut scanner un code QR ou utiliser un lien dans l\'app. + Pour se connecter, votre contact peut scanner un QR code ou utiliser un lien dans l\'app. Le code d\'accès de l\'application est remplacé par un code d\'autodestruction. Activer l\'autodestruction - Un profil de chat vierge portant le nom fourni est créé et l\'application s\'ouvre normalement. + Un profil de messagerie vierge portant le nom fourni est créé et l\'application s\'ouvre normalement. Modifier le code d\'autodestruction Activer le code d\'autodestruction Autodestruction @@ -1239,11 +1240,11 @@ Le changement d\'adresse sera annulé. L\'ancienne adresse de réception sera utilisée. Désactivé Seuls les propriétaires du groupe peuvent activer les fichiers et les médias. - Favoris + Ajouter aux favoris Interdire l\'envoi de fichiers et de médias. - Aucune discussion filtrés + Aucune conversation filtrée Fichiers et médias - Défavoris + Retirer des favoris Délai d\'attente du protocole par KB Fichiers et médias interdits ! Permet l\'envoi de fichiers et de médias. @@ -1278,9 +1279,9 @@ \n- et bien d\'autres choses encore ! Renégocier code de sécurité modifié - L\'envoi d\'accusés de réception sera activé pour tous les contacts dans tous les profils de chat visibles. + L\'envoi d\'accusés de réception sera activé pour tous les contacts dans tous les profils de messagerie visibles. Ils peuvent être modifiés dans les paramètres des contacts et des groupes. - Ces paramètres s\'appliquent à votre profil actuel + Ces paramètres s\'appliquent à votre profil actuel Vous pouvez les activer ultérieurement via les paramètres de Confidentialité et Sécurité de l\'application. Activer les accusés de réception \? Désactiver les accusés de réception \? @@ -1322,7 +1323,7 @@ Distribution désactivé Pas d\'information sur la distribution - Aucun chat sélectionné + Aucune conversation sélectionnée Activer les reçus pour les groupes \? Ce groupe compte plus de %1$d membres, les accusés de réception ne sont pas envoyés. %s : %s @@ -1360,7 +1361,7 @@ La phrase secrète sera stockée en clair dans les paramètres après que vous la modifiez ou que vous redémarrez l\'application. La phrase secrète est stockée en clair dans les paramètres. Chiffrement des fichiers et des médias stockés - Remarque : Les relais de messages et de fichiers sont connectés par le biais d\'un proxy SOCKS. Les appels et l\'envoi d\'aperçus de liens utilisent une connexion directe.]]> + Remarque : les relais de messages et de fichiers passent par un proxy SOCKS. Les appels utilisent une connexion directe.]]> Chiffrer les fichiers locaux Nouvelle application de bureau ! 6 nouvelles langues d\'interface @@ -1376,7 +1377,7 @@ Ouvrir Erreur lors de la création du contact du membre Envoyer un message direct pour vous connecter - envoyer un message direct + envoyer pour se connecter s\'est connecté.e de manière directe Étendre Répéter la demande de connexion ? @@ -1430,40 +1431,40 @@ Vous avez déjà demandé une connexion via cette adresse ! Afficher la console dans une nouvelle fenêtre Tous les nouveaux messages provenant de %s seront cachés ! - blocké + bloqué Erreur lors de la renégociation du chiffrement La renégociation du chiffrement a échoué. Bloquer des membres d\'un groupe Création de groupes via un profil aléatoire. Bureau connecté Nouvel appareil mobile - Adresse de bureau + Adresse du PC Un seul appareil peut fonctionner en même temps Liez vos applications mobiles et de bureau ! 🔗 Via un protocole sécurisé de cryptographie post-quantique. - Utiliser depuis le bureau dans l\'application mobile et scannez le code QR.]]> + Utiliser depuis l’ordinateur dans l’application mobile et scannez le QR code.]]> Pour cacher les messages indésirables. Version incompatible (nouveau)]]> - Délier le bureau ? + Dissocier le PC ? Des groupes plus performants - Options de bureau lié - Bureaux liés + Options de l\'ordinateur lié + Ordinateurs liés Rechercher sur le réseau Groupes incognito Cet appareil %s a été déconnecté]]> Connexion plus rapide et messages plus fiables. Mobiles liés - Bureau - Connecté au bureau + Ordinateur + Connecté au PC Nom de cet appareil En attente d\'une connexion mobile: Chargement du fichier - Connexion au bureau - Appareils de bureau + Connexion au PC + Ordinateurs Lier un portable - Accès au bureau + Utiliser depuis l’ordinateur Mobile connecté Code de session Connexion terminée @@ -1473,14 +1474,14 @@ Vérifier le code sur le mobile Entrez le nom de l\'appareil… Erreur - Connexion au bureau + Connexion au PC Se déconnecter auteur Connecté au portable - Adresse de bureau incorrecte - Coller l\'adresse du bureau - Vérifier le code avec le bureau - Scannez le code QR du bureau + Adresse du PC non valide + Coller l\'adresse du PC + Vérifier le code avec le PC + Scannez le QR code de l\'ordinateur Appareils - option pour notifier les contacts supprimés. \n- noms de profil avec espaces. @@ -1489,15 +1490,15 @@ Vérifier les connexions Déconnecter le bureau ? Veuillez patienter le temps que le fichier soit chargé depuis le mobile lié. - La version de l\'application de bureau %s n\'est pas compatible avec cette application. + La version de bureau %s n\'est pas compatible avec cette application. Vérifier la connexion Connexion automatique - En attente du bureau… - Bureau trouvé + En attente du PC… + Ordinateur trouvé Non compatible ! Accessible via le réseau local Rafraîchir - Créer un profil de chat + Créer un profil de messagerie Pas de mobile connecté Déconnecter les mobiles Aléatoire @@ -1515,7 +1516,7 @@ Ne pas envoyer d\'historique aux nouveaux membres. Ou montrez ce code Les 100 derniers messages sont envoyés aux nouveaux membres. - Le code scanné n\'est pas un code QR de lien SimpleX. + Le code scanné n\'est pas un QR code de lien SimpleX. Le texte collé n\'est pas un lien SimpleX. Autoriser l\'accès à la caméra Vous pouvez à nouveau consulter le lien d\'invitation dans les détails de la connexion. @@ -1524,23 +1525,23 @@ Créer un groupe : pour créer un nouveau groupe.]]> Historique visible Code d\'accès à l\'app - Nouveau chat - Chargement des discussions… + Nouvelle conversation + Chargement des conversations… Création d\'un lien… - Ou scanner le code QR + Ou scannez le QR code Code QR invalide Ajouter un contact Appuyez pour scanner Conserver Appuyez pour coller le lien Rechercher ou coller un lien SimpleX - Le chat est arrêté. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer le chat. - Lancer le chat ? + La messagerie est arrêtée. Si vous avez déjà utilisé cette base de données sur un autre appareil, vous devez la transférer à nouveau avant de démarrer la messagerie. + Lancer la messagerie ? %s.]]> Connexion interrompue - État médiocre de la connexion avec le bureau - La version de l\'ordinateur de bureau n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils. - Le bureau a été déconnecté + La connexion au PC est dans un état incorrect + La version de l\'ordinateur n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils. + Le PC a été déconnecté Options pour les développeurs Erreur interne %s n\'est pas prise en charge. Veillez à utiliser la même version sur les deux appareils.]]> @@ -1548,7 +1549,7 @@ Déconnecté pour la raison suivante : %s Nom d\'affichage invalide ! Ce nom d\'affichage est invalide. Veuillez choisir un autre nom. - Délai d\'attente dépassé lors de la connexion au bureau + Délai d\'attente dépassé lors de la connexion au PC Erreur critique Veuillez le signaler aux développeurs : \n%s @@ -1570,9 +1571,9 @@ %s est manquant]]> %s a été déconnecté]]> %s]]> - Le bureau ne possède pas le bon code d\'invitation - Le bureau est occupé - Le bureau est inactif + Le PC ne possède pas le bon code d\'invitation + Le PC est occupé + Le PC est inactif Avec les fichiers et les médias chiffrés. Historique récent et bot d\'annuaire amélioré. La barre de recherche accepte les liens d\'invitation. @@ -1634,7 +1635,7 @@ Messagerie transférée ! Vérifiez votre connexion internet et réessayez Échec du téléchargement - Transférer depuis un autre appareil sur le nouvel appareil et scanner le code QR.]]> + Transférer depuis un autre appareil sur le nouvel appareil et scanner le QR code.]]> Confirmer les paramètres réseau Confirmer la transmission Création d\'un lien d\'archive @@ -1650,8 +1651,8 @@ Erreur lors de la vérification de la phrase secrète : chiffrement de bout en bout avec une confidentialité persistante, une répudiation et une récupération en cas d\'effraction.]]> chiffrement e2e résistant post-quantique avec une confidentialité persistante, une répudiation et une récupération en cas d\'effraction.]]> - Cette discussion est protégée par un chiffrement de bout en bout. - Cette discussion est protégée par un chiffrement de bout en bout résistant post-quantique. + Cette conversation est protégée par un chiffrement de bout en bout. + Cette conversation est protégée par un chiffrement de bout en bout résistant aux ordinateurs quantiques. Accéder à l\'écran de transfert Transférer depuis un autre appareil Définir une phrase secrète @@ -1731,7 +1732,7 @@ Casque audio La source du message reste privée. enregistré - enregistré depuis %s + enregistré depuis Transféré Transféré depuis Le(s) destinataire(s) ne peut(vent) pas voir de qui provient ce message. @@ -1805,7 +1806,7 @@ Protégez votre adresse IP des relais de messagerie choisis par vos contacts. \nActivez-le dans les paramètres *Réseau et serveurs*. Réinitialiser au thème de l\'utilisateur - Afficher la liste des chats dans une nouvelle fenêtre + Afficher la liste des conversations dans une nouvelle fenêtre Teinte du fond d\'écran Fond d\'écran info sur la file du serveur : %1$s @@ -1832,8 +1833,8 @@ UI en persan Réception de fichiers en toute sécurité Consommation réduite de la batterie. - Couleurs de la discussion - Thème de la discussion + Couleurs de la conversation + Thème de la conversation Thème de profil Clair Système @@ -1853,10 +1854,10 @@ \nVeuillez faire part de tout autre problème aux développeurs. Ce lien a été utilisé avec un autre appareil mobile, veuillez créer un nouveau lien sur le desktop. Impossible d\'envoyer le message - Les paramètres de chat sélectionnés ne permettent pas l\'envoi de ce message. + Les paramètres de conversation sélectionnés ne permettent pas l\'envoi de ce message. Connections actives Tous les profiles - Reçu avec accusé de réception + Accusés de réception La mise à jour de l\'app est téléchargée Complétées Profil actuel @@ -1868,7 +1869,7 @@ Téléchargement %s (%s) Erreur de reconnexion au serveur inactif - Scanner / Coller un lien + Coller le lien / Scanner Le message peut être transmis plus tard si le membre devient actif. Reconnecter tous les serveurs connectés pour forcer la livraison des messages. Cette méthode utilise du trafic supplémentaire. Sessions de transport @@ -1913,7 +1914,7 @@ Veuillez redémarrer l\'application. Rappeler plus tard Afficher le pourcentage - Sauter cette version + Ignorer cette version Stable Pour être informé des nouvelles versions, activez la vérification périodique des versions Stable ou Bêta. Mise à jour disponible : %s @@ -2002,7 +2003,7 @@ Les appels ne sont pas autorisés ! Vous devez autoriser votre contact à appeler pour pouvoir l\'appeler. Impossible d\'envoyer un message à ce membre du groupe - Vous pouvez toujours consulter la conversation avec %1$s dans la liste des conversation. + Vous pouvez toujours voir la conversation avec %1$s dans la liste des conversations. Autoriser les appels ? appeler Impossible d\'appeler le contact @@ -2016,14 +2017,14 @@ Flouter pour une meilleure confidentialité. Connectez-vous à vos amis plus rapidement. État de la connexion et des serveurs. - Exportation de la base de données des discussions - Poursuivre + Exportation de la base de données de la messagerie + Continuer Les messages seront marqués comme étant à supprimer. Le(s) destinataire(s) pourra(ont) révéler ces messages. Supprimer %d messages de membres ? Message Augmenter la taille de la police. Créer - Rien n\'est sélectionné + Aucune sélection Lien invalide Nouveau message Nouvelles options de médias @@ -2061,9 +2062,9 @@ Transfert de messages… Les messages ont été supprimés après avoir été sélectionnés. Erreur lors du changement de profil - Sélectionner un profil de discussion + Sélectionner un profil de messagerie Partager le profil - Votre connexion a été déplacée vers %s mais une erreur inattendue s\'est produite lors de la redirection vers le profil. + Votre connexion a été transférée vers %s, mais une erreur s\'est produite lors du changement de profil. Ne pas utiliser d\'identifiants avec le proxy. Erreur lors de l\'enregistrement du proxy Mot de passe @@ -2090,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 de la messagerie Mode système Serveur De nouveaux identifiants SOCKS seront utilisées pour chaque serveur. @@ -2108,7 +2109,7 @@ Transférez jusqu\'à 20 messages à la fois. Protocoles SimpleX audité par Trail of Bits. Passer de l\'audio à la vidéo pendant l\'appel. - Changer de profil de chat pour les invitations à usage unique. + Changer de profil de messagerie pour les invitations à usage unique. rapport archivé Ajoutez les membres de votre équipe aux conversations. L\'application tourne toujours en arrière-plan @@ -2122,7 +2123,7 @@ Archiver le signalement Demander Ajouter à la liste - Toutes les discussions seront supprimées de la liste %s, et la liste sera supprimée + Toutes les conversations seront retirées de la liste %s, et la liste sera supprimée Ajouter des membres à l\'équipe Conditions acceptées Ajouter des amis @@ -2159,26 +2160,26 @@ Créer une liste Supprimer Supprimer la liste ? - Supprimer la discussion - Discussions + Supprimer la conversation + Conversation %s.]]> %s.]]> Le texte sur les conditions actuelles n\'a pas pu être chargé. Vous pouvez consulter les conditions en cliquant sur ce lien : Les messages directs entre membres sont interdits. %1$s.]]> - Supprimer la discussion ? + Supprimer la conversation ? Ajout de serveurs de médias et de fichiers Ajout de serveurs de messages %s.]]> Appareils Xiaomi : veuillez activer le démarrage automatique dans les paramètres du système pour que les notifications fonctionnent.]]> - La discussion sera supprimé pour tous les membres - cela ne peut pas être annulé ! - Le discussion sera supprimé pour vous - il n\'est pas possible de revenir en arrière ! + La conversation sera supprimée pour tous les membres ; cette action est irréversible ! + La conversation sera supprimée pour vous ; cette action est irréversible ! Les conditions seront acceptées pour les opérateurs activés après 30 jours. La connexion nécessite une renégociation du chiffrement. avec un seul contact - partagez en personne ou via n\'importe quelle messagerie.]]> Adresse ou lien unique ? Sécurité des connexions - Professionnels + Entreprises Le fichier est bloqué par l\'opérateur du serveur :\n%1$s. Favoris %d rapports @@ -2205,15 +2206,15 @@ Les adresses SimpleX et les liens à usage unique peuvent être partagés en toute sécurité via n\'importe quelle messagerie. Spam Signaler - Pas de discussions non lues + Aucune conversation non lue Groupes Signalements Signaler le profil d\'un membre : seuls les modérateurs du groupe le verront. Signaler le spam : seuls les modérateurs du groupe le verront. Ouvrir le lien - Ouvrir des liens depuis la liste de discussion + Ouvrir des liens depuis la liste de conversation Erreur de mise à jour du serveur - Les serveurs pour les nouveaux fichiers de votre profil de discussion actuel + Les serveurs pour les nouveaux fichiers de votre profil de messagerie actuel Serveur de l\'opérateur Le protocole du serveur a été modifié. Activer Flux @@ -2222,12 +2223,12 @@ Partager publiquement votre adresse Par exemple, si votre contact reçoit des messages via un serveur SimpleX Chat, votre application les transmettra via un serveur Flux. Seuls les propriétaires peuvent modifier les préférences. - Le rôle deviendra %s. Toutes les personnes présentes dans le discussion en seront informées. - Erreur lors de la création d\'une liste de discussion - Erreur de chargement des listes de discussion - Erreur de mise à jour de la liste des discussions - Pas de discussions - Pas de discussions trouvées + Le rôle sera remplacé par %s. Tous les participants à la conversation en seront informés. + Erreur lors de la création d\'une liste de conversations + Erreur de chargement des listes de conversations + Erreur de mise à jour de la liste des conversations + Aucune conversation + Pas de conversations trouvées Ouvrir avec %s Sauvegarder la liste Modifier @@ -2243,7 +2244,7 @@ Pas de serveurs pour le routage privé des messages. Erreur lors de la validation des conditions Erreurs dans la configuration des serveurs. - Pour le profil de discussion %s : + Pour le profil de messagerie %s : Pas de serveurs pour envoyer des fichiers. Veuillez réduire la taille du message et envoyer le à nouveau. Veuillez réduire la taille du message ou supprimer le média et renvoyer le message. @@ -2255,7 +2256,7 @@ Erreur d\'enregistrement de la base de données Signaler un contenu : seuls les modérateurs du groupe le verront. Opérateurs de serveur - Pas de discussions dans la liste %s. + Aucune conversation dans la liste %s. L\'opérateur du serveur a changé. Ouvrir le lien web ? Signaler une infraction : seuls les modérateurs du groupe le verront. @@ -2263,7 +2264,7 @@ Serveur ajouté à l\'opérateur %s. L\'application protège votre vie privée en utilisant des opérateurs différents pour chaque conversation. Sélectionnez les opérateurs de réseau à utiliser. - SimpleX Chat et Flux ont conclu un accord pour inclure les serveurs exploités par Flux dans l\'application. + SimpleX Chat et Flux ont conclu un accord pour intégrer à l’application des serveurs opérés par Flux. Notes Ou à partager en privé Adresse SimpleX ou lien unique ? @@ -2296,10 +2297,10 @@ Inviter à discuter Le nom de liste et l\'emoji doivent être différents pour toutes les listes. Nom de la liste... - Quitter la discussion ? - Vous ne recevrez plus de messages de cette discussion. L\'historique sera préservé. - Le membre sera retiré de la discussion - cela ne peut pas être annulé ! - Votre profil de discussion sera envoyé aux autres membres + Quitter la conversation ? + Vous ne recevrez plus de messages de cette conversation. L’historique sera conservé. + Le membre sera retiré de la conversation ; cette action est irréversible ! + Votre profil de messagerie sera envoyé aux autres membres Vos serveurs Utiliser %s Utiliser les serveurs @@ -2307,7 +2308,7 @@ Ouvrir les modifications Messages non distribués Vous pouvez copier et réduire la taille du message pour l\'envoyer. - Quitter la discussion + Quitter la conversation Voir les conditions mises à jour Navigation améliorée dans les discussions Lorsque plusieurs opérateurs sont activés, aucun d\'entre eux ne dispose de métadonnées permettant de savoir qui communique avec qui. @@ -2323,7 +2324,7 @@ Archiver tous les signalements ? Archiver %d signalements ? Modifier la suppression automatique des messages ? - Supprimer les messages de discussion de votre appareil. + Supprimer les messages de conversation de votre appareil. par défaut (%s) Bloquer ces membres pour tous ? Désactiver la suppression automatique des messages ? @@ -2342,23 +2343,23 @@ Aider les administrateurs à modérer leurs groupes. Tous les nouveaux messages de ces membres seront cachés ! Utiliser le profil incognito - Chat ouvert - Ouvrir un nouveau chat + Conversation ouverte + Ouvrir une nouvelle conversation Ouvrir un nouveau groupe Lien pour la voie SimpleX Pas de session de routage privé Erreur lors de l\'acceptation du membre - Erreur effaçant le chat avec le membre + Erreur lors de la suppression de la conversation Lien de connexion pas soutenu Ce lien requiert une version de l\'application plus récente. Veuillez s\'il-vous-plait actualiser l\'application ou demander à votre contact de vous envoyer un lien compatible. Erreur lors de rejeter la demande de contact - Erreur en ouvrant le chat + Erreur en ouvrant la conversation Erreur en ouvrant le groupe Erreur en changeant le profil Quatres nouvelles langues d\'interface Partagez votre adresse Actualisez votre adresse - 1 discussion avec un membre + 1 conversation avec un membre vous a accepté Accepter le membre actif @@ -2444,4 +2445,222 @@ Le lien sera court, et le profil de groupe sera partagé via le lien. L\'expéditeur n\'en sera PAS informé. Ce réglage est pour votre profil actuel + Propriétaires & contributeurs + contributeur + Description + Ajouter + Ajouter une description + Votre réseau + vous + Canal + Canal + canal + Votre canal + Votre canal + Lien de canal + Lien de canal + Membres du canal + Nom du canal + Préférences du canal + profil du canal mis à jour + Canaux + Nom du canal SimpleX + Canal temporairement indisponible + Page web du canal + Le canal sera supprimé pour tous les abonnés ; cette action est irréversible ! + Lien + Parce que nous avons détruit le pouvoir de vous identifier. Pour que votre pouvoir ne puisse jamais vous être enlevé. + Bot + Signé + Signé et vérifié + SimpleX + Rejoindre le canal %s + Se connecter à %s + L\'application est déjà en cours d\'exécution + Une autre instance de l\'application est peut-être en cours ou mal fermée. Démarrer quand même ? + sans abonnement + Vous n\'êtes pas connecté au serveur permettant de recevoir des messages via cette connexion (aucun abonnement). + L\'enregistrement vocal n\'est pas pris en charge sur votre plateforme + un chiffrement de bout en bout.]]> + pas chiffrés de bout en bout. Les relais de messagerie peuvent voir ces messages.]]> + Aucun relais de messagerie n\'est activé. + Aucun serveur pour résoudre les noms. + Erreur de résolution : %1$s + Aucun lien valide + Le nom SimpleX %1$s est enregistré, mais son lien n\'est pas valide. + Nom non confirmé + Le nom SimpleX %1$s est enregistré, mais absent du profil. Ajoutez-le à votre adresse ou au profil du canal si vous en êtes propriétaire. + Le canal ne dispose d\'aucun relais actif. Veuillez réessayer plus tard. + Mise à jour de l\'application requise + Ce groupe nécessite une version plus récente de l\'application. Veuillez la mettre à jour pour le rejoindre. + Erreur lors de la suppression du message + Enregistrer le nom SimpleX ? + Obtenir le nom SimpleX (BETA) + Comment enregistrer un nom de test + Supprimer le nom + Rechercher des images + Rechercher des vidéos + Rechercher des messages vocaux + Rechercher des fichiers + Rechercher des liens + Images + Vidéos + Messages vocaux + Liens + Filtre + Depuis l\'historique + Parler à quelqu\'un + Permettez à quelqu\'un de se connecter à vous + Se connecter via le lien ou le QR code + Créez votre lien + Invitez quelqu\'un en privé + Un lien pour se connecter à une personne + Créez votre adresse publique + Votre adresse publique + Pour que n\'importe qui puisse vous joindre + Ouvrir pour utiliser le bot + Ouvrir pour accepter + Signalement : %s + %d conversations avec des membres + %d conversation(s) + Appuyez sur Se connecter à la conversation + Appuyez sur Se connecter pour envoyer une demande + Appuyez sur Se connecter pour utiliser le bot + Accepter la demande de contact + Votre contact + Appuyez sur Rejoindre le groupe + Appuyez sur Rejoindre le canal + Votre groupe + Groupe + Connexion professionnelle + Votre contact professionnel + Partager le canal… + Pour utiliser un autre profil après une tentative de connexion, supprimez la conversation et utilisez à nouveau le lien. + Le profil du canal est stocké sur les appareils des abonnés et sur les relais de messagerie. + Erreur lors du marquage comme lu + Partager via la conversation + Lien de groupe + Adresse professionnelle + Adresse de contact + Lien à usage unique + (de la part du propriétaire) + Vous pouvez consulter vos signalements dans la section Discuter avec les admins. + Les messages de cette conversation ne seront jamais supprimés. + Cette action est irréversible : les messages envoyés et reçus dans cette conversation avant la date sélectionnée seront supprimés. + Définir le nom de la conversation… + Mettre tout en sourdine + Les opérateurs s’engagent à :\n- Être indépendants\n- Réduire au minimum l’utilisation des métadonnées\n- Exécuter du code open source vérifié + Ouvrir le lien complet + Ouvrir le lien propre + Données de conversation + Si vous avez rejoint ou créé des canaux, ils cesseront de fonctionner définitivement. + Vous ne recevrez plus de messages de ce canal. L’historique de la conversation sera conservé. + Les relais de messagerie utilisés ne prennent pas en charge les pages web. + Discuter avec les admins + Relais de messagerie + Discuter avec un membre + Les membres seront retirés de la conversation ; cette action est irréversible ! + L\'empreinte numérique dans l\'adresse du serveur ne correspond pas au certificat : %1$s. + Délai d\'attente dépassé pour le routage privé + L’empreinte de l’adresse du serveur de transfert ne correspond pas au certificat : %1$s. + L’empreinte de l’adresse du serveur de destination ne correspond pas au certificat : %1$s. + Nom de canal non pris en charge + Nom de contact non pris en charge + La connexion via un nom de canal nécessite une version plus récente de l\'application. + La connexion via un nom de contact nécessite une version plus récente de l\'application. + Veuillez mettre à jour l\'application. + Erreur de nom SimpleX + Aucun de vos serveurs n\'est configuré pour résoudre les noms SimpleX. Configurez des serveurs ou utilisez un lien de connexion. + Le serveur %1$s ne prend pas en charge la résolution de nom. Configurez les serveurs ou utilisez un lien de connexion. + Nom introuvable + Ce nom SimpleX n\'est pas enregistré. Veuillez vérifier le nom. + Appuyez pour ouvrir + Erreur lors du partage du canal + Erreur lors du partage de l\'adresse + Signature du lien vérifiée. + ⚠️ Échec de la vérification de la signature : %s. + Rejoindre le groupe + Rejoindre le canal + Diffusion + Ajouter un message + Vous ne pouvez pas envoyer de messages ! + contact non prêt + Interdire le signalement de messages aux modérateurs. + Les abonnés peuvent signaler des messages aux modérateurs. + vous êtes abonné + demande d’adhésion refusée + le groupe est supprimé + vous êtes parti + impossible d\'envoyer des messages + impossible de diffuser + le membre utilise une ancienne version + Vous devez être connecté pour envoyer des commandes. + Signer le message + La signature prouve que vous êtes l\'auteur de ce message et ne peut être déniée ultérieurement. + Signer les messages + Exiger la signature des messages. + Ne pas exiger la signature des messages. + La signature des messages est requise. + La signature des messages n\'est pas requise. + Afficher la signature + Afficher le chiffrement + Signature manquante + Le canal exigeait que ce message soit signé, mais la signature est manquante. + Accepter la demande de contact + Le membre a été supprimé : impossible d\'accepter la demande. + Mentions non lues + Vérifier le nom + Vérifier les noms SimpleX + Nom SimpleX non vérifié + Nom SimpleX + Votre nom SimpleX + Définir un nom SimpleX + Erreur lors de l\'enregistrement du nom + Le nom SimpleX %1$s est enregistré sans lien de canal. Ajoutez un lien de canal au nom via la page d\'enregistrement. + Le nom SimpleX %1$s est enregistré sans adresse SimpleX. Ajoutez votre adresse SimpleX au nom via la page d\'enregistrement. + Permettez aux personnes de se connecter à vous via un nom enregistré avec votre adresse SimpleX. + Permettez aux personnes de rejoindre via un nom enregistré avec ce lien de canal. + Lien complet + Chargement du profil… + Votre profil + Impossible de modifier le profil + Pour vérifier les clés avec cet abonné, comparez (ou scannez) le code sur vos appareils. + Utiliser le port web + Utiliser le port TCP %1$s lorsqu\'aucun port n\'est spécifié. + Utiliser le port TCP 443 uniquement pour les serveurs prédéfinis. + Options obsolètes + Message de bienvenue + Nouveau lien à usage unique + Envoyez le lien via n\'importe quelle messagerie : c\'est sécurisé. Demandez de le coller dans SimpleX. + Ou présentez le QR code en personne ou par appel vidéo. + Utilisez cette adresse dans votre profil de réseau social, sur votre site web ou dans votre signature électronique. + Ou utilisez ce QR code : imprimez-le ou affichez-le en ligne. + Mettre à jour l\'adresse + Mettre à jour l\'adresse ? + Mettre à jour + Mettre à jour le lien de groupe + Mettre à jour le lien de groupe ? + Bio : + Bio trop longue + Modifier la description + Saisissez une description (facultatif) + Enregistrer les paramètres d\'admission ? + Enregistrer et avertir les abonnés du canal + Votre bio : + Soyez libre\ndans votre réseau + Messagerie privée et sécurisée. + Le premier réseau où vous possédez\nvos contacts et vos groupes. + Commencer + Pourquoi SimpleX a été créé. + Votre profil + Sur votre téléphone, pas sur les serveurs. + Pas de compte. Pas de numéro de téléphone. Pas d\'email. Pas d\'identité.\nLe chiffrement le plus sécurisé. + Saisissez le nom du profil… + Migrer + Vous êtes né·e sans compte. + Personne n\'a tracé vos conversations. Personne n\'a dressé la carte de vos déplacements. La confidentialité n\'a jamais été une fonctionnalité : c\'était un mode de vie. + Puis nous sommes passés en ligne, et chaque plateforme a réclamé une partie de vous : votre nom, votre numéro, vos amis. Nous avons accepté que le prix à payer pour parler aux autres soit de permettre à quelqu\'un de savoir avec qui nous parlons. Chaque génération, humaine et technologique, a fonctionné ainsi : téléphone, email, messageries, réseaux sociaux. Il semblait que ce fût la seule voie possible. + Il existe une autre voie. Un réseau sans numéros de téléphone. Sans noms d\'utilisateur. Sans comptes. Sans aucune identité utilisateur. Un réseau qui connecte les personnes et achemine des messages chiffrés sans savoir qui est connecté. + Pas une meilleure serrure sur la porte de quelqu\'un d\'autre. Ni un propriétaire plus sympa qui respecte votre vie privée, mais conserve tout de même la trace de tous les visiteurs. Vous n\'êtes pas un invité. Vous êtes chez vous. Aucun roi ne peut y entrer : vous êtes souverain. + Vos conversations vous appartiennent, comme c\'était toujours le cas avant Internet. Le réseau n\'est pas un lieu que vous visitez. C\'est un lieu que vous créez et possédez. Et personne ne peut vous l\'enlever, que vous le rendiez privé ou public. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml index f3b1df5a40..3f17a6d8a7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hi/strings.xml @@ -283,6 +283,9 @@ सदस्य को समूह से निकाल दिया जाएगा - इसे पूर्ववत नहीं किया जा सकता! सदस्य सदस्य + आप सदस्य हैं + आप व्यवस्थापक हैं + आप स्वामी हैं खोजें बंद है संपर्क पते के माध्यम से कनेक्ट करें? 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 2d29984da5..3493f56711 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hr/strings.xml @@ -590,7 +590,7 @@ Anonimni režim štiti Vašu privatnost koristeći novi nasumični profil za svaki kontakt. nedelje Interna greška - Sačuvano od %s + Sačuvano od sačuvano pozvan Sačuvana poruka @@ -1435,6 +1435,11 @@ Za pozive je potreban podrazumevani veb pretraživač. Molimo vas da konfigurišete podrazumevani pretraživač u sistemu i podelite više informacija sa programerima. odblokirali ste %s Vi ste posmatrač. + Vi ste posmatrač + Vi ste član + Vi ste moderator + Vi ste administrator + Vi ste vlasnik Unapređena privatnost i bezbednost Migriraj na drugi uređaj pomoću QR koda. odbijeno 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 f9e627b21a..2fca87fdcd 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/hu/strings.xml @@ -1127,6 +1127,13 @@ Üzenetek fogadása… %s és %s kapcsolódott Ön megfigyelő + Ön megfigyelő + Ön tag + Ön moderátor + Ön adminisztrátor + Ön tulajdonos + Ön feliratkozó + Ön közreműködő Port Jelkód beállítása Újdonságok @@ -1475,7 +1482,7 @@ A jelmondat a beállításokban egyszerű szövegként van tárolva. Konzol megjelenítése új ablakban Az előző üzenet kivonata különbözik. - Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak + Ezek a beállítások csak a jelenlegi csevegési profiljára vonatkoznak Várjon, amíg a fájl betöltődik a társított hordozható eszközről GitHub-tárolónkban talál.]]> Hiba történt a tartalom megjelenítésekor @@ -1694,7 +1701,7 @@ A SimpleX-hivatkozások küldése engedélyezve van. Számukra engedélyezve mentett - mentve innen: %s + mentve innen: Továbbítva innen A címzett(ek) nem látja(k), hogy kitől származik ez az üzenet. Mentett @@ -2881,7 +2888,7 @@ A csatorna megköveteli az üzenet aláírását, de az hiányzik. Csatorna SimpleX-neve SimpleX-név beszerzése (béta) - Egy név regisztrálása tesztelési céllal + Útmutató egy név regisztrálásához tesztelési céllal Név eltávolítása Menti a SimpleX-nevet? A kulcsok ellenőrzéséhez ezzel a feliratkozóval hasonlítsa össze (vagy olvassa be) az eszközökön található kódot. 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 bd3f6e2b22..b5c35bc7c6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/in/strings.xml @@ -755,7 +755,7 @@ dimoderasi obrolan tidak valid diteruskan - disimpan dari %s + disimpan dari terima berkas belum didukung anda format pesan tak diketahui @@ -1206,7 +1206,7 @@ Aktifkan tanda terima untuk grup? Profil obrolan kosong dengan nama yang disediakan dibuat, dan aplikasi terbuka seperti biasa. Jika Anda memasukkan kode sandi saat membuka aplikasi, semua data aplikasi akan dihapus secara permanen! - Pengaturan ini untuk profil Anda saat ini + Pengaturan ini untuk profil Anda saat ini Kirim tanda terima diaktifkan untuk %d kontak Kirim tanda terima dimatikan untuk %d kontak Gagal memuat server XFTP @@ -2010,6 +2010,13 @@ Kunci salah atau alamat potongan berkas tidak dikenal - kemungkinan berkas dihapus. Versi server tidak kompatibel dengan pengaturan jaringan. Anda adalah pengamat + Anda adalah pengamat + Anda adalah anggota + Anda adalah moderator + Anda adalah admin + Anda adalah pemilik + Anda adalah pelanggan + Anda adalah kontributor Untuk memulai obrolan baru Video Koneksi yang Anda terima akan dibatalkan! 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 9fad882019..eba8b408df 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/it/strings.xml @@ -934,6 +934,13 @@ Il messaggio verrà eliminato per tutti i membri. Il messaggio sarà segnato come moderato per tutti i membri. sei un osservatore + Sei un osservatore + Sei un membro + Sei un moderatore + Sei un amministratore + Sei un proprietario + Sei iscritto/a + Sei un collaboratore Ruolo iniziale Errore nell\'aggiornamento del link del gruppo osservatore @@ -1290,7 +1297,7 @@ L\'invio delle ricevute di consegna sarà attivo per tutti i contatti in tutti i profili di chat visibili. L\'invio di ricevute è disattivato per %d contatti La crittografia funziona e il nuovo accordo sulla crittografia non è richiesto. Potrebbero verificarsi errori di connessione! - Queste impostazioni sono per il tuo profilo attuale + Queste impostazioni sono per il tuo profilo attuale Possono essere sovrascritte nelle impostazioni dei contatti e dei gruppi. Attiva per tutti Attiva (mantieni sostituzioni) @@ -1732,7 +1739,7 @@ Inoltra I destinatari non possono vedere da chi proviene questo messaggio. Salvato - salvato da %s + salvato da Bluetooth Auricolari Cuffie @@ -2916,7 +2923,7 @@ Il canale ha richiesto di firmare questo messaggio, ma la firma non è presente. Nome SimpleX per il canale Ottieni nome SimpleX (BETA) - Registra un nome di prova + Come registrare un nome di prova Rimuovi nome Salvare il nome SimpleX? Per verificare le chiavi con questo iscritto, confrontate (o scansionate) il codice sui vostri dispositivi. 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 430acfa6c1..8487614c9a 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/iw/strings.xml @@ -1128,6 +1128,10 @@ %1$d הודעות שדולגו שבועות הינך צופה + הינך צופה + הינך חבר קבוצה + הינך מנהל + הינך בעלים אין באפשרותך לשלוח הודעות! סרטון נשלח הודעה קולית @@ -1291,7 +1295,7 @@ \n- קבוצות קצת יותר טובות. \n- ועוד! שליחת קבלות שליחה תתאפשר עבור כל אנשי הקשר בכל פרופילי הצ\'אט הגלויים. - הגדרות אלו מיועדות לפרופיל הנוכחי שלך + הגדרות אלו מיועדות לפרופיל הנוכחי שלך ניתן לעקוף אותם בהגדרות אנשי קשר וקבוצות. שליחת קבלות מושבתת עבור %d אנשי קשר שליחת קבלות מאופשרת עבור %d אנשי קשר @@ -1767,7 +1771,7 @@ חיבור קווי סלולרי נשמר - נשמר מ%s + נשמר מ הועבר הועבר מחובר לרשת 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 e61e4e4372..1e0686d2de 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ja/strings.xml @@ -990,6 +990,12 @@ ビデオ メッセージのハッシュ値問題 あなたはオブザーバーです + あなたはオブザーバーです + あなたはメンバーです + あなたはモデレーターです + あなたは管理者です + あなたはオーナーです + あなたは購読者です グループの管理者に連絡してください。 動画は相手がアップロードを完了した時点で受信するができます。 .onion hostを使用する、は「いいえ」に設定します。]]> @@ -1278,7 +1284,7 @@ グループメンバーによる修正はサポートされていません 連絡先 これらは連絡先とグループの設定が優先されます。 - これらの設定は現在のプロファイル用です + これらの設定は現在のプロファイル用です 配信通知を有効? %s : %s 接続を修正 @@ -1733,7 +1739,7 @@ より信頼性の高いネットワーク接続 ネットワーク管理 保存済 - %sから保存 + から保存 転送済 転送元 保存元 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 ea87347a13..a987a0c92e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ko/strings.xml @@ -468,6 +468,10 @@ 나감 멤버 소유자 + 당신은 관찰자입니다 + 당신은 멤버입니다 + 당신은 관리자입니다 + 당신은 소유자입니다 그룹 삭제됨 초대됨 강퇴됨 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 aceb11ecf8..aa1da521bb 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lt/strings.xml @@ -1198,7 +1198,7 @@ %1$s!]]> Kad prisijungti su nuoroda Ši nuoroda nėra tinkama prisijungimo nuoroda! - Šie nustatymai yra jūsų dabartiniam profiliui + Šie nustatymai yra jūsų dabartiniam profiliui Slaptafrazė saugoma nustatymuose kaip paprastas tekstas. Bakstelėkite, kad prisijungti kaip inkognito Ši grupė turi daugiau nei %1$d narių, pristatymo kvitai nėra siunčiami. @@ -1547,6 +1547,10 @@ Jums reikės autentifikuotis kai paleidžiate programėlę arba pratęsiate jos naudojimą po 30 sekundžių fone. Nėra istorijos esate stebėtojas + Esate stebėtojas + Esate narys + Esate administratorius + Esate savininkas (saugo tik grupės nariai) Jūsų SimpleX adresas Nuskanuoti serverio QR kodą @@ -1729,7 +1733,7 @@ Garsiakalbis Tinklo valdymas išsaugota - išsaugota iš %s + išsaugota iš Išsaugota Balso žinutės neleidžiamos WiFi diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml index c5473aeea4..a6882871c7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/lv/strings.xml @@ -53,7 +53,7 @@ Jūs pārsūtīts saglabāts - saglabāts no %s + saglabāts no nederīga tērzēšana nederīgi dati kļūda, rādot ziņojumu @@ -283,6 +283,7 @@ Nevar nosūtīt ziņu, jūs esat izgājis Nevar nosūtīt ziņu Jūs esat vērotājs + Jūs esat vērotājs Pārbaudīts ar administratoriem Nevar nosūtīt ziņu, dalībniekam ir veca versija Nevar Nosūtīt Komandas Brīdinājuma Teksts @@ -1607,7 +1608,7 @@ Ievada tīkla operatori turpināt Ienākošais video zvans Ienākošais audio zvans - Čeki + Čeki Čeku apraksts 1 Čeku kontakti Čeku kontakti iespējot diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml index 19aa92a4a0..008ad2bb81 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ml/strings.xml @@ -295,6 +295,9 @@ സ്വാഗതം! ഈ വാചകം ക്രമീകരണങ്ങളിൽ ലഭ്യമാണ് നിങ്ങൾ നിരീക്ഷകനാണ് + നിങ്ങൾ നിരീക്ഷകനാണ് + നിങ്ങൾ അംഗമാണ് + നിങ്ങൾ ഉടമയാണ് തീർപ്പാക്കാത്തത് സന്ദേശം അയയ്ക്കുക തത്സമയ സന്ദേശം അയയ്ക്കുക 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 1275c31573..eeddfd1b38 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 @@ -61,6 +61,7 @@ Legg til velkomstmelding Legg til dine teammedlemmer i samtalene. administrator + Du er administrator administratorer Administratorer kan blokkere ett medlem for alle. Administratorer kan lage lenker for å bli med i grupper. 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 307ffc78fa..2ec65534a4 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/nl/strings.xml @@ -936,6 +936,11 @@ Waarnemer jij bent waarnemer je bent waarnemer + Je bent waarnemer + Je bent lid + Je bent moderator + Je bent beheerder + Je bent eigenaar Systeem Audio en video oproepen Bevestig wachtwoord @@ -1278,7 +1283,7 @@ Ontvangst bevestiging uitschakelen\? Ontvangst bevestiging inschakelen\? Het verzenden van ontvangst bevestiging is ingeschakeld voor %d-contactpersonen - Deze instellingen gelden voor uw huidige profiel + Deze instellingen gelden voor uw huidige profiel Uitschakelen (overschrijvingen behouden) Inschakelen voor iedereen Inschakelen (overschrijvingen behouden) @@ -1722,7 +1727,7 @@ Sta het verzenden van SimpleX-links toe. Leden kunnen SimpleX-links verzenden. opgeslagen - opgeslagen van %s + opgeslagen van Doorsturen Doorgestuurd Ontvanger(s) kunnen niet zien van wie dit bericht afkomstig is. 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 0cf195510b..bf47cebdb5 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pl/strings.xml @@ -179,6 +179,11 @@ Oczekiwanie na film Oczekiwanie na film jesteś obserwatorem + Jesteś obserwatorem + Jesteś członkiem + Jesteś moderatorem + Jesteś administratorem + Jesteś właścicielem Jesteś obserwatorem Połączony Obecnie maksymalny obsługiwany rozmiar pliku to %1$s. @@ -1287,7 +1292,7 @@ Renegocjować szyfrowanie\? Wysyłanie potwierdzeń jest włączone dla %d kontaktów Szyfrowanie działa, a nowe uzgodnienie szyfrowania nie jest wymagane. Może to spowodować błędy w połączeniu! - Te ustawienia dotyczą Twojego bieżącego profilu + Te ustawienia dotyczą Twojego bieżącego profilu Można je nadpisać w ustawieniach kontaktu i grupy. szyfrowanie ok renegocjacja szyfrowania dozwolona @@ -1731,7 +1736,7 @@ Przekaż wiadomość… Zapisane zapisane - zapisane od %s + zapisane od Bluetooth Przesyłaj dalej i zapisuj wiadomości Słuchawki douszne 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 cbf69db5d4..3b28d2fd20 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 @@ -12,11 +12,11 @@ mudando endereço… Mudar cargo Mudar - Você e seu contato podem excluir mensagens enviadas de forma irreversível. (24 horas) + Você e seu contato podem apagar permanentemente as mensagens enviadas em até 24h. Pode ser desativado nas configurações – as notificações ainda serão exibidas enquanto o aplicativo estiver em execução.]]> Você e seu contato podem enviar mensagens de voz. - O aplicativo pode receber notificações apenas quando estiver em execução, nenhum serviço em segundo plano será iniciado - Sempre ativada + O aplicativo só recebe notificações enquanto está aberto. Nenhum serviço em segundo plano será iniciado + Sempre ativado Verifica novas mensagens a cada 10 minutos por até 1 minuto Autenticação indisponível Cancelar visualização do arquivo @@ -25,11 +25,11 @@ Voltar Arquivo Aceitar - Limpar bate-papo? + Limpar conversa? Limpar - Limpar bate-papo + Limpar conversa Limpar - cancelar pré-visualização do link + cancelar prévia do link cancelado %s Versão do Aplicativo: v%s chamando… @@ -39,8 +39,8 @@ Chamada em andamento Chamada encerrada Atender chamada - hash de mensagem incorreta - ID de mensagem incorreta + hash da mensagem incorreto + ID da mensagem incorreto Atenção: você NÃO poderá recuperar ou alterar a senha se a perder.]]> Não é possível receber o arquivo Cancelar visualização da imagem @@ -53,76 +53,76 @@ Aceitar Chamadas na tela de bloqueio: Áudio ligado - Banco de dados do bate-papo importado - Android Keystore é usada para armazenar a senha com segurança - permite que o serviço de notificação funcione. - A Android Keystore será usada para armazenar a senha com segurança depois que você reiniciar o aplicativo ou alterar a senha - isso permitirá o recebimento de notificações. + Banco de dados do chat importado + O Android Keystore é usado para armazenar a senha com segurança. Isso permite que o serviço de notificações funcione. + O Android Keystore será usado para armazenar a senha com segurança após você reiniciar o aplicativo ou alterar a senha, permitindo continuar recebendo notificações. Não é possível acessar a Keystore para salvar a senha do banco de dados - O bate-papo está parado + O chat está parado Limpar - Preferências de bate-papo - Perfil de bate-papo + Preferências do chat + Perfil de chat Áudio desligado Aceitar imagens automaticamente - Banco de dados do bate-papo excluído + Banco de dados do chat excluído Não é possível convidar o contato! - A otimização da bateria está ativa, desligando o serviço em segundo plano e as solicitações periódicas de novas mensagens. Você pode reativá-los através das configurações. + A otimização de bateria está ativa, desativando o serviço em segundo plano e a busca periódica por novas mensagens. Você pode reativá-los nas configurações. Não é possível inicializar o banco de dados Anexar Cancelar - Console de bate-papo + Console do chat Verifique o endereço do servidor e tente novamente. - para cada perfil de bate-papo que você tiver no aplicativo.]]> + para cada perfil de chat 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 + Consome mais bateria! O aplicativo em segundo plano está sempre em execução. As notificações são exibidas instantaneamente.]]> + Chats Ícone do aplicativo - Banco de dados de bate-papo - O bate-papo está em execução - O bate-papo está parado + Banco de dados do chat + O chat está em execução + O chat está parado Alterar senha do banco de dados\? - endereço alterado para você + mudou o endereço para você Você e seu contato podem enviar mensagens temporárias. Backup de dados do aplicativo 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. - para cada contato e membro do grupo. \nAtenção: se você tiver muitas conexões, o consumo de bateria e tráfego pode ser substancialmente maior e algumas conexões podem falhar.]]> + O serviço permanecerá ativo em segundo plano – as notificações serão exibidas assim que as mensagens chegarem. + para cada contato e membro do grupo.\nAtenção: se você tiver muitas conexões, o consumo de bateria e tráfego poderá ser substancialmente maior, e algumas conexões podem falhar.]]> Bom para bateria. O aplicativo procura por mensagens a cada 10 minutos. Você pode perder chamadas ou mensagens urgentes.]]> - chamda encerrada %1$s + chamada encerrada %1$s Converse com os desenvolvedores Criar link de grupo Criar link Criar grupo secreto Escuro - Conectar via link de convite único? - Conectar via endereço do contato? + Conectar via link de uso único? + Conectar pelo endereço do contato? Criar fila Nome de contato Contato oculto: Copiar Permitir Permitir enviar mensagens temporárias. - Permitir o envio de mensagens diretas aos membros. - Conectar via link/QR code - Todas as mensagens serão excluídas - isso não pode ser desfeito! As mensagens serão excluídas APENAS para você. - Adicionar servidores pré-definidos + Permitir o envio de mensagens diretas para os membros. + Conectar via link/QR Code + Todas as mensagens serão apagadas. Isso não pode ser desfeito! As mensagens serão apagadas APENAS para você. + Adicionar servidores predefinidos Adicionar servidor Crie seu perfil Ícone de contexto - Contato e todas as mensagens serão excluídas - isso não pode ser desfeito! + O contato e todas as mensagens serão apagados. Isso não pode ser desfeito! Copiado para a área de transferência Aceitar solicitação de conexão\? Configurações de rede avançadas Criar endereço Todos os seus contatos permanecerão conectados. chamada aceita - Contato tem criptografia e2e - contato não tem criptografia e2e + o contato tem criptografia de ponta a ponta + o contato não tem criptografia de ponta a ponta Preferências de contato - Permite excluir irreversivelmente as mensagens enviadas. (24 horas) + Permitir apagar permanentemente as mensagens enviadas em até 24h. sempre - Adicione servidores escaneando o QR code. + Adicione servidores escaneando o QR Code. Permitir enviar mensagens de voz. Criar grupo secreto Sempre usar relay @@ -131,52 +131,52 @@ Criar perfil Banco de dados criptografado! criador - Todos os chats e mensagens serão excluídos - isso não pode ser desfeito! + Todas as conversas e mensagens serão excluídas. Isso não pode ser desfeito! Aceitar Permitir mensagens temporárias apenas se o seu contato permitir. - Permita a exclusão irreversível da mensagem somente se o seu contato permitir. (24 horas) + Permitir apagar mensagens de forma irreversível apenas se seu contato permitir. (24 horas) Permitir que seus contatos enviem mensagens temporárias. Permitir mensagens de voz somente se o seu contato permitir. Permitir que seus contatos enviem mensagens de voz. administrador Todos os membros do grupo permanecerão conectados. - "Contatos podem marcar mensagens para exclusão; você será capaz de visualizá-los." - Se conectar ao grupo? + Os contatos podem marcar mensagens para exclusão; você poderá visualizá-las. + Entrar no grupo? Contato já existe Contato verificado Contato ainda não está conectado! Contribuir Criar - Acessar os servidores via proxy SOCKS na porta %d\? O proxy deve ser iniciado antes de habilitar esta opção. - Permitir que seus contatos excluam de forma irreversível as mensagens enviadas. (24 horas) + Acessar os servidores por meio do proxy SOCKS na porta %d? O proxy deve ser iniciado antes de ativar esta opção. + Permitir que seus contatos apaguem permanentemente as mensagens enviadas em até 24h. Adicionar a outro dispositivo - Os administradores podem criar os links para ingressar em grupos. + Os administradores podem criar links para que outras pessoas entrem nos grupos. Permitir mensagens de voz\? Excluir grupo Conexão Excluir perfil de chat\? - Excluir para todos + Apagar para todos Conectar conectado conectando apagado Conectar - Excluir + Apagar Conectar conectando chamada… - Excluir perfil de chat\? - Excluir arquivos de todos os perfis de bate-papo + Excluir perfil de chat? + Apagar arquivos de todos os perfis conectando… Erro de conexão - Excluir contato + Apagar contato Configurar servidores ICE Excluir endereço\? Descentralizado Excluir banco de dados - O banco de dados é criptografado usando uma senha aleatória. Por favor, altere-o antes de exportar. + O banco de dados é criptografado usando uma senha aleatória. Altere-a antes de exportar. Confirmar nova senha… Senha atual… - Senha do banco de dados é necessária para abrir o chat. + A senha do banco de dados é necessária para abrir o chat. cargo alterado de %s para %s conectado Excluir link @@ -185,49 +185,49 @@ Solicitação de conexão enviada! conectando conectando… - Excluir para mim + Apagar para mim conectando… - Excluir contato\? + Apagar contato? Confirmar Senha e exportação do banco de dados Conectando chamada - Excluir mensagens - Senha de criptografia do banco de dados será atualizada. - Erro de banco de dados - Senha do banco de dados é diferente da salva na Keystore. + Apagar mensagens + A senha de criptografia do banco de dados será atualizada. + Erro no banco de dados + A senha do banco de dados é diferente da salva na Keystore. completo ID do banco de dados colorido conectado Conectado Conectado - chamada de áudio (não criptografada ponta-a-ponta) - Alterar a cargo do grupo? + chamada de áudio (sem criptografia de ponta a ponta) + Alterar cargo? chamada de áudio - mudou sua cargo para %s + mudou seu cargo para %s Compare os códigos de segurança com seus contatos. Confirme sua credencial conectando… conectando (anunciado) Conexão - Erro de conexão + Link de conexão removido conexão estabelecida conexão %1$d Atualmente, o tamanho máximo de arquivo suportado é %1$s. - Excluir - Ssnha de criptografia do banco de dados será atualizada e armazenada na Keystore. + Apagar + A senha de criptografia do banco de dados será atualizada e armazenada na Keystore. Excluir endereço - O banco de dados é criptografado usando uma senha aleatórioa, você pode alterá-la. + O banco de dados é criptografado usando uma senha aleatória, você pode alterá-la. Senha do banco de dados O banco de dados será criptografado e a senha será armazenada na Keystore. O banco de dados será criptografado. %d dia Erro de decodificação - Excluir + Apagar %d dias Excluir todos os arquivos - Excluir mensagem\? - Excluir depois + Apagar mensagem? + Apagar após Excluir perfil de chat para grupo excluído Excluir grupo\? @@ -236,21 +236,21 @@ conectado conectando (aceito) %dd - Por perfil de bate-papo (padrão) ou por conexão (BETA). - Aceitar anônimo - Excluir mensagens após - Escanear QR code]]> + Por perfil de chat (padrão) ou por conexão (BETA). + Aceitar como anônimo + Apagar mensagens após + Escanear QR Code.]]> Excluir conexão pendente\? Descrição Excluir servidor conectando (convite de introdução) Tempo de conexão esgotado - Excluir mensagem do membro\? + Apagar mensagem do membro? Excluir fila Dispositivo Ferramentas de desenvolvedor conectando (introduzido) - Tonalidade + Destaque Erro ao remover membro Erro ao alterar cargo direto @@ -258,7 +258,7 @@ Falha ao carregar a conversa Erro ao atualizar a configuração de conexão Erro ao enviar mensagem - Erro ao adicionar membro(s) + Não foi possível adicionar os membros Desconectar Erro ao excluir perfil do usuário %ds @@ -268,19 +268,19 @@ Erro ao receber arquivo Erro ao criar endereço Nome de exibição: - Erro ao iniciar o bate-papo - Erro ao excluir banco de dados de chat + Erro ao iniciar o chat + Erro ao excluir o banco de dados do chat Criptografar - Ativar TCP manter-vivo + Ativar TCP keep-alive Erro ao criar perfil! Erro ao ingressar no grupo Nome de exibição duplicado! - Erro ao excluir contato + Erro ao apagar contato Erro ao alterar endereço Erro ao excluir conexão de contato pendente Editar Ativar exclusão automática de mensagens\? - %d sec + %d seg Erro ao salvar servidores SMP Erro ao aceitar solicitação de contato Erro ao excluir solicitação de contato @@ -293,14 +293,14 @@ Erro ao salvar servidores ICE Sair sem salvar Digite o seu nome: - chamada de vídeo criptografada ponta-a-ponta + chamada de vídeo criptografada de ponta a ponta mensagem duplicada - criptografado ponta-a-ponta + criptografado de ponta a ponta Exportar banco de dados %d arquivo(s) com tamanho total de %s - Erro ao exportar banco de dados do bate-papo - Erro ao importar banco de dados do bate-papo - Erro ao interromper o bate-papo + Erro ao exportar o banco de dados do chat + Erro ao importar o banco de dados do chat + Erro ao parar o chat Erro ao alterar configuração Erro ao criptografar o banco de dados Banco de dados criptografado @@ -312,7 +312,7 @@ Erro ao salvar o perfil do grupo Mensagens diretas ativado - ativado para contato + ativado para o contato ativado para você %dm %d min @@ -325,7 +325,7 @@ Mensagens temporárias são proibidas. Erro ao salvar arquivo O nome de exibição não pode conter espaços em branco. - chamada de áudio criptografada ponta-a-ponta + chamada de áudio criptografada de ponta a ponta Editar imagem Insira o servidor manualmente Erro ao excluir grupo @@ -335,7 +335,7 @@ Mensagens diretas entre membros são proibidas neste grupo. %dh %d horas - anônimo via link de endereço de contato + anonimamente pelo link de endereço de contato anônimo via link de uso único Ocultar Da Galeria @@ -349,16 +349,16 @@ Imagem enviada Link do grupo Importar banco de dados - O convite de grupo não é mais válido, foi removido pelo remetente. + O convite para o grupo não é mais válido; ele foi removido pelo remetente. Grupo inativo Grupo não encontrado! - O grupo será excluído para você - isso não pode ser desfeito! + O grupo será excluído para você. Isso não pode ser desfeito! Grupo indireto (%1$s) Anônimo Mensagens que desaparecem Preferências do grupo - Mensagens temporárias são proibidas nesse bate-papo. + Mensagens temporárias são proibidas neste chat. Os membros podem enviar mensagens diretas. %dmês Link completo @@ -368,7 +368,7 @@ Oculto Como usar seus servidores Importar - Importar banco de dados de bate-papo? + Importar banco de dados do chat? Digite o nome de exibição do grupo: Nome completo do grupo: Links de grupo @@ -377,21 +377,21 @@ Arquivo: %s Arquivo salvo Os membros podem enviar mensagens de voz. - O grupo será excluído para todos os membros - isso não pode ser desfeito! + O grupo será excluído para todos os membros. Isso não pode ser desfeito! Ajuda Ocultar contato e mensagem Como usar Como usar markdown - mostre o QR code na video chamada ou compartilhe o link.]]> - escanear o QR code na video chamada ou seu contato pode compartilhar um link de convite.]]> + exiba o QR Code na videochamada ou compartilhe o link.]]> + escanear o QR Code em uma videochamada, ou pedir que o contato compartilhe um link de convite.]]> Se você confirmar, os servidores de mensagens poderão ver seu endereço IP e seu provedor - e quais servidores você está se conectando. Imagem Se você recebeu o link de convite SimpleX Chat, você pode abri-lo em seu navegador: Imagem salva na Galeria - anônimo via link do grupo + anonimamente pelo link do grupo Chamada de vídeo recebida - Permita na próxima caixa de diálogo para receber notificações instantaneamente]]> - Gerar um link de convite de uso único + Permita na próxima caixa de diálogo para receber notificações instantaneamente.]]> + Gerar link de uso único Arquivo não encontrado Se você optar por rejeitar o remetente NÃO será notificado. Código de segurança incorreto! @@ -403,9 +403,9 @@ Modo anônimo Cargo inicial perfil do grupo atualizado - Grupo excluído - O modo Incognito protege sua privacidade usando um novo perfil aleatório para cada contato. - Os membros grupo podem excluir mensagens enviadas de forma irreversível. (24 horas) + grupo excluído + O modo anônimo protege sua privacidade gerando um perfil aleatório novo para cada contato. + Os membros podem apagar permanentemente as mensagens enviadas em até 24h. %dsemana Configuração de servidor aprimorada Interface francesa @@ -413,7 +413,7 @@ Ative as chamadas pela tela de bloqueio nas configurações. Arquivos & mídia Erro ao atualizar o link do grupo - O convite do grupo expirou + O convite para o grupo expirou O arquivo será recebido quando seu contato estiver online, aguarde ou verifique mais tarde! O perfil do grupo é armazenado nos dispositivos dos membros, não nos servidores. ajuda @@ -424,44 +424,44 @@ Recebendo via Status da conexão seg - Permite ter várias conexões anônimas sem nenhum dado compartilhado entre elas em um único perfil de bate-papo. - Sim + Permite múltiplas conexões anônimas sem dados compartilhados entre si em um único perfil de chat. + sim Seu perfil será enviado para o contato do qual você recebeu esse link. Definir 1 dia k Você se conectará a todos os membros do grupo. - marcado como excluído + marcado como apagado o envio de arquivos ainda não é suportado Proibir o envio de mensagens de voz. - convidado à conectar + convidado a se conectar você compartilhou um link anônimo de uso único Links SimpleX - Por favor, atualize o app e contate os desenvolvedores. - O servidor requer autorização para criar filas, verifique a senha + Atualize o aplicativo e entre em contato com os desenvolvedores. + O servidor exige autorização para criar filas. Verifique a senha. Revelar Pendente - Proibir o envio de mensagens diretas para membros. + Proibir o envio de mensagens diretas para os membros. Escanear QR Code - Rejeitar + Recusar ofereceu %s Endereço SimpleX ofereceu %s: %2s Novo em %s Envie-nos um email - Escanear QR code do servidor + Escanear QR Code do servidor Seus servidores SMP nunca - Restaurar o backup do banco de dados\? + Restaurar backup do banco de dados? moderado O remetente cancelou a transferência de arquivos. - Por favor, cheque sua conexão com a rede com %1$s e tente de novo. + Verifique sua conexão de rede com %1$s e tente novamente. Recebendo mensagens… Arquivo grande! - Marcado como lido + Marcar como lida Você convidou seu contato - QR code inválido + QR Code inválido Mais - Você será conectado ao grupo quando o dispositivo do host do grupo estiver online, por favor aguarde ou verifique mais tarde! + Você será conectado ao grupo quando o dispositivo que hospeda o grupo estiver online. Por favor, aguarde ou verifique mais tarde. Essa string não é um link de conexão! Quando disponível Compilação do aplicativo: %s @@ -471,67 +471,67 @@ Quando o aplicativo está em execução Periódico Suas chamadas - Arquivo de banco de dados antigo + Banco de dados antigo Convidar membros Nenhum contato selecionado Salvar Redefinir cores - interface italiana + Interface italiana Notificações periódicas Câmera Seus servidores ICE Seus servidores ICE - Sua privacidade - Juntar-se ao grupo\? + Privacidade + Entrar no grupo? Sair - Você já possui um perfil de bate-papo com o mesmo nome. Por favor escolha outro nome. - Por favor, cheque se você usou o link correto ou peça ao seu contato para enviar outro. - Abrir console de bate-papo - juntar-se como %s + Você já tem um perfil de chat com este mesmo nome de exibição. Escolha outro nome. + Por favor, verifique se você usou o link correto ou peça ao seu contato para enviar outro. + Abrir console do chat + Entrar como %s Por favor, peça ao seu contato para ativar o envio de mensagens de voz. OK (armazenado apenas por membros do grupo) Permissão negada! Usar proxy SOCKS\? Chamada rejeitada - Restaurar o backup do banco de dados + Restaurar backup do banco de dados Para console - Executar bate-papo + Executar chat Parar Definir senha para exportar - Reinicie o aplicativo para usar o banco de dados do chat importado. + Reinicie o aplicativo para usar o banco de dados importado. Reinicie o aplicativo para criar um novo perfil de chat. Essa ação não pode ser desfeita - todos os arquivos e mídias recebidos e enviados serão excluídos. Imagens de baixa resolução permanecerão. - Você deve usar a versão mais recente de seu banco de dados de bate-papo SOMENTE em um dispositivo, caso contrário, você pode parar de receber as mensagens de alguns contatos. + Você deve usar a versão mais recente do banco de dados do chat em APENAS um dispositivo, caso contrário poderá parar de receber as mensagens de alguns contatos. Sem arquivos enviados ou recebidos Mensagens - Esta configuração aplica-se às mensagens no seu perfil de chat atual - erro na Keychain - Por favor, guarde a senha em um local seguro, você não poderá acessar o bate-papo se perdê-lo. + Esta configuração se aplica às mensagens do seu perfil atual + Erro no Keychain + Por favor, guarde a senha em um local seguro. Você NÃO poderá acessar as mensagens se perdê-la. Guarde a senha em um local seguro, você NÃO poderá alterá-la se a perder. Restaurar - Por favor, digite a senha antiga depois de recuperar o backup do banco de dados. Essa ação não pode ser desfeita. - Senha incorreta - Você pode iniciar o bate-papo via Configurações / Banco de dados ou reiniciando o aplicativo. + Insira a senha anterior após restaurar o backup do banco de dados. Esta ação não pode ser desfeita. + Senha incorreta! + Você pode iniciar o chat pelas Configurações do aplicativo / Banco de dados ou reiniciando o aplicativo. Convite expirado! - Você deixará de receber mensagens deste grupo. O histórico do bate-papo será preservado. + Você deixará de receber mensagens deste grupo. O histórico do chat será preservado. removeu você - convidado%1$s + convidado %1$s você alterou o endereço para %s observador membro Sair do grupo - %1$s MEMBROS + %1$s membros você: %1$s Nome local Salvar perfil do grupo Intervalo de PING - Atualizar configurações de conexão\? - contagem de PING - Conexões de servidor e perfil + Atualizar configurações de rede? + Contagem de PING + Perfil e conexões com o servidor Suas preferências Definir preferências de grupo - Somente você pode excluir irreversivelmente as mensagens (seu contato pode marcá-las para exclusão). (24 horas) + Somente você pode apagar mensagens de forma irreversível (seu contato pode marcá-las para exclusão). (24 horas) A exclusão irreversível de mensagens é proibida. Os destinatários vêem as atualizações conforme você as digita. Uso da bateria reduzido @@ -547,21 +547,21 @@ Abrir o link no navegador pode reduzir a privacidade e a segurança da conexão. Links SimpleX não confiáveis ficarão vermelhos. Link de conexão inválido Certifique-se de que os endereços do servidor SMP estejam no formato correto, separados por linhas e não estejam duplicados. - O remetente pode ter excluído a solicitação de conexão. + O remetente excluiu a solicitação de conexão. Notificações periódicas estão desativadas! As notificações instantâneas estão desativadas! - SimpleX executa em segundo plano em vez de usar notificações push.]]> - Executa quando o aplicativo está aberto + o SimpleX roda em segundo plano em vez de usar notificações push.]]> + Apenas com o aplicativo aberto enviado o envio falhou Conversas Colar Link de convite de uso único - Enviar perguntas e idéias + Enviar perguntas e ideias Endereço de servidor inválido! Os servidores WebRTC ICE salvos serão removidos. Salvar e notificar contatos - Seu perfil, contatos e mensagens enviadas são guardados no seu dispositivo. + Seu perfil, contatos e mensagens entregues ficam armazenados no seu dispositivo. Por favor, digite a senha correta. Juntando-se ao grupo Entrar como anônimo @@ -572,26 +572,26 @@ Novo cargo de membro Remover Membro - O membro será removido do grupo - isso não pode ser desfeito! + O membro será removido do grupo. Isso não pode ser desfeito! Cargo Enviando via Proibir o envio de mensagens temporárias. - A exclusão irreversível de mensagens é proibida neste bate-papo. + A exclusão irreversível de mensagens é proibida neste chat. Proibir o envio de mensagens de voz. - No máximo 40 segundos, recebido instantaneamemte. - Você não pode enviar mensagens! - Por favor, contate o administrador do grupo. + No máximo 40 segundos, recebido instantaneamente. + observador + Por favor, entre em contato com o administrador do grupo. Markdown em mensagens Servidores SMP - Endereço do servidor pré-definido + Endereço do servidor predefinido Rejeitar %1$d mensagem(ens) ignorada(s) Proteger a tela do aplicativo Enviar prévias de links Privacidade e segurança - Junte-se - Você pode compartilhar um link ou um QR code - qualquer um poderá entrar no grupo. Você não perderá membros do grupo se você deletá-los mais tarde. - Somente dados de perfil local + Entrar + Você pode compartilhar um link ou um QR Code. Qualquer pessoa poderá entrar no grupo. Excluir o link ou o QR Code não remove os membros do grupo. + Apenas o perfil local Somente você pode enviar mensagens temporárias. Somente seu contato pode enviar mensagens temporárias. Notificações instantâneas @@ -599,74 +599,72 @@ Erro ao atualizar a privacidade do usuário A senha é necessária Prévia de notificação - Serviço de notificação + Serviço de notificações Mensagem de texto nova mensagem Nova solicitação de contato - Você será solicitado a autenticar quando iniciar ou voltar ao aplicativo após 30 segundos em segundo plano. + Você precisará se autenticar ao abrir ou retornar ao aplicativo após 30 segundos em segundo plano. Responder Inicie sessão com a sua credencial Erro na entrega da mensagem Provavelmente esse contato excluiu a conexão com você. Salvar - A mensagem será excluída - isso não pode ser desfeito! + A mensagem será apagada. Isso não pode ser desfeito! A mensagem será marcada para exclusão. O(s) destinatário(s) poderá(ão) revelar esta mensagem. Moderar Excesso de imagens! Apenas 10 imagens podem ser enviadas ao mesmo tempo Notificações Definir nome do contato… - O endereço de recebimento será alterado para um servidor diferente. A mudança de endereço terminará após o remetente ficar online. + O endereço de recebimento será alterado para outro servidor. A alteração do endereço será concluída quando o remetente ficar online. Enviar Redefinir Mensagem ao vivo! - Enviar uma mensagem ao vivo - ela será atualizada para o(s) destinatário(s) conforme você a digita + Envie uma mensagem ao vivo: ela será atualizada para os destinatários enquanto você digita (para compartilhar com seu contato) (escanear ou colar da área de transferência) Você aceitou a conexão - Marcado como não lido - Mutar + Marcar como não lida + Silenciar Definir nome do contato Link inválido! - Esse QR code não é um link! - Seu perfil de bate-papo será enviado -\npara seu contato + Esse QR Code não é um link! + Seu perfil de chat será enviado\nao seu contato Você será conectado quando o dispositivo do seu contato estiver online, aguarde ou verifique mais tarde! Como Teste do servidor falhou! - Onion hosts não serão usados. - Os hosts Onion serão necessários para a conexão. -\nAtenção: você não será capaz de se conectar aos servidores sem um endereço .onion + Os hosts .onion não serão usados. + Os hosts .onion serão obrigatórios para a conexão.\nAtenção: você não conseguirá se conectar aos servidores sem um endereço .onion. Versão principal: v%s repositório do GitHub.]]> Como isso afeta a bateria %1$s quer se conectar com você via - sem criptografia ponta-a-ponta + sem criptografia de ponta a ponta Abrir O servidor de relay é usado apenas se necessário. Terceiros podem observar seu endereço IP. ponto-a-ponto Chamada pendente - Novo arquivo de banco de dados - As notificações serão entregues até o aplicativo parar! + Novo banco de dados + As notificações só serão entregues até o aplicativo ser encerrado! Remover senha da Keystore\? Nova senha… - Erro na restauração do banco de dados - Abrir bate-papo - Salvar senha e abrir bate-papo + Erro ao restaurar banco de dados + Abrir chat + Salvar senha e abrir o chat removido %1$s Tempo limite do protocolo recebido, proibido Ocultar Digite a senha na pesquisa Não mostrar novamente - Somente seu contato pode excluir irreversivelmente mensagens (você pode marcá-las para exclusão). (24 horas) + Somente seu contato pode apagar mensagens de forma irreversível (você pode marcá-las para exclusão). (24 horas) Interface chinesa e espanhola Maior redução no uso da bateria Mais melhorias chegarão em breve! Você Mensagens e arquivos - Seu banco de dados de bate-papo - Você removeu %1$s + Seu banco de dados do chat + você removeu %1$s removido saiu proprietário @@ -676,12 +674,12 @@ Esta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão irreversivelmente perdidos. Remover Senha do banco de dados incorreta - Senha não encontrada na Keystore, por favor digite-a manualmente. Isso pode ter ocorrido se você recuperou os dados do app usando uma ferramenta de backup. Se esse não é o caso, por favor, contate os desenvolvedores. - Você se juntou a este grupo. Conectando-se a um membro convidado do grupo. + Senha não encontrada na Keystore. Digite-a manualmente. Isso pode ter ocorrido se você recuperou os dados do aplicativo usando uma ferramenta de backup. Se esse não for o caso, entre em contato com os desenvolvedores. + Você entrou neste grupo. Conectando-se ao membro que fez o convite. Sair do grupo\? Este grupo não existe mais. Salvar e atualizar perfil do grupo - Você aceita + Você permite não Agora administradores podem: \n- excluir mensagens de membros. @@ -689,11 +687,11 @@ Moderação do grupo Mensagem de boas-vindas do grupo Desatualizar banco de dados - migração diferente no aplicativo/banco de dados: %s / %s + divergência no aplicativo/banco de dados: %s / %s Convidar para o grupo Sem contatos para adicionar - O cargo será alterada para "%s". Todos no grupo serão notificados. - Mutar + O cargo será alterado para %s. Todos no grupo serão notificados. + Silenciar Somente você pode enviar mensagens de voz. Somente seu contato pode enviar mensagens de voz. Proibir a exclusão irreversível de mensagens. @@ -701,7 +699,7 @@ Exclusão irreversível de mensagens Mensagens enviadas serão excluídas depois do tempo definido. Mensagens ao vivo - Vários perfis de bate-papo + Vários perfis de chat Perfis de chat ocultos Rascunho de mensagem Preservar o último rascunho, com anexos. @@ -711,55 +709,52 @@ Para conectar via link QR Code Marcar como verificado - Para verificar a criptografia de ponta-a-ponta com seu contato, compare (ou escaneie) o código em seus dispositivos. + Para verificar a criptografia de ponta a ponta com seu contato, compare (ou escaneie) o código nos seus dispositivos. Salvar servidores\? - Os servidores para novas conexões do seu perfil de chat atual + Estes são os servidores para novas conexões do seu perfil de chat atual: Avalie o aplicativo - IDs de banco de dados e opção de isolamento de transporte. + IDs do banco de dados e opção de isolamento de transporte. O perfil é compartilhado apenas com seus contatos. segredo - Itálico + itálico chamada perdida Você pode usar markdown para formatar mensagens: chamada rejeitada saiu Versão do banco de dados incompatível Remover membro - Seu perfil de bate-papo será enviado aos membros do grupo + Seu perfil de chat será enviado aos membros do grupo Torne o perfil privado! Avaliação de segurança Nomes diferentes, avatares e isolamento de transporte. Com mensagem de boas-vindas opcional. - Senha de perfil oculta - Senha a ser exibida + Senha do perfil oculto + Senha para exibir Confirmar senha Salvar senha do perfil Chamada perdida Salvar senha na Keystore - Seu banco de dados de bate-papo não está criptografado - defina uma senha para protegê-lo. + Seu banco de dados do chat não está criptografado — defina uma senha para protegê-lo. convite para o grupo %1$s - Você esta usando um perfil anônimo para este grupo - para evitar compartilhar seu perfil principal, convidar contatos não é permitido + Você está usando um perfil anônimo neste grupo. Para não expor seu perfil principal, não é possível convidar contatos. Confirmação de migração inválida Migrações: %s - versão do banco de dados é mais recente do que o aplicativo, mas não há migração para: %s + a versão do banco de dados é superior à do aplicativo, mas não é possível reverter para: %s Você entrou neste grupo você saiu Novidades Chamadas de áudio e vídeo - Proteja seus perfis de bate-papo com uma senha! + Proteja seus perfis de chat com uma senha! Este texto está disponível nas configurações Escanear código - Os hosts Onion serão usados quando disponíveis. + Os hosts .onion serão usados quando estiverem disponíveis. Seu perfil atual Privacidade redefinida Notificações privadas Fazer uma conexão privada Você decide quem pode se conectar. - Pode acontecer quando: -\n1. As mensagens expiraram no remetente após 2 dias ou no servidor após 30 dias. -\n2. A descriptografia da mensagem falhou porque você ou seu contato usou o backup do banco de dados antigo. -\n3. A conexão foi comprometida. - Você tem que digitar a senha toda vez que o aplicativo iniciar - ela não é armazenada no dispositivo. + Isso pode acontecer quando:\n1. As mensagens expiraram no remetente após 2 dias ou no servidor após 30 dias.\n2. A descriptografia da mensagem falhou porque você ou seu contato usou um backup antigo do banco de dados.\n3. A conexão foi comprometida. + Você deverá inserir a senha sempre que o aplicativo for iniciado – ela não fica armazenada no dispositivo. Salvar mensagem de boas-vindas\? Excluir perfil Senha de perfil @@ -768,25 +763,25 @@ Apenas 10 vídeos podem ser enviados ao mesmo tempo Seu contato enviou um arquivo maior que o tamanho máximo permitido (%1$s). O arquivo será recebido quando seu contato concluir o upload. - Alguns servidores falharam o teste: + Alguns servidores falharam no teste: Salvar servidores Código de segurança imagem de perfil Gravar mensagem de voz Certifique-se de que os endereços do servidor WebRTC ICE estão em formato correto, separados por linha e não estejam duplicados. - Conexão e servidores + Rede e servidores Configurações avançadas sem detalhes - Link de convite de uso único + Link de uso único Seu endereço de servidor Configurações - %s é verificado - %s não é verificado - Começar novo chat + %s foi verificado + %s não foi verificado + Iniciar nova conversa Usar servidor Você precisa permitir que seu contato envie mensagens de voz para poder enviá-las também. - Seu perfil de bate-papo - Servidor pré-definido + Seus perfis de chat + Servidor predefinido Escaneie o código de segurança do aplicativo do seu contato. Enviar mensagem Requerido @@ -802,13 +797,13 @@ O servidor de relay protege seu endereço IP, mas pode observar a duração da chamada. Experimental você alterou o endereço - Atualização do banco de dados + Atualizar banco de dados O cargo será alterado para "%s". O membro receberá um novo convite. Somente os proprietários do grupo podem alterar as preferências do grupo. Adicionar mensagem de boas-vindas Mensagem de boas-vindas Enviar mensagem direta - Mutado quando inativo! + Silenciado quando inativo! Você ainda receberá chamadas e notificações de perfis silenciados quando eles estiverem ativos. Excluir perfil de chat Salvar @@ -816,7 +811,7 @@ Seu contato precisa estar online para completar a conexão. \nVocê pode cancelar esta conexão e remover o contato (e tentar mais tarde com um novo link). SimpleX - Somente o proprietários de grupo podem ativar mensagens de voz + Somente os proprietários do 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 @@ -831,32 +826,32 @@ via link de uso único Compartilhar link de uso único Usar hosts .onion - chamada de vídeo (não criptografada ponta-a-ponta) - Parar bate-papo? + chamada de vídeo (sem criptografia de ponta a ponta) + Parar o chat? Pelo navegador Você já está conectado a %1$s. O banco de dados não está funcionando corretamente. Toque para saber mais Aguardando a imagem - Mostrar QR code + Mostrar QR Code Abrir no aplicativo móvel.]]> - Servidores de teste + Testar servidores Atualizar o modo de isolamento de transporte\? Vídeo ativado Aguardando o arquivo Toque no botão - Para começar um novo bate-papo + Para começar uma nova conversa Ligar Bem-vindo(a)! O futuro da transmissão de mensagens 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. + Não foi possível concluir a alteração da senha do banco de dados. + Pare o chat para exportar, importar ou excluir o banco de dados. Não será possível enviar ou receber mensagens enquanto o chat estiver parado. %s segundo(s) - Erro de banco de dados desconhecido: %s + Erro desconhecido no banco de dados: %s Erro desconhecido - Toque para juntar-se + Toque para entrar Toque para entrar no modo anônimo - Você está convidado para o grupo + Você recebeu um convite para o grupo A atualização das configurações reconectará o cliente a todos os servidores. Atualizar Sistema @@ -866,38 +861,44 @@ formato de mensagem desconhecido via link de grupo via %1$s - A menos que seu contato tenha excluído a conexão ou este link já tenha sido usado, pode ser um bug - por favor, relate-o. \nPara se conectar, peça ao seu contato para criar outro link de conexão e verifique se você tem uma conexão de rede estável. + Seu contato removeu este link ou o link de uso único já foi utilizado.\nPara se conectar, solicite um novo link ao seu contato. O teste falhou na etapa %s. - Inicia periodicamente + Iniciar periodicamente envio não autorizado - Toque para iniciar um novo bate-papo + Toque para iniciar uma nova conversa Você não tem conversas aguardando resposta… - Seu banco de dados de bate-papo atual será EXCLUÍDO e SUBSTITUÍDO pelo importado. -\nEsta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão perdidos de forma irreversível. + Seu banco de dados atual será EXCLUÍDO e SUBSTITUÍDO pelo importado.\nEsta ação não pode ser desfeita - seu perfil, contatos, mensagens e arquivos serão completamente destruídos. Atualizar senha do banco de dados perfil de grupo atualizado Trocar - Totalmente decentralizado - visível apenas para os membros. + Totalmente descentralizado — visível apenas para os membros. você é um observador + Você é um observador + Você é um membro + Você é um moderador + Você é um administrador + Você é um proprietário + Você é um inscrito + Você é um colaborador Mensagem de voz (%1$s) Compartilhar link - Para proteger a privacidade, SimpleX usa identificadores separados para cada um de seus contatos. + Para proteger sua privacidade, o SimpleX usa IDs separados para cada um dos seus contatos. chamada de vídeo Mostrar Servidores ICE WebRTC 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. + O aplicativo 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 Serviço de Chat SimpleX Mostrar prévia Mostrar contato e mensagem Mostrar somente contato Compartilhar - Parar conversa + Parar chat Desbloquear - A mensagem será excluída para todos os membros. + A mensagem será apagada para todos os membros. A mensagem será marcada como moderada para todos os membros. Compartilhar mídia… Aguardando a imagem @@ -906,30 +907,30 @@ Mensagem de voz Mensagem de voz… Alterar endereço de recebimento\? - Desmutar + Ativar som Logo SimpleX Esse link não é válido! Servidor de teste simplexmq: v%s (%2s) - Isolamento de transporte - Você controla sua conversa! + Isolar transporte + Você controla seu chat! Sem identificadores de usuário. Alto-falante ligado Vídeo desativado Alto-falante desligado - Você pode ocultar ou mutar um perfil de usuário - segure-o para abrir o menu. + Você pode ocultar ou silenciar um perfil de usuário: toque e segure para ver o menu. Obrigado aos usuários – contribua via Weblate! Ignorar o convite aos membros A segurança do SimpleX foi auditada pelo Trail of Bits. Seus contatos podem permitir a exclusão completa da mensagem. Obrigado aos usuários – contribua via Weblate! - Isolamento de transporte + Isolar transporte Obrigado aos usuários – contribua via Weblate! - Você está convidado para o grupo. Junte-se para se conectar com os membros do grupo. - Quando você compartilha um perfil anônimo com alguém, esse perfil será usado para os grupos aos quais essa pessoa o convidar. + Você recebeu um convite para o grupo. Entre para se conectar com os demais membros. + Quando você compartilhar um perfil anônimo com alguém, esse perfil será usado nos grupos para os quais essa pessoa te convidar. Mensagem de boas-vindas Mostrar - Desmutar + Ativar som Mensagens de voz se conectar aos desenvolvedores do SimpleX Chat para fazer qualquer pergunta e receber atualizações.]]> A conexão que você aceitou será cancelada! @@ -939,15 +940,15 @@ Usando servidores SimpleX Chat. Mostrar opções para desenvolvedores Suporte bluetooth e outras melhorias. - Para revelar seu perfil oculto, digite uma senha em um campo de busca em sua página de perfis de bate-papo. - Atualizar e abrir bate-papo + Para mostrar seu perfil oculto, insira a senha completa no campo de busca da página Seus perfis de chat. + Atualizar e abrir o chat Atenção: você pode perder alguns dados! - Você rejeitou um convite de grupo + Você recusou o convite do grupo não lida Compartilhar mensagem… Bem-vindo(a) %1$s! - você está convidado para o grupo - Usar bate-papo + Você está convidado para o grupo + Usar chat Mensagens de voz são proibidas neste chat. Vídeo Vídeo enviado @@ -963,22 +964,22 @@ Mostrar: iniciando… aguardando confirmação… - Não armazenamos nenhum dos seus contatos ou mensagens (uma vez entregues) nos servidores. + Não armazenamos nenhum dos seus contatos nem suas mensagens (uma vez entregues) nos servidores. Mensagens ignoradas Toque para ativar o perfil. Mostrar perfil de chat Mostrar perfil - Tentando se conectar ao servidor utilizado para receber mensagens deste contato (erro:%1$s). - Tentando se conectar ao servidor utilizado para receber mensagens deste contato. - Você está conectado ao servidor usado para receber mensagens desse contato. + Não foi possível conectar ao servidor que recebe mensagens desta conexão: %1$s. + Tentando se conectar ao servidor utilizado para receber mensagens desta conexão. + Você está conectado ao servidor usado para receber mensagens desta conexão. Seu servidor Bloqueio SimpleX - Possivelmente, a impressão digital do certificado no endereço do servidor está incorreta + A impressão digital do endereço do servidor não corresponde à do certificado. Você enviou um convite de grupo Seu perfil aleatório Bloqueio SimpleX ativado Seu endereço SimpleX - Redefinir para os padrões + Redefinir padrões imagem de pré-visualização do link Para proteger suas informações, ative o bloqueio SimpleX. \nVocê será solicitado a completar a autenticação antes que este recurso seja ativado. @@ -995,18 +996,18 @@ Bloqueio SimpleX Ajuda com Markdown Instantânea - Abra o bate-papo do SimpleX para aceitar a chamada + Abrir o SimpleX Chat para aceitar a chamada via relay desativado - Desatualizar e abrir o bate-papo + Desatualizar e abrir o chat desativado 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. + Esta ação não pode ser desfeita - as mensagens enviadas e recebidas antes do período selecionado serão excluídas. Isso 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. + Somente os dispositivos do usuário armazenam perfis, contatos, grupos e mensagens. Obrigado por instalar o SimpleX Chat! A plataforma de mensagens que protege sua privacidade e segurança. - Você está tentando convidar um contato com quem compartilhou um perfil anônimo para o grupo no qual está usando seu perfil principal + Você está tentando convidar um contato com quem compartilhou um perfil anônimo para o grupo no qual está usando seu perfil principal. Fila segura imagem de perfil temporária Erro ao carregar servidores SMP @@ -1015,10 +1016,10 @@ Certifique-se de que os endereços dos servidores XFTP estejam no formato correto, separados por linha e não estão duplicados. Criar arquivo Baixar arquivo - O servidor requer autorização para fazer upload, verifique a senha + O servidor requer autorização para fazer upload, verifique a senha. Enviar arquivo Comparar arquivo - Excluír arquivo + Excluir arquivo Seus servidores XFTP Autenticação falhou Mudar senha @@ -1029,7 +1030,7 @@ Imediatamente Sem senha de aplicativo Entrada de senha - Por favor, lembre-se ou guarde-a com segurança - não há como recuperar uma senha perdida! + Por favor, guarde a senha com segurança — não há como recuperar uma senha perdida! Modo de bloqueio SimpleX Autenticação do sistema Você não pôde ser verificado; por favor, tente novamente. @@ -1037,15 +1038,15 @@ Bloqueio SimpleX não ativado! Servidores XFTP porta %d - Configurações de proxy SOCKS + Configurações do proxy SOCKS Usar proxy SOCKS - Hospedar - Usar hosts .onion para não se o proxy SOCKS não oferecer suporte a eles.]]> + Host + Usar hosts .onion como “Não” se o proxy SOCKS não oferecer suporte a eles.]]> Porta Confirmar senha Senha incorreta Bloquear após - Enviar + Confirmar Mudar modo de bloqueio Modo de bloqueio Nova Senha @@ -1056,14 +1057,13 @@ Autenticação cancelada Habilitar bloqueio Senha alterada! - Você pode ativar o bloqueio SimpleX via configurações. - Hash de mensagem incorreta - O hash da mensagem anterior é diferente.\" + Você pode ativar o bloqueio SimpleX nas Configurações. + Hash da mensagem incorreto + O hash da mensagem anterior é diferente. %1$d mensagens falharam em serem descriptografadas. - ID de mensagem incorreta - A ID da próxima mensagem está incorreta (menor ou igual à anterior). -\nIsso pode acontecer por causa de algum bug ou quando a conexão está comprometida. - Isso pode acontecer quando você ou sua conexão usaram o backup do banco de dados antigo. + ID da mensagem incorreto + O ID da próxima mensagem está incorreto (menor ou igual ao anterior).\nIsso pode acontecer devido a algum problema ou quando a conexão está comprometida. + Isso pode acontecer quando você ou seu contato usa um backup antigo do banco de dados. Por favor, informe aos desenvolvedores. O recebimento do arquivo será interrompido. O envio do arquivo será interrompido. @@ -1077,8 +1077,7 @@ %1$d mensagens ignoradas. Chamadas de áudio/vídeo Chamadas de áudio/vídeo são proibidas. - " -\nDisponível em v5.1" + \nDisponível em v5.1 Você e seu contato podem fazer chamadas. Somente você pode fazer chamadas. Somente seu contato pode fazer chamadas. @@ -1087,7 +1086,7 @@ Permita que seus contatos liguem para você. Senha do aplicativo Rápido e sem esperar até que o remetente esteja online! - interface polonesa + Interface polonesa Defina-o em vez da autenticação do sistema. Obrigado aos usuários – contribua via Weblate! Vídeos e arquivos de até 1GB @@ -1096,9 +1095,9 @@ Revogar Sobre o endereço SimpleX Secundária adicional - Tonalidade adicional - Link de uso único - Adicione o endereço ao seu perfil, para que seus contatos possam compartilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. + Destaque adicional + link de uso único + Adicione o endereço ao seu perfil para que seus contatos possam compartilhá-lo com outras pessoas. A atualização do perfil será enviada aos seus contatos. Crie um endereço para permitir que as pessoas se conectem com você. Criar endereço SimpleX Continuar @@ -1107,8 +1106,8 @@ Tema escuro Fundo Todos os seus contatos permanecerão conectados. A atualização do perfil será enviada para seus contatos. - Aceitação automática - Personalizar o tema + Aceitar automaticamente + Personalizar tema Digite a mensagem de boas-vindas… Erro ao definir o endereço Saiba mais @@ -1116,18 +1115,18 @@ Certifique-se que o arquivo tenha a sintaxe YAML correta. Exporte o tema para ter um exemplo da estrutura do arquivo do tema. Exportar tema Erro ao importar tema - Menus & Alertas + Menus e avisos Mensagem recebida Mensagem enviada Título - Se não puderem se encontrar pessoalmente, mostre o QR code em uma chamada de vídeo ou compartilhe o link. - Para se conectar, seu contato pode ler o QR code ou usar o link no aplicativo. - Você pode compartilhar seu endereço como um link ou QR code - qualquer pessoa pode se conectar a você. + Se não puderem se encontrar pessoalmente, exiba o QR Code na videochamada ou compartilhe o link. + Para se conectar, seu contato pode ler o QR Code ou usar o link no aplicativo. + Você pode compartilhar seu endereço como link ou QR Code para que qualquer pessoa possa se conectar com você. 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. + Quando alguém solicitar conexão, você pode aceitar ou recusar. Cores da interface - compartilhar com os contatos + Compartilhar com contatos do SimpleX A atualização do perfil será enviada aos seus contatos. Salvar configurações\? Parar de compartilhar @@ -1137,23 +1136,23 @@ Convide amigos Vamos conversar no SimpleX Você pode criá-lo mais tarde - Compartilhar endereço - Você pode compartilhar esse endereço com seus contatos para que eles se conectem com %s. + Compartilhar endereço… + Você pode compartilhar este endereço com seus contatos para que eles se conectem com %s. Prévia Secundária Importar tema Guia do Usuário.]]> Digite a mensagem de boas-vindas... (opcional) - Salvar configurações de aceitação automática + Salvar configurações do endereço SimpleX Abrindo banco de dados… - Alterar perfis de conversa + Alterar perfis de chat Compartilhar endereço com os contatos? Seus contatos continuarão conectados. Todos os dados do aplicativo serão excluídos. - A senha do aplicativo é substituída por uma senha de auto-destruição. + A senha do aplicativo é substituída pela senha de autodestruição. Novo nome de exibição: Definir senha - Ativar auto-destruição + Ativar autodestruição Enviar mensagem temporária 1 minuto 30 segundos @@ -1161,25 +1160,25 @@ Horário personalizado Mensagem temporária Enviar - A senha de auto-destruição foi alterada! - Senha de auto-destruição - Senha de auto-destruição ativada! - Ativar senha de auto-destruição - Permitir reações à mensagens. - Somente você pode adicionar reações à mensagens. - Somente seu contato pode adicionar reações à mensagens. - Reações a mensagens são proibidas. + A senha de autodestruição foi alterada! + Senha de autodestruição + Senha de autodestruição ativada! + Ativar senha de autodestruição + Permitir reações. + Somente você pode adicionar reações. + Somente seu contato pode adicionar reações. + Reações proibidas. horas minutos segundos - Auto-destruição + Autodestruição Erro ao carregar detalhes Histórico Info Mensagem recebida Mensagem enviada - Um perfil chat vazio com o nome fornecido é criado, e o aplicativo é aberto normalmente. - Alterar o modo de auto-destruição + Um perfil de chat vazio com o nome fornecido é criado, e o aplicativo é aberto normalmente. + Alterar modo de autodestruição Se você digitar essa senha ao abrir o aplicativo, todos os dados do aplicativo serão excluídos irreversivelmente! Excluído em Recebido em @@ -1194,17 +1193,17 @@ Registro atualizado em: %s (atual) %s (atual) - Permitir que seus contatos adicionem reações à mensagens. - Você e seu contato podem adicionar reações à mensagens. + Permitir que seus contatos adicionem reações às mensagens. + Você e seu contato podem adicionar reações. Os membros podem adicionar reações. - Reações à mensagens são proibidas neste bate-papo. - Proibir reações à mensagens. + Reações são proibidas neste chat. + Proibir reações. personalizado Ler mais Todos seus dados são apagados quando inserido. Finalmente, nós os temos! 🚀 - Reações à mensagens - Senha de auto-destruição + Reações + Senha de autodestruição Mensagens melhores IU em japonês e português meses @@ -1214,16 +1213,16 @@ \n- tempo personalizado para desaparecer. \n- edição de histórico. semanas - Permitir reações à mensagens somente se o seu contato permitir. + Permitir reações apenas se seu contato permitir. Personalize e compartilhe temas de cores. Temas personalizados dias - Reações à mensagens - Proibir reações à mensagens. + Reações + Proibir reações. Enviado em: %s Enviado em - Alterar senha de auto-destruição - Se você digitar sua senha de auto-destruição ao abrir o aplicativo: + Alterar senha de autodestruição + Se você digitar sua senha de autodestruição ao abrir o aplicativo: sem texto Alguns erros não fatais ocorreram durante importação: Pesquisar @@ -1231,17 +1230,17 @@ Arquivos e mídia Erro ao cancelar alteração de endereço Abortar a mudança de endereço? - Abortar + Cancelar A alteração de endereço será cancelada. O endereço de recebimento antigo será usado. - Desligar\? + Desligar? Abortar alteração de endereço Tempo limite do protocolo por KB As notificações deixarão de funcionar até que você reinicie o aplicativo Em resposta a Somente os proprietários do grupo podem habilitar arquivos e mídia. - A criptografia está funcionando e o novo acordo de criptografia não é necessário. Pode resultar em erros de conexão! + A criptografia está funcionando e o novo acordo de criptografia não é obrigatório. Isso pode resultar em erros de conexão! concordando com criptografia para %s… - criptografia concordada + criptografia estabelecida renegociação de criptografia permitida código de segurança alterado Renegociar criptografia @@ -1252,10 +1251,10 @@ Correção não suportada pelo membro do grupo concordando com criptografia… Permitir o envio de arquivos e mídia. - App + Aplicativo criptografia OK renegociação de criptografia necessária - criptografia concordada para %s + criptografia estabelecida para %s renegociação de criptografia permitida para %s renegociação de criptografia necessária para %s Sem histórico @@ -1268,50 +1267,48 @@ Desligar Corrigir conexão Corrigir conexão\? - Sem conversas filtradas + Nenhuma conversa filtrada Renegociar Desfavoritar Renegociar a criptografia\? Reiniciar - Recibos de entrega! - Os recibos de entrega estão desabilitadas! + Confirmações de entrega! + Confirmações de entrega desativadas! Ativar para todos Desativar para todos - Desabilitar os recibos? + Desativar confirmações? Desativar (mantém alterações) Ativar (mantém alterações) - Ativar recibos? - Encontrar conversas mais rápido + Ativar confirmações? + Encontrar chats mais rápido Contatos - Enviar recibos de entrega para - Enviar confirmações está desativado para %d contatos. - Enviar confirmações está ativado para %d contatos. + Enviar confirmações de entrega para + O envio de confirmações está desativado para %d contatos + Enviar confirmações está ativado para %d contatos Enviar confirmações Mais algumas coisas - Até mesmo desabilitado na conversa. - Filtrar bate-papo não lidos e favoritos. + Mesmo quando desativado na conversa. + Filtrar chats não lidos e favoritos. Corrigir criptografia depois de restaurar os backups. Manter suas conexões Fazer uma mensagem desaparecer Confirmações de entrega de mensagens! - - entregas de mensagens mais estáveis. -\n- grupos um pouco melhores. -\n- e mais! + - entregas de mensagens mais estáveis.\n- grupos um pouco melhores.\n- e mais! Não ative Ativar - Enviar confirmações de entrega serão ativadas para todos os contatos. - Ocorreu um erro ao ativar as recibos de entrega! + As confirmações de entrega serão ativadas para todos os contatos. + Ocorreu um erro ao ativar as confirmações de entrega! Escolher arquivo - Conectar anônimamente + Conexão anônima Permitir Desativar notificações Sem chamadas de fundo Abrir configurações do aplicativo Um novo perfil aleatório será compartilhado. - Cole o link que você recebeu para se conectar com seu contato.. - Desativar recibos para grupos\? - Ativar recibos para grupos\? - Recibos de entrega estão desativados para %d grupos + Cole o link que você recebeu para se conectar com seu contato… + Desativar confirmações para grupos? + Ativar confirmações para grupos? + O envio de confirmações está desativado para %d grupos Ativar para todos os grupos Ativar (manter sobreposições do grupo) Desativar (manter sobreposições do grupo) @@ -1319,7 +1316,7 @@ Em breve! Entrega Nenhuma informação de entrega - Enviar recibos de entrega serão habilitados para todos os contatos em todos os perfis visíveis. + As confirmações de entrega serão ativadas para todos os contatos em todos os perfis de chat visíveis. %s e %s conectados Conectar diretamente\? Nenhuma conversa selecionada @@ -1329,25 +1326,25 @@ Uso de bateria do aplicativo / Irrestrito nas configurações do aplicativo.]]> 6 novos idiomas de interface Notas privadas - Com arquivos criptografados e mídia + Com arquivos criptografados e mídia. Colar o link para conectar! A barra de pesquisa aceita links de convite. Participe de conversas em grupo Não compatível! - QR code inválido + QR Code inválido Corrigir nome para %s? - diretamente conectado + conexão solicitada Este é o seu próprio endereço SimpleX! - Conexão terminada + Conexão encerrada %1$s!]]> Conexão interrompida Desktop está ocupado - %d mensagens bloqueadas pelo admnistrador - bloqueado pelo admnistrador + %d mensagens bloqueadas pelo administrador + bloqueado pelo administrador Notas privadas O aplicativo pode ser fechado após 1 minuto em segundo plano. - Procurar e colar link SimpleX - Adicionar contato: para criar um novo link de convite, ou conectar pelo link que você recebeu.]]> + Procurar ou colar link SimpleX + Adicionar contato: para criar um novo link de convite ou conectar-se por um link recebido.]]> Compartilhe este link de convite único Renegociação de criptografia falhou. e %d outros eventos @@ -1359,34 +1356,34 @@ Conexão interrompida Aguardando o desktop… Desktop conectado - Desktops vínculados + Desktops vinculados %s está faltando]]> Abrir porta na firewall Para permitir que um aplicativo móvel se conecte ao desktop, abra esta porta em seu firewall, se estiver ativado - %s]]> + %s]]> %s está ocupado]]> %s está inativo]]> %s está em mau estado]]> - A conexão com o desktop está em mau estado + A conexão com o desktop está instável Desktop foi desconectado - Desktop tem um código de convite errado + O desktop apresenta um código de convite incorreto O desktop tem uma versão não suportada. Por favor, certifique-se de usar a mesma versão em ambos os dispositivos Erro crítico Erro interno - Reiniciar o bate-papo + Reiniciar o chat Erro ao criar contato de membro Você pode habilitar mais tarde nas configurações - Você pode habilitá-los mais tarde pelo aplicativo nas configurações de Privacidade & Segurança + Você pode ativá-las mais tarde nas configurações de Privacidade. Verificar código com o desktop (novo)]]> - Repetir o pedido para se juntar? + Reenviar solicitação? Este recurso ainda não é compatível. Experimente o próximo lançamento. Você já pediu para se conectar por este endereço! - Você já está se conectando! + Já está se conectando! Conectar-se a você mesmo? Abrir grupo Você já está se conectando ao grupo por este link. - Você já está entrando no grupo! + Já está entrando no grupo! Conectar via link? Chamada de áudio Finalizar chamada @@ -1394,24 +1391,24 @@ Expandir Enviar mensagem direta para conectar Toque para escanear - Novo bate-papo + Nova conversa Ou mostrar este código Manter Manter convite não utilizado? - Ou escanear o QR code + Ou escanear o QR Code Toque para colar o link O texto que você colou não é um link SimpleX. Você pode visualizar o código de convite novamente nos detalhes de conexão. Opções de desenvolvedor Mostrar chamadas de API lentas - Você pode tornar isso visível aos seus contatos SimpleX nas configurações. + Você pode torná-lo visível para seus contatos nas Configurações. Criptografar arquivos locais %s conectou Erro ao bloquear membro para todos - Pedido de conexão será enviado para este membro do grupo. - Mensagem de boas vindas é muito grande - Alterne para navegação anônima ao conectar. - Dispositivos móveis vínculados + Uma solicitação de conexão será enviada a este membro do grupo. + A mensagem de boas-vindas é muito longa + Alternar para o modo anônimo ao conectar. + Dispositivos móveis vinculados %s pelo motivo: %s]]> Endereço de desktop incorreto Aguardando o dispositivo móvel conectar: @@ -1423,32 +1420,31 @@ Grupo já existe! %1$s.]]> Mostrar erros internos - O envio de recibos está habilitado para %d grupos - Iniciar bate-papo? + O envio de confirmações está ativado para %d grupos + Iniciar o chat? Histórico não é enviado para novos membros. - Descobrir e se juntar a grupos + Descubra e participe de grupos Recarregar - %s tem uma versão não suportada. Por favor, certifique-se de usar a mesma versão em ambos os dispositivos]]> + %s tem uma versão não suportada. Por favor, certifique-se de usar a mesma versão em ambos os dispositivos]]> Nome de exibição inválido! - Este nome de exibição é inválido. Por favor escolha outro nome. + Este nome de exibição é inválido. Por favor, escolha outro nome. bloqueado Caminho de arquivo inválido Você compartilhou um caminho de arquivo inválido. Informe o problema para os desenvolvedores do aplicativo. A mensagem é muito grande - Visualização travou - %d mensagens marcadas como excluídas - Deletar %d mensagens? + A visualização travou + %d mensagens marcadas como apagadas + Apagar %d mensagens? Toque para conectar Modo anônimo simplificado Desktop encontrado Aleatório - Migração do banco de dados em progresso. -\nIsso pode levar alguns minutos. + Migração do banco de dados em andamento.\nIsso pode levar alguns minutos. %1$d mensagens moderadas por %2$s %d mensagens bloqueadas - Erro ao deletar notas privadas + Erro ao excluir notas privadas Uso de bateria do aplicativo / Irrestrito nas configurações do aplicativo.]]> - Este grupo tem mais de %1$d membros, recibos de entrega não são enviados. + Este grupo tem mais de %1$d membros; as confirmações de entrega não são enviadas. Carregando o arquivo contato deletado Erro @@ -1486,21 +1482,18 @@ Conectando ao desktop Conectar ao desktop Versão incompatível - Use no desktop no aplicativo móvel e escaneie o QR code.]]> + Use no desktop no aplicativo móvel e escaneie o QR Code.]]> Colar o endereço de desktop Nenhum celular conectado - Se juntar ao seu grupo? + Entrar no seu grupo? Repetir o pedido de conexão? - Você já está se conectando por este código de uso único! - Por favor informe isto aos desenvolvedores: -\n%s -\n -\nÉ recomendado reiniciar o aplicativo. + Você já está se conectando por este link de uso único! + Por favor, informe isto aos desenvolvedores:\n%s\n\nÉ recomendado reiniciar o aplicativo. Mostrar últimas mensagens Eles podem ser substituídos nas configurações de contato e grupo. Salvar senha nas configurações - A senha é armazenada nas configurações como um texto simples. - A senha será armazenada nas configurações como um texto simples após você mudar ela ou reiniciar o aplicativo. + A senha é armazenada nas configurações em texto puro. + A senha será armazenada nas configurações em texto puro depois que você a alterar ou reiniciar o aplicativo. desbloqueado %s %s, %s e %d membros %s, %s e %d outros membros conectaram @@ -1511,17 +1504,17 @@ Configurar senha do banco de dados Senha do aplicativo Adicionar contato - Todas as mensagens serão deletadas - isto não poderá ser desfeito! + Todas as mensagens serão apagadas. Isso não pode ser desfeito! Todas as novas mensagens de %s serão ocultadas! - Bloqueado pelo admnistrador + Bloqueado pelo administrador bloqueado %s Criar grupo: para criar um novo grupo.]]> Bloquear bloqueado - O código que você escaneou não é um QR code SimpleX. + O código que você escaneou não é um QR Code SimpleX. O navegador padrão é necessário para chamadas. Configure o navegador padrão no sistema e compartilhe mais informações com os desenvolvedores. - Essas configurações são para o seu perfil atual - O vídeo não pode ser decodificado. Por favor, tente com um vídeo diferente ou contate os desenvolvedores. + Essas configurações são para o seu perfil atual + O vídeo não pode ser decodificado. Por favor, tente com um vídeo diferente ou entre em contato com os desenvolvedores. Este é o seu próprio link de uso único! Tempo limite atingido durante a conexão com o desktop Até as 100 últimas mensagens são enviadas para novos membros. @@ -1529,19 +1522,19 @@ Seu perfil %1$s será compartilhado. %1$s.]]> Bloquear membro? - Atenção: retransmissões de mensagens e arquivos são conectadas via proxy SOCKS. As chamadas e o envio de visualizações de links usam conexão direta.]]> + Atenção: os relays de mensagens e arquivos são conectados via proxy SOCKS. As chamadas usam conexão direta.]]> Câmera não disponível Dispositivo móvel conectado - O bate-papo foi interrompido. Se você já usou esse banco de dados em outro dispositivo, deverá transferi-lo de volta antes de iniciar o bate-papo. + O chat está parado. Se você já usou este banco de dados em outro dispositivo, deve transferi-lo de volta antes de iniciar o chat. Limpar notas privadas? Conectado ao desktop Criando link… Conectar com %1$s? Desktop está inativo A senha de criptografia do banco de dados será atualizada e armazenada nas configurações. - Criar perfil de bate-papo - O banco de dados será criado e a senha será armazenada nas configurações. - Deletar e notificar contato + Criar perfil de chat + O banco de dados será criptografado e a senha será armazenada nas configurações. + Apagar e notificar o contato Desktop A versão do aplicativo desktop %s não é compatível com este aplicativo. Desconectar desktop? @@ -1549,18 +1542,16 @@ Erro de renegociação de criptografia Erro ao abrir o navegador Erro ao enviar o convite - Carregando conversas… + Carregando chats… %s foi desconectado]]> %s foi desconectado]]> Apenas um dispositivo pode funcionar ao mesmo tempo Abrir - Ex-membro %1$s - Por favor informe isto aos desenvolvedores: -\n%s - Por favor, espere até que o arquivo seja carregado pelo dispositivo móvel vínculado - Senha aleatória é armazenadas nas configurações como um texto simples. -\nVocê pode mudar isso mais tarde. - Os recibos estão desativados + Membro %1$s + Por favor, informe isto aos desenvolvedores:\n%s + Por favor, aguarde até que o arquivo seja carregado pelo dispositivo móvel vinculado + A senha aleatória é armazenada nas configurações em texto puro.\nVocê poderá alterá-la depois. + Confirmações desativadas Remover senha das configurações? Definir senha do banco de dados Grupos pequenos (max 20) @@ -1582,7 +1573,7 @@ Para ocultar mensagens indesejadas. Via protocolo seguro de resistência quântica. Dispositivos - Disconectar + Desconectar Digite o nome deste dispositivo… Novo dispositivo móvel Código de sessão @@ -1592,9 +1583,9 @@ Desvincular desktop? Verificar código no dispositivo móvel Verificar conexão - Opções de desktop vinculada - Escanear QR code pelo desktop - %1$s.]]> + Opções de desktop vinculadas + Escanear QR Code pelo desktop + %1$s.]]> autor %s: %s Conectar automaticamente @@ -1602,7 +1593,7 @@ Descobrível via rede local A execução da função está demorando muito: %1$d segundos: %2$s Função lenta - enviar mensagem direta + enviar para conectar Tentar novamente Abrir a pasta do banco de dados Mostrar console em uma nova janela @@ -1621,7 +1612,7 @@ Novo aplicativo de desktop! Arquivando banco de dados Cancelar migração - Por Favor, note que: usando o mesmo banco de dados em dois dispositivos vai quebrar a descriptografia das mensagens das suas conexões, como proteção de segurança.]]> + Atenção: usar o mesmo banco de dados em dois dispositivos impedirá a descriptografia das mensagens dos seus contatos, como medida de segurança.]]> Aplicar Tema do aplicativo Preto @@ -1630,117 +1621,117 @@ Aplicar para Administradores podem bloquear um membro para todos. Migração de dados do aplicativo - Todos os seus contatos,conversas e arquivos irão ser criptografados seguramente e enviados em partes para relays de XFTP configurados. - Aviso: o arquivo irá ser deletado.]]> + Todos os seus contatos, conversas e arquivos serão criptografados com segurança e enviados em partes para os relays XFTP configurados. + Aviso: o arquivo será excluído.]]> Rede móvel Sempre Sempre usar roteamento privado. Câmera Câmera e microfone Permitir o envio de links do SimpleX. - Todos os membros + todos os membros Configurações avançadas Verificar atualizações - Completado + Completas Servidores SMP configurados Servidores XFTP configurados Verifique sua conexão de internet e tente novamente Verificar atualizações Modo de cor - Apagar banco de dados desse dispositivo - O endereço do servidor de destino de %1$s é incompatível com o as configurações %2$s do servidor de encaminhamento. - Capacidade excedida - o destinatário não recebeu as mensagens enviadas anteriormente. + Excluir banco de dados deste dispositivo + O endereço do servidor de destino de %1$s é incompatível com as configurações do servidor de encaminhamento %2$s. + Limite atingido. O destinatário não recebeu as mensagens enviadas anteriormente. Erro do servidor de destino: %1$s - Inativo - Migre de outro dispositivono novo dispositivo e escaneie o QR code.]]> - Confirme se você se lembra da senha do banco de dados para migrá-lo. - Borrar conteúdo + inativo + Migrar de outro dispositivo no novo dispositivo e escaneie o QR Code.]]> + Confirme que você se lembra da senha do banco de dados antes de transferi-lo. + Desfocar mídia Borrar para melhor privacidade. Confirmar exclusão do contato? conectar Apagar sem notificar - Tonalidade adicional 2 + Destaque adicional 2 Confirmar configurações de rede Todos os modos de cor Modo escuro Não é possível enviar mensagem Cores do chat Criar - Confirmar upload + Confirmar envio administradores O contato foi apagado. Permitir chamadas? Não é possível chamar o contato - Conectando ao contato, por favor aguarde ou volte depois! + Conectando ao contato. Por favor, aguarde ou tente novamente mais tarde. Chamadas proibidas! Não é possível chamar membro do grupo - Não é possível mandar mensagem para o membro do grupo + Não é possível enviar uma mensagem para o membro do grupo Controle sua rede - Apague até 20 mensagens por vez. + Apague até 20 mensagens de uma vez. Arquivar contatos para conversar depois. Conecte aos seus amigos mais rapidamente. Escuro Confirmar arquivos de servidores desconhecidos. Copiar erro - Conversa migrada! + Chat migrado! chamar - O contato será apagado - essa ação não pode ser desfeita! + O contato será apagado. Isso não pode ser desfeito! Beta A atualização do aplicativo foi baixada - Tema da conversa + Tema do chat Entrega de depuração Cores do modo escuro Criando link de arquivo Permitir downgrade - Todos os usuários + Todos os perfis Conectado Conectando Perfil atual Servidores conectados Conexões ativas tentativas - Reconhecido - Erros conhecidos + Reconhecidas + Erros de reconhecimento Conexões - Criado - erros de decriptação - Apagado + Criadas + erros de descriptografia + Excluídas Erros de exclusão - Pedaços excluídos - Pedaços baixados - Pedaços carregados + Partes excluídas + Partes baixadas + Partes enviadas Apagar %d mensagens dos membros? - Contato apagado! + Contato excluído! Conversa apagada! Contatos arquivados - Banco de dados da conversa exportado + Banco de dados do chat exportado Continuar Conexão e status dos servidores. Repetir download - Recebendo simultaneidade + Concorrência de recebimento Redefinir para o tema do usuário IU Persa Aviso de entrega de mensagem Erro: %1$s Chave incorreta ou conexão desconhecida - provavelmente esta conexão foi excluída. - Essa conversa é protegida por criptografia ponta a ponta - Repetir upload - Erro de conexão ao servidor de encaminhamento %1$s. Por favor tente mais tarde. + Esta conversa é protegida por criptografia de ponta a ponta. + Repetir envio + Erro de conexão ao servidor de encaminhamento %1$s. Por favor, tente mais tarde. A versão do servidor de encaminhamento é incompatível com as configurações de rede: %1$s. - O servidor de encaminhamento %1$s falhou ao se conectar ao servidor de destino %2$s. Por favor tente mais tarde. + O servidor de encaminhamento %1$s falhou ao se conectar ao servidor de destino %2$s. Por favor, tente mais tarde. O endereço do servidor de encaminhamento é incompatível com as configurações de rede: %1$s. - A versão do servidor de destino de %1$s é incompatível com o servidor de encaminhamento %2$s. - Problemas de rede - a mensagem expirou após muitas tentativas de envio. + A versão do servidor de destino %1$s é incompatível com o servidor de encaminhamento %2$s. + Problemas de rede - a mensagem expirou após várias tentativas de envio. Servidor de encaminhamento: %1$s \nErro: %2$s Destinatário(s) não podem ver de onde essa mensagem veio. A mensagem poderá ser entregue mais tarde se o membro se tornar ativo. Outros servidores SMP - Para proteger seu endereço IP, roteamento privado usa seus servidores SMP para entregar mensagens. + Para proteger seu endereço IP, o roteamento privado usa seus servidores SMP para entregar mensagens. Pular essa versão Baixar %s (%s) Download da atualização cancelado - Mostrar lista de conversas em nova janela + Mostrar lista de chats em nova janela Tamanho da fonte Fundo do papel de parede Tonalidade do papel de parede @@ -1748,9 +1739,9 @@ Zoom Definir tema padrão Proibido enviar links SimpleX - Erro ao carregar o arquivo - Falha ao carregar - Carregando arquivo + Erro ao enviar o arquivo + Falha ao enviar + Enviando arquivo Estatísticas detalhadas Erro no servidor de arquivo: %1$s Salvo de @@ -1761,12 +1752,12 @@ Aumentar tamanho da fonte. Aprimorar aplicativo automaticamente Redefinir todas as estatísticas? - As estatísticas dos servidores serão redefinidas - isso não poderá ser desfeito! + As estatísticas dos servidores serão redefinidas. Isso não pode ser desfeito! Link inválido - Por favor cheque se o link SimpleX está correto - criptografia quantum resistant e2e com perfeito sigilo direto, repúdio e recuperação de vazamento.]]> - Essa conversa é protegida por criptografia quantum resistant ponta a ponta - Por favor tente mais tarde. + Verifique se o link SimpleX está correto. + criptografia de ponta a ponta resistente à computação quântica, com sigilo de encaminhamento perfeito, repúdio e recuperação após invasão.]]> + Esta conversa é protegida por criptografia de ponta a ponta resistente à computação quântica. + Por favor, tente mais tarde. Selecionado %d SimpleX links não permitidos Arquivos e mídia não permitidos @@ -1779,54 +1770,53 @@ NÃO use roteamento privado. Abrir configurações Fone de ouvido - Erro ao iniciar o WebView. Atualize seu sistema para a nova versão. Por favor contate os desenvolvedores. -\nErro: %s + Falha ao iniciar o WebView. Atualize seu sistema para a versão mais recente. Se o problema persistir, contate os desenvolvedores.\nErro: %s Desativado Forte - Ativar em conversas diretas (BETA)! - Finalizar migração em outro dispositivo. + Ativar em chats diretos (BETA)! + Finalize a migração no outro dispositivo. Claro A origem da mensagem permanece privada. Migrar aqui Migrando - Nova experiência de conversa 🎉 + Nova experiência de chat 🎉 Novas opções de mídia Ou cole o link do arquivo - Reproduzir da lista de conversa. + Reproduzir da lista de chats. Redefinir todas as estatísticas - Aviso: iniciar conversa em múltiplos dispositivos não é suportado e pode causar falhas na entrega de mensagens + Aviso: iniciar o chat em vários dispositivos não é suportado e pode causar falhas na entrega de mensagens Internet cabeada - não deve usar a mesma base de dados em dois dispositivos.]]> - Membros podem enviar links SimpleX. + não deve usar o mesmo banco de dados em dois dispositivos.]]> + Os membros podem enviar links SimpleX. Importando arquivo Modo claro Ativado para - Ao conectar em chamadas de áudio de vídeo. + Ao conectar chamadas de áudio e vídeo. Migrar dispositivo Erro ao salvar configurações Arquivo exportado não existe %s carregados - Para continuar, a conversa precisa ser interrompida. + Para continuar, o chat precisa estar parado. Outro Sem conexão de rede - As preferências de conversa selecionadas proíbem essa mensagem. + As preferências de conversa selecionadas não permitem esta mensagem. Erro de arquivo Redefinir cor Sons de chamada Formato das imagens de perfil IU Lituana Roteamento de mensagem privada 🚀 - Novos temas de conversa + Novos temas de chat Com uso de bateria reduzida. Falha no download Falha na importação Baixando arquivo Repetir importação Você pode tentar novamente. - Erro ao exportar banco de dados de conversa - Erro ao verificar a palavra-chave: + Erro ao exportar banco de dados + Erro ao verificar a senha: salvo - salvo de %s + salvo de Encaminhado de Mensagens de voz não permitidas Nova mensagem @@ -1835,7 +1825,7 @@ Encaminhar e salvar mensagens Suave Médio - Você precisa permitir seu contato ligue para poder ligar para ele. + Você precisa permitir que seu contato faça chamadas para poder ligar para ele. Redefinir para o tema do aplicativo Resposta recebida Boa tarde! @@ -1845,7 +1835,7 @@ O arquivo foi deletado ou o link está inválido Abrir tela de migração Grupos seguros - Preparando upload + Preparando envio Conexão de rede Servidores desconhecidos! Sem Tor ou VPN, seu endereço de IP ficará visível para esses relays XFTP @@ -1864,22 +1854,22 @@ Arquivos Fotos de perfil Roteamento de mensagem privada - criptografia padrão ponta a ponta + criptografia padrão de ponta a ponta proprietários Migrar para outro dispositivo - Verificar palavra-passe - WiFi + Verificar senha + Wi-Fi Você pode mudar isso em configurações de Aparência. desativado nenhum informações da fila do servidor: %1$s \n \núltima mensagem recebida: %2$s - Por favor peça para seu contato ativar as chamadas. + Peça ao seu contato para ativar as chamadas. Enviar mensagem para ativar chamadas. Salvar e reconectar Conexão TCP - Barra de ferramentas de conversa acessível + Barra do chat acessível Isso protege seu endereço de IP e conexões. Use o aplicativo com uma mão. Arquivos carregados @@ -1894,38 +1884,37 @@ Preencher Ajustar Links SimpleX são proibidos. - Migrar para outro dispositivo via QR code. + Migrar para outro dispositivo via QR Code. Chamadas picture-in-picture Use o aplicativo enquanto está em chamada. - Será ativado em conversas diretas! + Será ativado em chats diretos! Receber arquivos de forma segura Gerenciamento de rede - Faça suas conversas terem uma aparência diferente! + Faça seus chats terem uma aparência diferente! Conexão de rede mais confiável. Preparando download %s baixados Colar link de arquivo - Insira a palavra-chave + Insira a senha Erro ao baixar o arquivo - Ou de forma segura compartilhe esse link de arquivo - Verifique a palavra-passe do banco de dados - criptografia ponta-a-ponta com perfeito sigilo direto, repúdio e recuperação de vazamento.]]> + Ou compartilhe este link de arquivo com segurança + Verifique a senha do banco de dados + criptografia de ponta a ponta, com sigilo de encaminhamento perfeito, repúdio e recuperação após invasão.]]> Arquivo não encontrado - provavelmente o arquivo foi excluído ou cancelado. - Chave incorreta ou arquivo de pedaço de endereço - provavelmente o arquivo foi excluído. + Chave incorreta ou endereço de fragmento de arquivo desconhecido - provavelmente o arquivo foi excluído. Mande mensagens diretamente quando o seu endereço de IP está protegido e o servidor de destino não suporta roteamento privado. Use roteamento privado em servidores desconhecidos. Escanear / Colar link - Baixando detalhes de link + Baixando detalhes do link Finalizar migração - Criptografia Quantum resistant + Criptografia resistente à computação quântica Migração concluída - Proteja seu endereço de IP dos retransmissores de mensagem escolhidos por seus contatos. -\nAtive nas configurações *Redes e servidores* . - Iniciar conversa + Proteja seu endereço IP dos relays de mensagens escolhidos pelos seus contatos.\nAtive nas configurações de Rede e servidores. + Iniciar o chat Configurações abrir Manter conversa - Apenas excluir conversa + Apenas apagar conversa Outros servidores XFTP Use roteamento privado em servidores desconhecidos quando o endereço de IP não está protegido. Sim @@ -1936,20 +1925,19 @@ Microfone Conceder nas configurações Encontre essa permissão nas configurações do Android e conceda-a manualmente. - O aplicativo irá perguntar para confirmar os downloads de servidores de arquivo desconhecidos (exceto .onion ou quando o proxy SOCKS estiver habilitado). + O aplicativo solicitará confirmação para downloads de servidores de arquivos desconhecidos (exceto .onion ou quando o proxy SOCKS estiver ativado). Tema de perfil - Defina uma palavra-chave - criptografia quantum resistant e2e + Definir senha + criptografia de ponta a ponta resistente à computação quântica Convidar Status da mensagem Entrega de mensagens aprimorada Quadrado, circulo, ou qualquer coisa entre eles. - Por favor verifique se o celular e o computador estão conectados na mesma rede local e o firewall do computador permite a conexão. -\nPor favor compartilhe qualquer outro problema com os desenvolvedores. - Esse link foi usado em outros dispositivo móvel, por favor crie um novo link no computador. + Por favor, verifique se o celular e o computador estão conectados à mesma rede local e se o firewall do computador permite a conexão.\nPor favor, compartilhe qualquer outro problema com os desenvolvedores. + Esse link foi usado em outro dispositivo móvel. Crie um novo link no computador. Erro ao excluir banco de dados - Por favor confirme que as configurações de rede estão corretas para este dispositivo. - Parando conversa + Por favor, confirme que as configurações de rede estão corretas para este dispositivo. + Encerrando o chat Você pode tentar novamente. A versão do servidor é incompatível com seu aplicativo: %1$s. Erro de roteamento privado @@ -1961,7 +1949,7 @@ Encaminhar mensagem… Quando IP oculto Proteger endereço IP - Enviar resposta + Resposta enviada Arquivos Não Baixando atualização do aplicativo, não feche o aplicativo @@ -1978,48 +1966,48 @@ Sessões de transporte Recepção de mensagem Pendente - Começando em %s.\nTodos os dados são mantidos privados em seu dispositivo. + Iniciado em %s.\nTodos os dados são mantidos privados em seu dispositivo. Total - Servidores proxiados + Servidores acessados via proxy Servidores conectados anteriormente - Reconecte todos os servidores conectados para forçar entrega de mensagem. Isso usa tráfego adicional. + Reconecte todos os servidores para forçar a entrega das mensagens. Essa ação consome tráfego adicional. Reconectar servidor? Reconectar servidores? Reconectar servidor para forçar entrega de mensagem. Isso usa tráfego adicional. - Você não está conectado nesses servidores. Roteamento privado é usado para entregar mensagens para eles. + Você não está conectado a estes servidores. O roteamento privado é usado para entregar mensagens a eles. Erro Erro ao reconectar servidor Erro ao reconectar servidores Reconectar todos os servidores Reconectar - Enviar diretamente - Enviar mensagens - Enviar total - Enviar via proxy + Diretas + Mensagens enviadas + Total enviado + Via proxy Servidor SMP Mensagens recebidas Total recebido - Receber erros - Começando de %s. + Falhas no recebimento + Iniciado em %s. Servidor XFTP - Seguro - Enviar erros + Seguras + Falhas no envio Inscrito - duplicatas + duplicadas expirada outro Erros de inscrição outros erros - Proxied + Encaminhadas Inscrições ignoradas - Erros de download + Erros ao baixar Arquivos baixados Endereço do servidor Tamanho - Erros de upload + Erros de envio Abrir configurações de servidor Selecione - As mensagens serão excluídas para todos os membros. + As mensagens serão apagadas para todos os membros. As mensagens serão marcadas como moderadas para todos os membros. Mensagem encaminhada Ainda não há conexão direta, a mensagem é encaminhada pelo administrador. @@ -2030,59 +2018,59 @@ Nenhum contato filtrado Seus contatos NÃO envie mensagens diretamente, mesmo que o seu servidor ou o servidor de destino não suporte roteamento privado. - Mande mensagens diretamente quando o seu servidor ou o servidor de destino não suporta roteamento privado. + Enviar mensagens diretamente quando o seu servidor ou o servidor de destino não for compatível com roteamento privado. Retorno de roteamento de mensagens Mostrar status da mensagem Migrar de outro dispositivo Status da mensagem: %s - Carregado + Enviado Servidores de mensagem Servidores de mídia e arquivo Mostrar porcentagem Proxy SOCKS Você pode salvar o arquivo exportado. - Você pode migrar o banco de dados exportado. + Você pode transferir o banco de dados exportado. Alguns arquivos não foram exportados - Você pode enviar mensagens para %1$s de Contatos arquivados. + Você pode enviar mensagens para %1$s em Contatos arquivados. Redefinir todas as dicas Desativado Estável Instalado com sucesso - Por favor reinicie o aplicativo. + Por favor, reinicie o aplicativo. Me lembre mais tarde Para ser notificado sobre os novos lançamentos, habilite a checagem periódica de versões Estáveis e Beta. - Barras de ferramentas de aplicativos acessível - Falha no baixar de %1$d arquivo(s). + Barras do aplicativo acessíveis + falha ao baixar %1$d arquivo(s). %1$s mensagens não encaminhadas. - Dados do bate-papo - Utilize credenciais aleatórias + Banco de dados do chat + Usar credenciais aleatórias O arquivo de banco de dados enviado será removido permanentemente dos servidores. - Use credenciais diferentes de proxy para cada conexão. + Usar credenciais diferentes em cada conexão. Sua conexão foi movida para %s, mas um erro inesperado ocorreu ao redirecioná-lo para o seu perfil. %1$d erro(s) de arquivo(s): \n%2$s %1$d outro(s) erro(s) de arquivo(s). - Erro ao encaminhar mensagens. + Erro ao encaminhar mensagens Encaminhar %1$s mensagens? Encaminhar mensagens sem arquivos? - As mensagens foram excluidas após vocês selecioná-las. + As mensagens foram excluídas após você selecioná-las. Nada para encaminhar! %1$d o(s) arquivo(s) ainda está(ão) sendo baixado(s). %1$d arquivo(s) foi(ram) excluído(s). %1$d arquivo(s) não foi(ram) baixado(s). Baixar - Emcaminhar mensagens… - Encaminhando %1$s mensagens. + Encaminhar mensagens… + Encaminhando %1$s mensagens Salvando %1$s mensagens - Autenticação de proxy + Autenticação do proxy Não utilize credenciais com proxy. Certifique-se de que configuração do proxy está correta. - Use diferentes credenciais de proxy para cada perfil. + Usar credenciais de proxy diferentes para cada perfil. Suas credenciais podem ser enviadas sem criptografia. Remover arquivo? - As mensagens serão excluídas - isso não pode ser desfeito! + As mensagens serão apagadas. Isso não pode ser desfeito! Erro ao alternar perfil - Selecionar perfil de bate-papo + Selecionar perfil de chat Compartilhar perfil Modo sistema Erro ao salvar proxy @@ -2093,27 +2081,27 @@ Configurações de endereço Adicione membros da sua equipe às conversas. Melhores ligações - Servidores de mensagem adicionados - Adicionado servidores de mídia e arquivos + Servidores de mensagens adicionados + Servidores de mídia e arquivos adicionados Barra de ferramentas Aplicativo sempre roda em segundo plano Adicionar amigos Condições aceitas - Convite aceito + convite aceito Adicionar membros da equipe Sobre operadores Aceite as condições denúncia arquivada por %s Outra razão Adicionar lista - Todas as conversas serão removidas da lista %s, e a lista será apagada + Todas as conversas serão removidas da lista %s, e a lista será excluída Melhor segurança ✅ Arquivar denúncia? Arquivar denúncia Todos Adicionar à lista Em dispositivos Xiaomi: por favor, ative a opção Autostart nas configurações do sistema para que as notificações funcionem.]]> - Datas de mensagens melhores. + Melhorias na exibição de datas nas mensagens. Melhor experiência do usuário %1$s.]]> Arquivar @@ -2121,27 +2109,27 @@ Desfoque Endereço comercial denúncia arquivada - Deletar chat + Apagar conversa O texto das condições atuais não pôde ser carregado, você pode revisar as condições por meio deste link: %s.]]> Formato de mensagem personalizável. Envio de mensagens mais rápido. Checar mensagens a cada 10 minutos - Todas novas mensagens destes membros serão ocultadas + Todas as novas mensagens desses membros serão ocultadas! Erro ao atualizar servidor Permitir denunciar mensagens aos moderadores. Melhorias de privacidade e segurança Não perca mensagens importantes. - Chat já existente! + O chat já existe! Ativar logs Bloquear membros para todos? - Deletar ou moderar até 200 mensagens. + Apagar ou moderar até 200 mensagens. %s.]]> %s.]]> Mensagens diretas entre membros são proibidas neste chat. Melhor desempenho de grupos - com criptografia de ponta-a-ponta, e com segurança pós-quântica em mensagens diretas.]]> - Chat será deletado para você - essa ação não pode ser desfeita! + com criptografia de ponta a ponta, com segurança pós-quântica em mensagens diretas.]]> + A conversa será apagada para você. Isso não pode ser desfeito! Condições aceitas em: %s. Mensagens diretas entre membros são proibidas. %s.]]> @@ -2152,7 +2140,7 @@ Erro ao salvar servidores %d denúncias 1 denúncia - com apenas um contato - compartilhe pessoalmente ou por qualquer aplicativo de mensagens.]]> + com um contato - compartilhe pessoalmente ou por qualquer aplicativo de mensagens.]]> Erro ao salvar configurações Criar link único Reparar @@ -2161,16 +2149,16 @@ Conexão bloqueada A conexão está bloqueada pelo operador do servidor:\n%1$s. O arquivo está bloqueado pelo operador do servidor:\n%1$s. - Deletar denúncia + Apagar denúncia Empresas Alterar lista Continuar Erro ao salvar banco de dados - %s.]]> - Erro ao inicializar o WebView. Certifique-se de que você tenha o WebView instalado e que sua arquitetura suportada seja arm64.\nErro: %s + %s.]]> + Falha ao iniciar o WebView. Certifique-se de que o WebView esteja instalado e que a arquitetura suportada seja arm64.\nErro: %s Alterar exclusão automática de mensagens? Desativar exclusão automática de mensagens? - Deletar lista? + Apagar lista? 1 ano padrão (%s) %s.]]> @@ -2193,18 +2181,18 @@ Editar Canto Ativar o Flux nas Configurações de rede e servidores para melhor privacidade de metadados. - Todas denúncias serão arquivadas para você. - Arquivar todas denúncias? + Todas as denúncias serão arquivadas para você. + Arquivar todas as denúncias? Arquivar %d denúncias? Arquivar denúncias - Para todos moderadores + Para todos os moderadores Para mim - Deletar mensagens de chat do seu dispositivo. - Excluir chat? - O chat será deletado para todos os membros - essa ação não pode ser desfeita! + Apagar mensagens do seu dispositivo. + Apagar conversa? + A conversa será apagada para todos os membros. Isso não pode ser desfeito! Desativar exclusão de mensagens Renegociação de criptografia em andamento. - Deletar + Apagar Clique no botão de informação perto do campo de endereço para permitir usar o microfone. As condições serão aceitas para operadores habilitados após 30 dias. Por exemplo, se o seu contato receber mensagens por meio de um servidor SimpleX Chat, seu aplicativo as entregará por meio de um servidor Flux. @@ -2233,12 +2221,12 @@ Nomes de arquivos de mídia privados. Conteúdo inapropriado Perfil inapropriado - Nenhuma mensagem de servidores. + Nenhum servidor de mensagens. Nenhuma mensagem Denúncias de membros Ou compartilhe em particular - Nenhum chat não lido - Nenhum chat + Nenhuma conversa não lida + Nenhuma conversa Notas Abrir com %s O nome da lista e o emoji devem ser diferentes para todas as listas. @@ -2248,9 +2236,9 @@ Silenciar tudo Para redes sociais Servidores predefinidos - Abrir links da lista de bate-papo - Abrir web link? - Convidar ao chat + Abrir links na lista de conversas + Abrir link da web? + Convidar para o chat Abrir condições Nome da lista... Novas credenciais SOCKS serão usadas toda vez que você iniciar o aplicativo. @@ -2258,20 +2246,20 @@ Forma da mensagem moderador A mensagem é muito grande! - Por favor, reduza o tamanho da mensagem e a envie novamente. + Por favor, reduza o tamanho da mensagem e envie-a novamente. Operador da rede Para roteamento privado - Aprimorada a navegação de bate-papo - - Abra o chat na primeira mensagem não lida.\n- Pule para mensagens citadas. - Os membros serão removidos do chat. Essa ação não pode ser desfeita! + Navegação no chat aprimorada + - Abra o chat na primeira mensagem não lida.\n- Pule para as mensagens citadas. + Os membros serão removidos do chat. Isso não pode ser desfeito! Sair do chat - Os membros serão removidos do grupo. Essa ação não pode ser desfeita! - Nove servidor - Nenhum chat encontrado - As mensagens neste chat nunca serão excluídas. - A frase-senha na Keystore não pôde ser lida. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Os membros serão removidos do grupo. Isso não pode ser desfeito! + Novo servidor + Nenhuma conversa encontrada + As mensagens nesta conversa nunca serão apagadas. + Não foi possível ler a senha no Keystore. Isso pode ter ocorrido após uma atualização do sistema incompatível com o aplicativo. Se não for esse o caso, entre em contato com os desenvolvedores. Somente os proprietários do chat podem alterar as preferências. - A frase-senha na Keystore não pôde ser lida, insira-a manualmente. Isso pode ter acontecido após uma atualização do sistema incompatível com o aplicativo. Se não for o caso, entre em contato com os desenvolvedores. + Não foi possível ler a senha no Keystore. Insira-a manualmente. Isso pode ter ocorrido após uma atualização do sistema incompatível com o aplicativo. Se não for esse o caso, entre em contato com os desenvolvedores. Grupos Ou importar arquivo compactado Lista @@ -2279,19 +2267,19 @@ Como isso ajuda na privacidade Não Sair do chat? - O membro será removido do chat - essa ação não pode ser desfeita! + O membro será removido do chat. Isso não pode ser desfeito! Encaminhe até 20 mensagens de uma vez. - Nenhuma mídia & nenhum arquivo de servidores. + Nenhum servidor de mídia e arquivos. Nenhum servidor para enviar arquivos. Nenhum servidor para roteamento de mensagens privadas. Nenhum servidor para receber arquivos. Nenhum servidor para receber mensagens. Abra Configurações do Safari / Websites / Microfone, e escolha Permitir para localhost. Servidor do operador - Nenhum chat na lista %s. - Somente o remetente e os moderadores podem vê-lo. + Nenhuma conversa na lista %s. + Somente o remetente e os moderadores podem vê-lo Somente você e os moderadores podem ver isso - rejeitado + recusado Denunciar Proibir a denúncia de mensagens aos moderadores. Denunciar conteúdo: somente os moderadores do grupo poderão ver. @@ -2301,9 +2289,9 @@ Denunciar outro: somente os moderadores do grupo poderão ver. Qual é a razão da denúncia? Denúncia: %s - rejeitado + recusado É proibido denunciar mensagens neste grupo. - Barra de ferramentas de chat acessível + Barra do chat acessível Denúncias Operador do servidor alterado. Definir nome do chat… @@ -2312,7 +2300,7 @@ %s servidores Protocolos SimpleX analisados pela Trail of Bits. Enviar denúncias privadas - Defina a expiração de mensagens em chats. + Defina a expiração de mensagens nos chats. Spam Spam Salvar lista @@ -2320,7 +2308,7 @@ Endereço SimpleX ou link único? Som silenciado Alterne entre áudio e vídeo durante a chamada. - Selecione as operadoras de rede a serem utilizadas. + Selecione os operadores de rede que deseja utilizar. Revisar condições Endereços SimpleX e links únicos são seguros para compartilhar por meio de qualquer mensageiro. Operadores do servidor @@ -2333,32 +2321,32 @@ Servidor O SimpleX Chat e o Flux fizeram um acordo para incluir servidores operados pelo Flux no aplicativo. conexão solicitada - The role will be changed to %s. Everyone in the chat will be notified. + O cargo será alterado para %s. Todos no chat serão notificados. Transparência Alterne o perfil de chat para convites únicos. Desbloquear membros para todos? Para enviar Condições atualizadas O segundo operador predefinido no aplicativo! - Esta mensagem foi excluída ou ainda não foi recebida. + Esta mensagem foi apagada ou ainda não foi recebida. Ver condições atualizadas Toque em Criar endereço SimpleX no menu para criá-lo mais tarde. Usar porta TCP %1$s quando nenhuma porta for especificada. - A denúncia será arquivado para você. + A denúncia será arquivada para você. Para receber - Esta ação não pode ser desfeita - as mensagens enviadas e recebidas neste chat antes da selecionada serão excluídas. - Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança dos contatos. + Esta ação não pode ser desfeita - as mensagens enviadas e recebidas nesta conversa antes do período selecionado serão apagadas. + Para se proteger contra a substituição do seu link, você pode comparar os códigos de segurança com os dos seus contatos. Menções não lidas Porta TCP para mensagens Usar porta web O aplicativo protege sua privacidade usando diferentes operadores em cada conversa. - Quando mais de um operador está ativado, nenhum deles têm metadados para saber quem se comunica com quem. + Quando mais de um operador está habilitado, nenhum deles tem metadados que permitam saber quem se comunica com quem. Sim Seu perfil de chat será enviado aos membros do chat Ver condições Usar para mensagens Você pode definir o nome da conexão para lembrar com quem o link foi compartilhado. - Você pode configurar servidores nas configurações. + É possível ajustar os servidores através das configurações. Usar %s Usar servidores Website @@ -2369,16 +2357,16 @@ Você pode configurar operadores em Configurações de rede & servidores. Usar para arquivos Você deixará de receber mensagens deste chat. O histórico do chat será preservado. - Para fazer chamadas, permita usar seu microfone. Encerre a chamada e tente ligar novamente. - Os servidores para novos arquivos do seu perfil de chat atual - A conexão atingiu o limite de mensagens não entregues, seu contato pode estar offline. + Para realizar chamadas, permita o uso do microfone. Encerre a chamada atual e tente novamente. + Estes são os servidores para novos arquivos do seu perfil de chat atual: + A conexão atingiu o limite de mensagens não entregues; seu contato pode estar offline. Mensagens não entregues - Chats privados, grupos e seus contatos não são acessíveis aos operadores de servidor. + Os operadores se comprometem com:\n- Independência\n- Uso mínimo de metadados\n- Execução de código aberto verificado Aceitar - Ao usar o SimpleX Chat, você concorda em:\n- enviar apenas conteúdo legal em grupos públicos.\n- respeitar outros usuários – sem spam. + Ao usar o SimpleX Chat, você concorda em:\n- enviar apenas conteúdo lícito em grupos públicos\n- respeitar outros usuários – sem spam Política de privacidade e condições de uso. Aceitar como membro - Adicionar link curto + Atualizar endereço 1 conversa com um membro Todos servidores %1$s aceito @@ -2389,4 +2377,548 @@ Aceitar membro todos Conversas com membros + %1$d/%2$d relays ativos + %1$d/%2$d relays ativos, %3$d erros + %1$d/%2$d relays ativos, %3$d falharam + %1$d/%2$d relays ativos, %3$d removidos + %1$d/%2$d relays conectados + %1$d/%2$d relays conectados, %3$d erros + %1$d/%2$d relays conectados, %3$d falharam + %1$d/%2$d relays conectados, %3$d removidos + %1$d proprietário + %1$d proprietários + %1$d proprietários e colaboradores + %1$d relays falharam + %1$d relays inativos + %1$d relays removidos + %1$d inscrito + %1$d inscritos + %1$s apoiou o SimpleX Chat. O selo expirou em %2$s. + Você pode apoiar o SimpleX a partir da versão 7 do aplicativo. + Sobre + Aceitar solicitação de contato + Aceitar solicitação de contato + aceito + lista reconhecida + ativo + Adicionar + Adicionar colaboradores. + Adicionar descrição + Adicionar mensagem + Adicionar relay + Adicionar relays + Adicionar relays para restaurar a entrega das mensagens. + Adicione este código à sua página da web. Ele exibirá a prévia do seu canal/grupo. + Opções avançadas + Configurações avançadas + Link para uma pessoa se conectar + Todas as mensagens + Permitir que qualquer pessoa insira + Permitir arquivos e mídias apenas se seu contato permitir. + Permitir que os membros conversem com os administradores. + Permitir o envio de mensagens diretas para os inscritos. + Permitir que os inscritos conversem com os administradores. + O perfil do canal é armazenado nos dispositivos dos inscritos e nos relays do chat. + O canal será excluído para todos os inscritos. Isso não pode ser desfeito! + Os relays de chat encaminham mensagens aos inscritos do canal. + Mensagens diretas entre os inscritos são proibidas. + Não enviar o histórico para novos inscritos. + O histórico não é enviado aos novos inscritos. + A URL será exibida aos inscritos e usada para permitir o carregamento da prévia. + Privacidade: para proprietários e inscritos. + Proibir o envio de mensagens diretas para os inscritos. + Salvar e notificar os inscritos do canal + Enviar até as 100 últimas mensagens para novos inscritos. + Inscritos + Os inscritos podem adicionar reações. + Os inscritos podem conversar com os administradores. + Os inscritos podem apagar permanentemente as mensagens enviadas em até 24h. + Os inscritos podem denunciar mensagens aos moderadores. + Os inscritos podem enviar mensagens diretas. + Os inscritos podem enviar mensagens temporárias. + Os inscritos podem enviar arquivos e mídias. + Os inscritos podem enviar links do SimpleX. + Os inscritos podem enviar mensagens de voz. + Os inscritos usam o link do relay para se conectar ao canal.\nO endereço do relay foi usado para configurar este relay para o canal. + Este é o último relay ativo. Removê-lo impedirá a entrega de mensagens aos inscritos. + As últimas 100 mensagens são enviadas aos novos inscritos. + Seu perfil %1$s será compartilhado com os relays do canal e os inscritos.\nOs relays podem acessar as mensagens do canal. + 4 novos idiomas na interface + Permitir que seus contatos enviem arquivos e mídias. + Todos os relays falharam + Todos os relays foram removidos + Outra instância do aplicativo pode estar em execução ou não ter sido encerrada corretamente. Iniciar mesmo assim? + Qualquer página da web poderá exibir a prévia. + O aplicativo já está em execução + É necessário atualizar o aplicativo + Não foi possível verificar o selo + Destruímos o poder de saber quem você é. Para que o seu poder nunca possa ser tirado de você. + Seja livre\nna sua rede + Link do canal + Conecte-se pelo link ou QR Code + Ou exiba o QR Code pessoalmente ou na videochamada. + Ou use este QR Code - imprima ou mostre online. + Você pode compartilhar um link ou um QR Code. Qualquer pessoa poderá entrar no canal através deles. + O cargo será alterado para %s. Todos do canal receberão uma notificação. + Cancelar e excluir canal + O canal será excluído para você. Isso não pode ser desfeito! + contato excluído + Seja livre na sua rede. + Melhores canais 📢 + Biografia: + Biografia muito longa + Bot + Você e seu contato podem enviar arquivos e mídia. + Barra inferior + Transmissão + Testar relay para obter o nome.]]> + Endereço comercial + Conexão comercial + Cancelar a criação do canal? + não é possível transmitir + Não é possível alterar o perfil + canal + Canal + Canal + Nome completo do canal: + O canal não possui relays ativos. Tente entrar novamente mais tarde. + Link do canal + Membros do canal + Nome do canal + Preferências do canal + perfil do canal atualizado + Canais + Nome SimpleX do canal + Canal temporariamente indisponível + Página do canal + O canal começará a funcionar com %1$d de %2$d relays. Continuar? + Dados do chat + Relay de chat + Relays de chat + Relays de chat + Relays de chat + Os relays de chat encaminham mensagens nos canais que você cria. + Conversas com administradores são proibidas. + As conversas com administradores em canais públicos não têm criptografia de ponta a ponta. Use apenas com relays de chat confiáveis. + As conversas com os membros estão desativadas + Conversar com administradores + Conversar com administradores + Conversar com administradores + Conversar com administradores + Conversar com membro + Converse com os membros antes que eles entrem. + Verifique o endereço do relay e tente novamente. + Verifique o nome do relay e tente novamente. + Fechar o aplicativo + Fechar para a bandeja do sistema + Configurar relays + Conectar + Conectar + conectado + Conecte-se mais rápido! 🚀 + conectando + Para se conectar usando o nome do canal, é necessário estar na versão mais recente do aplicativo. + Para se conectar usando o nome do contato, é necessário estar na versão mais recente do aplicativo. + A conexão falhou + Conectar-se a %s + Contato + Endereço do contato + contato desativado + o contato não está pronto + Solicitações de contato de grupos + o contato deve aceitar… + colaborador + Copiar código + Crie uma página da web para mostrar a prévia do seu canal aos visitantes antes deles entrarem. Hospede-a você mesmo ou use qualquer serviço de hospedagem. + Criar canal público + Criar canal público + Criar prévia na web. + Criar seu endereço + Criar seu link + Criar seu endereço público + Criando canal + %d eventos do canal + %d conversa(s) + %d conversas com membros + Decodificar link + Excluir canal + Excluir canal? + Apagar conversa + Apagar conversa com membro? + excluído + canal excluído + Excluir mensagens do membro + Apagar mensagens do membro? + Apagar mensagens + Apagar relay + Opções obsoletas + Descrição + Descrição muito longa + Desativar + %d mensagens + Não exigir assinatura de mensagens. + %d relay(s) selecionado(s) + descartado (%1$d tentativas) + Convide seus amigos facilmente 👋 + Mais fácil de ler. + Editar perfil do canal + Editar descrição + Ativar + Ativar + Ativar pelo menos um relay de chat para criar um canal. + Ativar chats com administradores? + Ativar mensagens temporárias por padrão. + Ativar prévias de links? + Insira uma descrição (opcional) + Insira o nome de perfil… + Insira o nome do relay… + Insira a URL da página web + Erro + Erro ao aceitar membro + Erro ao adicionar relay + Erro ao adicionar relays + Erro ao alterar perfil + Erro ao criar canal + Erro ao apagar conversa + Erro ao apagar mensagem + Erro ao marcar como lida + Erro ao abrir canal + Erro ao abrir conversa + Erro ao abrir grupo + Erro ao recusar solicitação de contato + erro: %s + Erro ao salvar perfil do canal + Erro ao salvar nome + Erro ao compartilhar endereço + Erro ao compartilhar canal + falhou + falhou + falhou + Arquivos + Arquivos e mídias não são permitidos neste chat. + Servidores de arquivos + Servidores de arquivos: %s + Filtro + A impressão digital no endereço do servidor de destino não corresponde ao certificado: %1$s. + A impressão digital do endereço do servidor de encaminhamento não corresponde à do certificado: %1$s. + A impressão digital do endereço do servidor não corresponde à do certificado: %1$s. + Para que qualquer pessoa possa entrar em contato com você + Do histórico + (do proprietário) + Link completo + Obter link + Obter nome SimpleX (BETA) + Iniciar + Grupo + o grupo foi excluído + Link do grupo + Página web do grupo + Ajuda e suporte + Como registrar um nome de teste + https:// + Se você escolher Fechar, as mensagens não serão recebidas.\nVocê pode alterar isso posteriormente nas configurações de Aparência. + Se você entrou ou criou canais, eles deixarão de funcionar permanentemente. + Imagens + inativo + Endereço do relay inválido! + Nome do relay inválido! + convidado + Convidar alguém em particular + Entrar no canal + Entrar no canal %s + Entrar no grupo + Mantenha seus chats organizados + Sair do canal + Sair do canal? + Menos tráfego nas redes móveis. + Permita que as pessoas se conectem com você pelo nome registrado no seu endereço SimpleX. + Permita que as pessoas entrem pelo nome registrado neste link do canal. + Permita que alguém se conecte com você + Link + A prévia do link será solicitada por meio de um proxy SOCKS. A consulta DNS ainda poderá ocorrer localmente pelo seu resolvedor DNS. + Links + Assinatura do link verificada. + Carregando perfil… + Gerencie seus relays. + Admissão de membros + o membro tem uma versão antiga + Bloquear inscrito para todos? + O membro foi excluído - não é possível aceitar a solicitação + As mensagens do membro serão apagadas. Isso não pode ser desfeito! + Os membros podem conversar com os administradores. + O membro entrará no grupo. Deseja aceitá-lo? + Erro na mensagem + Envie a mensagem imediatamente ao tocar em Conectar. + criptografia de ponta a ponta.]]> + A assinatura das mensagens não é obrigatória. + A assinatura das mensagens é obrigatória. + não são criptografadas de ponta a ponta. Os relays de chat podem ver essas mensagens.]]> + Migrar + Os relays de chat utilizados não suportam páginas da web. + Nenhum relay de chat + Nenhum relay de chat ativado. + Nenhuma conversa com membros + Nenhum dos seus servidores está configurado para resolver nomes SimpleX. Configure os servidores ou use um link de conexão. + Governança sem fins lucrativos + Nenhuma sessão de roteamento privado + Nenhum relay + Nenhum relay selecionado + Nenhum servidor para resolver nomes. + sem assinatura + Não se trata de uma fechadura melhor na porta de outra pessoa. Nem de um proprietário mais gentil que respeita sua privacidade, mas continua registrando todos os visitantes. Você não é um hóspede. Você está em casa. Nenhum rei pode entrar — você é soberano. + Nem todos os relays estão conectados + Mais privacidade + Nome não encontrado + Compromissos de rede + Erro de rede + Roteadores de rede não podem saber\nquem fala com quem + novo + Novo link de uso único + Novo relay de chat + Novo cargo no grupo: Moderador + Um novo membro deseja entrar no grupo. + Sem conta. Sem telefone. Sem e-mail. Sem ID.\nA criptografia mais segura. + Nenhum relay ativo + Nenhum relay disponível + Ninguém rastreava suas conversas. Ninguém desenhava um mapa de por onde você passou. A privacidade nunca foi um recurso — era o modo de vida. + não está sincronizado + Link inválido + desativado + Desativado + Link de uso único + Apenas os proprietários do canal podem alterar as preferências do canal. + Apenas você pode enviar arquivos e mídias. + Apenas o seu contato pode enviar arquivos e mídias. + Apenas a sua página acima pode exibir a prévia. + No seu celular, não em servidores. + Abrir canal + Abrir conversa + Abrir link limpo + Abrir link externo? + Abrir link completo + Abrir novo canal + Abrir nova conversa + Abrir novo grupo + Abrir para aceitar + Abrir para conectar + Abrir para entrar + Abrir para usar o bot + - optar por enviar prévias de links.\n- usar proxy SOCKS se ativado.\n- prevenir phishing em links.\n- remover rastreadores de links. + Proprietário + Proprietários e colaboradores + Autonomia: você pode executar seus próprios relays. + aguardando revisão + Atualize o aplicativo. + Aguarde os moderadores do grupo analisarem seu pedido para participar. + Endereço do relay predefinido + Nome do relay predefinido + Servidores predefinidos + Mensagens privadas e seguras. + Tempo limite de roteamento privado + Proibir conversas com administradores. + Proibir o envio de arquivos e mídia. + Tempo limite do protocolo em segundo plano + Canais públicos - fale livremente 🚀 + Nomes públicos para o seu canal ou negócio. + Enviar pelo chat + Definir nome SimpleX + Mensagem de boas-vindas + A assinatura comprova que você é o autor desta mensagem e não poderá negar isso posteriormente. + Suas conversas pertencem a você, como sempre foi antes da internet. A rede não é um lugar que você visita. É um lugar que você cria e possui. E ninguém pode tirar isso de você, seja ela privada ou pública. + Barra superior + Recusar + Recusar solicitação de contato + recusado + recusado pelo operador do relay + Recusar membro? + relay + Relay + Endereço do relay + Endereço do relay + Falha na conexão com o relay + Link do relay + Resultados do relay: + Relays adicionados: %1$s. + O teste do relay falhou! + O relay será removido do canal. Isso não pode ser desfeito! + Confiabilidade: múltiplos relays por canal. + Revisar membros + Revisar novos membros antes de admiti-los. + Configurar admissão de membros + Remover nome + Remover relay + Remover relay? + Remover inscrito + Remover inscrito? + Remover e apagar mensagens + removido + removido pelo operador + removido do grupo + Remover rastreamento de links + Denúncia enviada aos moderadores + solicitou uma conexão pelo grupo %1$s + solicitação enviada + solicitação para entrar recusada + Exigir mensagens assinadas. + Erro no resolvedor: %1$s + em análise + analisado pelos administradores + Analisar membros do grupo + Permanece ativo em segundo plano para receber mensagens + Sair do SimpleX + Remove mensagens e bloqueia membros. + Salvar perfil do canal + Salvar nome SimpleX? + Pesquisar arquivos + Pesquisar imagens + Pesquisar links + Pesquisar vídeos + Pesquisar mensagens de voz + Selecionar relays + Enviar solicitação de contato? + Enviar prévias de links pode expor seu endereço IP ao site. É possível alterar essa opção nas configurações de Privacidade mais tarde. + Enviar solicitação + Enviar solicitação sem mensagem + Envie o link por qualquer aplicativo de mensagens - é seguro. Peça para colar no SimpleX. + Será enviado ao seu contato após a conexão. + O servidor %1$s não suporta resolução de nomes. Configure os servidores ou use um link de conexão. + O servidor requer autorização para se conectar ao relay; verifique a senha. + Aviso do servidor + Definir biografia do perfil e mensagem de boas-vindas. + Configurar notificações + Configurar roteadores + Compartilhar canal… + Compartilhar endereço antigo + Compartilhar link antigo + Compartilhar endereço do relay + Compartilhar seu endereço + Breve descrição: + Link curto + Mostrar criptografia + Mostrar assinatura + Mostrar SimpleX + Assinatura ausente + ⚠️ Falha na verificação de assinatura: %s. + Assinado + Assinado e verificado + Assinar mensagem + Assinar mensagens + SimpleX + Link do canal SimpleX + Nome SimpleX + Erro no nome SimpleX + Nome SimpleX não verificado + Nomes públicos SimpleX (BETA) + Endereço do relay SimpleX + %s contribuiu com o financiamento coletivo do SimpleX Chat. + %s apoia o SimpleX Chat. + Status + inscrito + Inscrito + Denúncias de inscritos + O inscrito será removido do canal – essa ação não pode ser desfeita! + Apoiar o projeto + Converse com alguém + Toque em Conectar para conversar + Toque em Conectar para enviar solicitação + Toque em Conectar para usar o bot + Toque em Entrar no canal + Toque em Entrar no grupo + Toque para abrir + Tempo limite da conexão TCP em segundo plano + Testar relay + Use a porta TCP 443 apenas para os servidores predefinidos. + Biografia: + Você não pode enviar mensagens! + inscrito + Para enviar comandos, você precisa estar conectado. + você saiu + Salvar configurações de admissão? + O teste falhou na etapa %s. + O endereço será curto, e seu perfil será compartilhado por meio dele. + O link será curto, e o perfil do grupo será compartilhado por meio do link. + somente após sua solicitação ser aceita.]]> + Seu contato + Usar perfil anônimo + Vídeos + Seu dispositivo não tem suporte para gravação de áudio + Link de conexão não suportado + Nome de canal não suportado + Nome de contato não suportado + Nome não confirmado + Seu endereço público + O nome SimpleX %1$s está registrado, mas não está associado ao perfil. Se você for o proprietário, adicione-o ao perfil do endereço ou canal. + Você não está conectado ao servidor usado para receber mensagens desta conexão (sem assinatura). + Este link requer uma versão mais recente do aplicativo. Atualize o aplicativo ou peça ao contato um link compatível. + Este grupo requer uma versão mais recente do aplicativo. Atualize-o para participar. + O nome SimpleX %1$s está registrado sem um endereço SimpleX. Adicione seu endereço SimpleX ao nome pela página de registro. + O nome SimpleX %1$s está registrado, mas não possui um link válido. + Este nome SimpleX não está registrado. Verifique o nome. + Para verificar as chaves com este inscrito, compare ou escaneie o código nos seus dispositivos. + Você deixará de receber mensagens deste canal. O histórico da conversa será preservado. + Use este endereço no perfil das suas redes sociais, site ou na assinatura de e-mail. + Há outro caminho. Uma rede que não usa números de telefone, nomes de usuário ou contas. Sem qualquer tipo de identidade de usuário. Uma rede que conecta pessoas e transmite mensagens criptografadas sem saber quem está conectado. + A liberdade ancestral de dialogar sem observadores, erguida sobre uma infraestrutura que não tem como trair você. + Então passamos para o mundo online, e cada plataforma passou a pedir uma parte de você — seu nome, seu número, seus amigos. Aceitamos que o preço de conversar com outras pessoas era permitir que alguém soubesse com quem conversávamos. Cada geração, com suas pessoas e tecnologias, passou por isso — telefone, e-mail, mensageiros, redes sociais. Parecia ser a única maneira possível. + Mensagens de voz + Seu grupo + Seu canal + Seu contato comercial + Você pode ver suas denúncias no chat com administradores. + O canal exigiu que esta mensagem fosse assinada, mas a assinatura está ausente. + O remetente NÃO será notificado. + Verificar nome + Verificar nomes SimpleX + Seu nome SimpleX + O nome SimpleX %1$s está registrado sem um link de canal. Adicione o link do canal ao nome pela página de registro. + Seu perfil + Para usar outro perfil após uma tentativa de conexão, exclua o chat e use o link novamente. + Atualizar endereço? + Atualizar + Atualizar link do grupo + Atualizar link do grupo? + A primeira rede em que seus contatos e grupos\npertencem a você. + Por que o SimpleX foi criado. + Seu perfil + Você nasceu sem uma conta. + Sua rede + O aplicativo removeu esta mensagem após %1$d tentativas de recebê-la. + Esta configuração se aplica ao seu perfil atual + perfil do canal atualizado + você aceitou este membro + Código da página da web + Para resolver nomes + O tempo para desaparecer é definido apenas para novos contatos. + Envie seu feedback privado aos grupos. + Dê boas-vindas aos seus contatos 👋 + Endereço SimpleX curto + Atualizar seu endereço + Catalão, indonésio, romeno e vietnamita — graças aos nossos usuários! + Segurança: os proprietários possuem as chaves do canal. + Tornamos a conexão mais simples para novos usuários. + Links da web seguros + Para garantir a continuidade da SimpleX Network. + você + Nome do seu relay + Endereço do seu relay + Usar relay + Usar para novos canais + Aguardar resposta + Verificar + Aguardando o proprietário do canal adicionar relays. + via %1$s + Você se conectou ao canal por meio deste link do relay. + A conexão atingiu o limite de mensagens não entregues + Seu novo canal %1$s está conectado a %2$d de %3$d relays.\nSe você cancelar, o canal será excluído — você poderá criá-lo novamente. + Aguardar + Este é um endereço de relay de chat; ele não pode ser usado para se conectar. + Seu canal + %1$s!]]> + Desbloquear inscrito para todos? + Minimizar para a bandeja do sistema? + Minimizar para a bandeja do sistema + SimpleX — %d não lidas + Selo não verificado + Não foi possível verificar este selo, que pode não ser legítimo. + O selo é assinado com uma chave que esta versão do aplicativo não reconhece. Atualize o aplicativo para verificar este selo. 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 08285dbe78..6ab12f76f6 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/pt/strings.xml @@ -88,6 +88,9 @@ enviada você está convidado para o grupo você é observador + Você é observador + Você é membro + Você é administrador Notificações Desconectado Definir nome do contato… 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 ae0a98b44e..f2437af22b 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ro/strings.xml @@ -195,7 +195,7 @@ Repetă cererea de alăturare? Reporniți conversația salvat - salvat de la %s + salvat de la Salvează Salvat Salvat din @@ -1999,7 +1999,7 @@ Pentru a efectua apeluri, permiteți utilizarea microfonului. Încheiați apelul și încercați să sunați din nou. Când sunt activați mai mulți operatori, niciunul dintre ei nu are metadate pentru a afla cine comunică cu cine. Video pornit - Aceste setări sunt pentru profilul tău actual + Aceste setări sunt pentru profilul tău actual Da Coada Utilizare de pe desktop @@ -2195,6 +2195,11 @@ Se opresc conversațiile Total ești observator + Ești observator + Ești membru + Ești moderator + Ești administrator + Ești proprietar Videoclipul nu poate fi decodificat. Vă rugăm să încercați un alt videoclip sau să contactați dezvoltatorii. Puteți copia și micșora dimensiunea mesajului pentru a-l trimite. aștept răspunsul… 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 dae2a494dc..44f3d3858e 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/ru/strings.xml @@ -1329,7 +1329,7 @@ Отправка отчётов о доставке включена для %d контактов Отправка отчётов о доставке будет включена для всех контактов во всех видимых профилях чата. Установка для Вашего активного профиля - Установки для Вашего активного профиля + Установки для Вашего активного профиля Отправка отчётов о доставке выключена для %d контактов Шифрование работает, и новое соглашение не требуется. Это может привести к ошибкам соединения! Вторая галочка - знать, что доставлено! ✅ @@ -1799,7 +1799,7 @@ Более надёжное соединение с сетью. Статус сети сохранено - сохранено из %s + сохранено из Переслано Переслано из Получатели не видят от кого это сообщение. @@ -2771,6 +2771,13 @@ (от владельца) Ошибка при публикации канала Вы подписчик + Вы читатель + Вы член группы + Вы модератор + Вы админ + Вы владелец + Вы подписчик + Вы соавтор Новая одноразовая ссылка Или покажите QR лично или через видеозвонок. Используйте этот адрес в профиле социальных сетей, на сайте или в подписи email. diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml new file mode 100644 index 0000000000..dfd1f3d1f5 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/sk/strings.xml @@ -0,0 +1,1211 @@ + + + k + Pripojiť cez kontaktnú adresu? + Pripojiť cez jednorazovú pozvánku? + Pripojiť sa do skupiny? + Použiť aktuálny profil + Použiť nový inkognito profil + Použiť inkognito profil + Váš profil bude odoslaný kontaktu, od ktorého ste obdržali tento odkaz. + Pripojíte sa ku všetkým členom skupiny. + Pripojiť sa + Spojiť sa inkognito + Otvoriť chat + Pripojiť sa na kanál %s + Otvoriť nový chat + Otvoriť skupinu + Otvoriť novú skupinu + Neplatný odkaz + Prosím skontrolujte, že SimpleX odkaz je správny. + Otváranie databázy… + Prebieha migrácia databázy.\nMôže to trvať niekoľko minút. + Neplatná cesta súboru + Zdieľali ste neplatnú cestu súboru. Nahláste problém vývojárom aplikácie. + Zobrazenie zlyhalo + Aplikácia už beží + Pripojiť sa do %s + %1$d/%2$d relé aktívnych + %1$d/%2$d relé aktívnych, %3$d chýb + %1$d/%2$d relé aktívnych, %3$d zlyhalo + %1$d/%2$d relé aktívnych, %3$d odstránených + %1$d/%2$d relé pripojených + %1$d/%2$d relé pripojených, %3$d chýb + %1$d/%2$d relé pripojených, %3$d zlyhalo + %1$d/%2$d relé pripojených, %3$d odstránených + %1$d chýb súboru:\n%2$s + %1$d súbor(y) sa stále sťahujú. + %1$d súbor(y) sa nepodarilo stiahnuť. + %1$d súbor(y) zmazané. + %1$d súbor(y) neboli stiahnuté. + %1$d správ sa nepodarilo dešifrovať. + %1$d správ moderoval %2$s + %1$d správ preskočených. + %1$d iné súborové chyby. + %1$d majiteľ + %1$d majiteľov + %1$d majiteľov a prispievateľov + %1$d relé zlyhalo + %1$d relé neaktívnych + %1$d relé odstránených + %1$d preskočených správ + %1$d odberateľ + %1$d odberateľov + %1$s členov + %1$s správ nepreposlaných + %1$s podporilo SimpleX Chat. Odznak vypršal %2$s. + %1$s sa s vami chce spojiť prostredníctvom + 1 chat s členom + 1 deň + 1 minútu + 1 mesiac + 1 hlásenie + Jednorazový odkaz + len s jedným kontaktom - zdieľajte ho osobne alebo cez akúkoľvek chatovaciu službu.]]> + 1 týždeň + 1 rok + 30 sekúnd + 4 nové jazyky rozhrania + 5 minút + 6 nových jazykov rozhrania + a + b + Prerušiť + Prerušiť zmenu adresy + Prerušiť zmenu adresy? + O aplikácii + O operátoroch + O SimpleX + O adrese SimpleX + O SimpleX Chat + vyššie, potom: + Sfarbenie + Pristupovať k serverom cez SOCKS proxy na porte %d? Pred povolením tejto možnosti musí byť spustená proxy. + aktívne + aktívne spojenia + Pridať + Pridať kontakt + Pridať prispievateľov. + Pridať opis + Pridané servery pre médiá a súbory + Pridané chatovacie servery + Pridať priateľov + Ďalšie sfarbenie + Ďalšie sfarbenie 2 + Ďalšie sekundárne + Pridať zoznam + Pridať správu + Pridať prednastavené servery + Pridať profil + Pridať relé + Pridať relé + Adresa + Zmena adresy bude prerušená. Bude použitá stará prijímacia adresa. + Adresa alebo jednorazový odkaz? + Nastavenie adries + Pridať server + Pridať servery skenovaním QR kódu. + Pridať členov tímu + Pridajte tento kód na vašu webovú stránku. Zobrazí ukážku vášho kanálu / skupiny. + Pridať do iného zariadenia + Pridať na zoznam + Pridať uvítaciu správu + Pridať členov vášho tímu ku konverzáciám. + správca + správcovia + Správcovia môžu zablokovať člena pre všetkých. + Správcovia môžu vytvárať odkazy pre pripojenie ku skupinám. + Pokročilé nastavenia siete + Pokročilé možnosti + Pokročilé nastavenia + Pokročilé nastavenia + Pokročilé nastavenia + Ešte pár vecí + dohadujem šifrovanie… + dohadujem šifrovanie pre %s… + Odkaz pre pripojenie jednej osoby + všetko + Všetko + Všetky dáta aplikácie sú vymazané. + Všetky chaty budú zo zoznamu %s odobrané, a zoznam bude vymazaný + Všetky farebné režimy + Všetky dáta sa pri zadaní vymažú. + Všetky správy + end-to-end šifrovaním, s post-quantovým zabezpečením v priamych správach.]]> + Všetky nové správy od %s budú skryté! + Všetky nové správy od týchto členov budú skryté! + Povoliť + Povoliť + Povoliť volanie? + Povoliť volanie, ale iba ak ho povolil váš kontakt. + Povoliť downgrade + Povoliť členom chat so správcami. + Povoliť reakcie na správy. + Povoliť posielať priame správy členom. + Povoliť posielanie priamych správ odberateľom. + Povoliť odberateľom chatovať so správcami. + Povoliť nezvratné mazanie odoslaných správ. (24 hodín) + Povoliť nahlasovanie správ moderátorom. + Povoliť posielanie miznúcich správ. + Povoliť posielanie súborov a médií. + Povoliť posielanie SimpleX odkazov. + Povoliť posielanie hlasových správ. + Povoliť hlasové správy? + Povoliť kontaktom pridávať reakcie na správy. + Všetky profily + Všetky relé zlyhali + Všetky relé odobrané + Všetky servery + Všetky Vaše kontakty, konverzácie a súbory budú bezpečne šifrované a oddelene nahraté na zvolené XFTP relé. + Všetky Vaše kontakty zostanú pripojené. + Všetky Vaše kontakty zostanú pripojené. Aktualizácia profilu bude odoslaná vašim kontaktom. + Už pripájam! + Už sa ku skupine pripájate! + vždy + Vždy + Vždy zapnuté + Vždy použiť súkromné smerovanie. + Vždy použiť relé + a %d iných udalostí + Prázdny chatový profil so zadaným názvom bude vytvorený a aplikácia sa otvorí ako zvyčajne. + Nový náhodný profil bude zdieľaný. + Iný dôvod + Prijať hovor + Aplikácia + Aplikácia bude vždy bežať na pozadí + Zostavenie aplikácie: %s + Aplikácia môže prijímať notifikácie iba počas svojho behu, žiadna služba na pozadí nebude spustená + Vzhľad + Aplikácia šifruje nové lokálne súbory (okrem videí). + Ikona aplikácie + Použiť + Použiť na + Téma aplikácie + Panel nástrojov aplikácie + Aktualizácia aplikácie je stiahnutá + Je potrebná aktualizácia aplikácie + Verzia aplikácie + Verzia aplikácie: v%s + Archivovať + Archivované kontakty + \nDostupné vo verzii v5.1 + Späť + Pozadie + Služba na pozadí je vždy spustená - notifikácia sa zobrazí hneď ako bude správa k dispozícii. + Pridať kontakt: vytvoriť novú pozvánku alebo sa pripojiť cez odkaz, ktorý ste obdržali.]]> + Nesprávna adresa počítača + Najlepšie pre batériu. Budete dostávať notifikácie len keď bude aplikácia bežať (ŽIADNA služba na pozadí).]]> + Vytvoriť skupinu: vytvoriť novú skupinu.]]> + Buďte slobodný\nvo vašej sieti + Beta + Lepšie volanie + Lepšie skupiny + Lepší výkon skupín + Lepšie správy + Lepšie súkromie a bezpečnosť + Lepšie zabezpečenie ✅ + Dobré pre batériu. Aplikácia bude kontrolovať správy každých 10 minút. Môžete zmeškať hovory alebo naliehavé správy.]]> + Bio: + Bio je príliš veľké + Čierne + Bluetooth + Rozmazanie + Rozmazanie pre lepšie súkromie. + Rozmazať médiá + Bot + Vysielanie + Otestujte relé pre načítanie jeho mena.]]> + Využíva viac batérie Aplikácia bude vždy bežať na pozadí - notifikácie sa zobrazia okamžite.]]> + Xiaomi zariadenie: prosím povoľte Spustenie na pozadí v systémových nastaveniach aby Vám fungovali notifikácie.]]> + Zrušiť + Zrušiť a odstrániť kanál + Zrušiť vytvorenie kanálu? + Zrušiť náhľad súboru + zrušené %s + Zrušiť migráciu + Vytvoriť + Vytvoriť + Vytvoriť jednorazový odkaz + Vytvoriť adresu + Vytvoriť skupinu pomocou náhodného profilu. + Vytvoriť súbor + Vytvoriť skupinu + Vytvoriť odkaz na skupinu + Vytvoriť odkaz + Vytvoriť zoznam + Vytvoriť jednorazovú pozvánku + Vytvoriť profil + Vytvoriť profil + Vytvoriť tajnú skupinu + Vytvorenie tajnej skupiny + Vytvoriť SimpleX adresu + Vytvorte si svoj profil + Vytvorte si verejnú adresu + Vytváram kanál + Vytváram odkaz… + Kritická chyba + (aktuálny) + Aktuálny profil + Prispôsobiteľný tvar správ. + Vlastný čas + Databáza zašifrovaná! + Chyba databázy + ID databázy + ID databázy: %d + Aktualizácia databázy + Databáza bude zašifrovaná. + dní + %d chat(y) + %dd + %d deň + %d dní + Decentralizovaná + Vymazať + Vymazať + Prijať + Prijať + Prijať + Prijať + Prijať + Prijať + Prijať ako člena + Prijať ako pozorovateľa + Prijať podmienky + Prijať žiadosť o pripojenie? + Prijať žiadosť o kontakt + Prijať žiadosť o kontakt + prijaté + prijatý hovor + Prijaté podmienky + prijaté pozvanie + Vás prijal + Prijať inkognito + Prijať člena + Potvrdené + Pridajte relé na obnovenie doručovania správ. + Všetky chaty a správy budú vymazané – toto je nezvratné! + Všetci členovia skupiny zostanú pripojení. + všetci členovia + Všetky správy budú vymazané – toto je nezvratné! + Všetky správy budú vymazané – toto je nezvratné! Správy sa vymažú IBA u vás. + Povoliť miznúce správy, ale iba ak ich povolil váš kontakt. + Povoliť súbory a médiá, ale iba ak ich povolil váš kontakt. + Povoliť nezvratné mazanie správ, ale iba ak ho povolil váš kontakt. (24 hodín) + Povoliť reakcie na správy, ale iba ak ich povolil váš kontakt. + Povoliť hlasové správy, ale iba ak ich povolil váš kontakt. + Povoliť vašim kontaktom vám volať. + Povoliť vašim kontaktom nezvratne mazať poslané správy. (24 hodín) + Povoliť vašim kontaktom posielať miznúce správy. + Povoliť vašim kontaktom odosielať súbory a média. + Povoliť vašim kontaktom posielať hlasové správy. + Všetky hlásenia budú pre vás archivované. + Servery môže prevádzkovať ktokoľvek. + arabčina, bulharčina, fínčina, hebrejčina, thajčina a ukrajinčina - vďaka používateľom a Weblate. + Archivovať všetky hlásenia? + Archivovať a nahrať + Archivujte kontakty pre neskoršie chatovanie. + archivované hlásenie + Archivovať %d hlásení? + Archivovať hlásenie + Archivovať hlásenie? + Archivovať hlásenia + Archivujem databázu + Opýtať sa + pokusy + Lepšie kanály 📢 + katalánčina, indonézština, rumunčina a vietnamčina - vďaka našim používateľom! + kanál + Kanál + Kanál + Celý názov kanálu: + prijal %1$s + Chyby potvrdenia + Pridajte adresu na váš profil, aby ju vaše SimpleX kontakty mohli zdieľať s ostatnými ľudmi. Aktualizácia profilu bude odoslaná vašim kontaktom. + Každá stránka môže zobraziť náhľad. + Zálohovať dáta aplikácie + Migrácia dát aplikácie + %s archivoval hlásenie + pre každý chatový profil ktorý máte v aplikácii.]]> + Zvuk vypnutý + Zvuk zapnutý + Overiť + Overenie zrušené + Overenie zlyhalo + Overenie nie je k dispozícii + autor + Automaticky prijať + Žiadosti o kontakt prijímať automaticky + Obrázky prijímať automaticky + Odznak nie je možné overiť + chybné ID správy + Chybné ID správy + Je aktívna optimalizácia batérie, ktorá vypína službu na pozadí a pravidelné požiadavky na nové správy. Môžete ich znovu zapnúť v nastaveniach. + Buďte slobodní vo vašej sieti. + Lepšie zobrazenie dátumu správ. + Zablokovať + zablokované + Zablokovať pre všetkých + Zablokovať členov skupiny + Zablokovať člena + Zablokovať člena? + Zablokovať člena pre všetkých? + Zablokovať členov pre všetkých? + Varovanie: archív bude vymazaný.]]> + volanie + prebiehajúci hovor + Prebiehajúci hovor + Zrušiť náhľad obrázku + zrušiť náhľad odkazu + Zrušiť živú správu + Nie je možné prijať súbor + Nie je možné odoslať správu + nie je možné vysielať + Prekročená kapacita - príjemcovi neprišli predtým poslané správy. + Zmeniť + Zmeniť automatické mazanie správ? + Zmeniť zoznam + Zmeniť poradie + Kanál nemá žiadne aktívne relé. Prosím, skúste sa pripojiť neskôr. + Odkaz kanálu + Odkaz kanálu + Členovia kanálu + Názov kanálu + Vlastnosti kanálu + Kanály + Kanál je dočasne nedostupný + Webová stránka kanálu + Chat + Chat už existuje! + Farby chatu + Konzola chatu + Dáta chatu + Databáza chatu + Databáza chatu + Profil chatu + Relé chatu + Relé chatu + Relé chatu + Relé chatu + Chaty so správcami sú zakázané. + Chaty s členmi + Téma chatu + Chat bude vymazaný u všetkých členov – toto je nezvratné! + Chat bude u vás vymazaný – toto je nezvratné! + Chat so správcami + Chat so správcami + Chat so správcami + Chat so správcami + Chat s členom + Chat s vývojármi + Povoliť každému vložiť + Požiadané o prijatie obrázku + Požiadané o prijatie videa + Pripojiť + Hlasové a video hovory + hlasový hovor + hlasový hovor (nešifrovaný e2e) + Hlasové a video hovory + Povoľte v nasledujúcom dialógu okamžité prijímanie notifikácií.]]> + Zničili sme silu vedieť, kto ste. Aby vám vašu moc nikto nemohol vziať. + Môže to byť vypnuté v nastaveniach – notifikácie sa stále budú zobrazovať ak aplikácia bude bežať.]]> + zablokované správcom + Zablokované správcom + Zablokovať odberateľa pre všetkých? + tučné + Upozornenie: relé na správy a súbory sú pripojené pomocou SOCKS proxy. Hovory používajú priame spojenie.]]> + Upozornenie: Ako ochranu bezpečnosti sa použitím tej istej databázy na dvoch zariadeniach rozbije dešifrovanie vašich správ z vašich pripojení.]]> + Firemná adresa + Firemná adresa + Firemné chaty + Firemné spojenie + Firmy + Podľa chatového profilu (predvolené) alebo podľa spojenia (BETA). + Hovor už skončil! + Ukončený hovor + hovor ukončený %1$s + chyba volania + volám… + Hovory + Hovory na uzamknutej obrazovke: + Volanie je zakázané! + Zmeniť chatové profily + Zmeniť zamykací režim + Zmeniť prijímaciu adresu + Zmeniť prijímaciu adresu? + Zmeniť režim sebazničenia + SimpleX meno kanálu + Kanál bude vymazaný u všetkých odberateľov – toto je nezvratné! + Kanál bude odstránený u vás – toto je nezvratné! + Kanál začne pracovať s %1$d z %2$d relé. Pokračovať? + Databáza chatu vymazaná + Databáza chatu exportovaná + Databáza chatu importovaná + Relé chatu preposielajú správy v kanáloch, ktoré vytvoríte. + Relé chatu preposielajú správy odberateľom kanálu. + Chaty so správcami na verejných kanáloch nemajú E2E šifrovanie – používajte iba s dôveryhodnými chatovými relé. + Chat s členmi je vypnutý + Skontrolovať aktualizácie + Skontrolovať aktualizácie + Kontrolovať správy každých 10 minút + Skontrolujte adresu relé a skúste to znova. + Skontrolujte meno relé a skúste to znova. + Skontrolujte adresu serveru a skúste to znova. + Kontroluje nové správy každých 10 minút, po dobu až 1 minúty + Skontrolujte pripojenie k internetu a skúste to znova + Čínske a Španielske rozhranie + Vybrať súbor + Časti vymazané + Časti stiahnuté + Časti nahraté + Vyčistiť + Vyčistiť + Vyčistiť + Zavrieť aplikáciu + Zavrieť na lištu + farebný + Farebný režim + Už čoskoro! + Porušenie pokynov pre komunitu + Porovnať súbor + Podmienky prijaté dňa: %s. + %s.]]> + Podmienky používania + Nastavené SMP servery + Nastavené XFTP servery + Nastaviť relé + Potvrdiť + Potvrdiť vymazanie kontaktu? + Potvrdiť aktualizáciu databázy + Potvrdiť súbory z neznámych serverov. + Potvrdiť nastavenie siete + Potvrďte nahratie + Pripojiť + Pripojiť + Pripojiť + Pripojiť + Pripojiť sa automaticky + Pripojiť sa priamo? + pripojené + Pripojený počítač + Pripojený mobil + Pripojené servery + Pripojené k počítaču + Pripojený k mobilu + Pripojte sa rýchlejšie! 🚀 + Pripojenie zlyhalo + Pripojenie nie je pripravené. + Časový limit pripojenia vypršal + Pripojenie k PC je v zlom stave + %s je v zlom stave]]> + Kontakt + kontakt %1$s zmenený na %2$s + Adresa kontaktu + Kontakt povoľuje + Kontakt už existuje + Kontakt a všetky správy budú vymazané – toto je nezvratné! + kontakt vymazaný + Kontakt vymazaný! + Skrytý kontakt: + Kontakt je vymazaný. + Meno kontaktu + kontakt nie je pripravený + Kontakty + Kontakty + Pokračovať + Pokračovať + Pokračovať + Prispieť + prispievateľ + Konverzácia vymazaná! + Skopírované do schránky + Kopírovať + Kopírovať kód + Kopírovať chybu + Verzia jadra: v%s + Roh + Opraviť meno na %s? + Vytvoriť adresu, aby sa s vami ľudia mohli spojiť. + Vytvoriť chatový profil + Vytvorený + Vytvoriť nový profil v počítačovej aplikácii. 💻 + Vytvoriť verejný kanál + Vytvoriť verejný kanál + Vytvoriť rad + Vytvoriť vašu adresu + Vytvorte váš odkaz + tvorca + Text aktuálnych podmienok sa nepodarilo načítať, podmienky si môžte pozrieť prostredníctvom tohto odkazu: + Momentálne je maximálna podporovaná veľkosť súboru %1$s. + Prispôsobiť a zdieľať farebné témy. + Prispôsobiť tému + Vlastné témy + %d udalostí kanálu + %d chatov s členmi + %d kontakt(y) vybraný(é) + Ladiť doručovanie + Chyba dešifrovania + chyby dešifrovania + Vymazať + Vymazať + Vymazať adresu + Vymazať adresu? + Vymazať za + Vymazať všetky súbory + Vymazať kanál + Vymazať kanál? + Vymazať chat + Vymazať chat? + Vymazať chat + Vymazať kontakt + Vymazať kontakt? + vymazané + vymazané + Vymazaný + Vymazané v + Vymazať databázu + Vymazať databázu z tohto zariadenia + vymazaný kanál + vymazaný kontakt + Vymazať %d správ? + Vymazať %d správ členov? + Vymazať súbor + Vymazať súbory a médiá? + Vymazať súbory všetkých chatových profilov + Vymazať pre všetkých + Vymazať pre mňa + Vymazať skupinu + Vymazať skupinu? + Vymazať obrázok + Vymazať odkaz + Vymazať odkaz? + Vymazať zoznam? + Vymazať správu? + Vymazať správy + Vymazať správy + Vymazať správy po + Vymazať profil + Aplikácia už možno beží alebo sa nesprávne vypla. Zapnúť aj tak? + Relácia aplikácie + pre každý kontakt a každého člena skupiny.\nUpozornenie: ak máte veľa spojení, spotreba vašej batérie a internetu môže byť podstatne vyššia a niektoré spojenia môžu zlyhať.]]> + zmenil rolu %s na %s + zmenil vašu rolu na %s + Zmeniť rolu + Zmeniť rolu? + Chyba pri zmene roli + Rozšíriť výber rolí + Počiatočná rola + člen + moderátor + Ste pozorovateľ + Ste člen + Ste moderátor + Ste správca + Ste majiteľ + Ste odberateľ + Ste prispievateľ + moderátori + Nová skupinová rola: Moderátor + Nová rola člena + Správcovia teraz môžu:\n- mazať správy členov.\n- zakázať členov (rola pozorovateľ) + pozorovateľ + majiteľ + majitelia + relé + Maže správy a blokuje členov. + Rola + odberateľ + Rola bude zmenená na %s. Všetci v kanáli budú informovaní. + Rola bude zmenená na %s. Všetci v chate budú informovaní. + Rola bude zmenená na %s. Všetci v skupine budú informovaní. + Rola bude zmenená na %s. Člen obdrží novú pozvánku. + zmenili ste svoju rolu na %s + zmenili ste rolu %s na %s + Mobilný + potvrdená súpiska + Prístupový kód aplikácie + Prístupový kód aplikácie + Prístupový kód aplikácie je nahradený prístupovým kódom sebazničenia. + Zmeniť prístupový kód + Zmeniť prístupový kód sebazničenia + Potvrdiť prístupový kód + Aktuálny prístupový kód + Povoliť prístupový kód sebazničenia + Zadajte prístupový kód + Ak tento prístupový kód zadáte pri otvorení aplikácie, všetky dáta aplikácie budú nenávratne vymazané! + Ak zadáte váš prístupový kód sebazničenia pri otvorení aplikácie: + Nesprávny prístupový kód + Nový prístupový kód + Žiadny prístupový kód aplikácie + Prístupový kód + Prístupový kód zmenený! + Zadať prístupový kód + Prístupový kód nezmenený! + Prístupový kód nastavený! + Prístupový kód sebazničenia + Prístupový kód sebazničenia + Prístupový kód sebazničenia zmenený! + Prístupový kód sebazničenia povolený! + Nastavte to namiesto systémového overenia. + Nastaviť prístupový kód + Odoslať + Nedá sa získať prístup ku Keystore na uloženie hesla databázy + Potvrdiť heslo + Databáza je šifrovaná pomocou náhodnej prístupovej frázy. Prosím zmeňte ju pred exportovaním. + Zadajte heslo vo vyhľadávaní + Chyba pri ukladaní hesla používateľa + Heslo skrytého profilu + Heslo + Heslo na zobrazenie + Prosím zadajte predošlé heslo po obnovení databázovej zálohy. Toto je nezvratné. + Prosím zapamätajte si ho alebo ho bezpečne uložte - neexistuje spôsob, ako obnoviť stratené heslo! + Heslo profilu + Chránte svoje chatové profily pomocou hesla! + Uložiť heslo profilu + Server vyžaduje overenie aby sa mohol pripojiť na relé, skontrolujte heslo. + Server vyžaduje overenie pre vytvorenie radov, skontrolujte heslo. + Server vyžaduje overenie pre nahrávanie, skontrolujte heslo. + Nastavte prístupovú frázu pre export + Aby ste odhalili svoj skrytý profil, zadajte celé heslo do vyhľadávacieho poľa na stránke profilov chatu. + Vaše prihlasovacie údaje môžu byť zaslané nešifrované. + Android Keystore je použitý na bezpečné uloženie prístupovej frázy - umožňuje to fungovanie služby oznámení. + Android Keystore bude použitý na bezpečné uloženie prístupovej frázy potom ako reštartujete aplikáciu alebo zmeníte prístupovú frázu - umožní to fungovanie služby oznámení. + Upozornenie: ak stratíte vašu prístupovú frázu, NEBUDE možné ju obnoviť ani zmeniť.]]> + Zmeniť prístupovú frázu k databáze? + Potvrdiť novú prístupovú frázu… + Potvrďte že si pamätáte prístupovú frázu k databáze, aby ste ju mohli migrovať. + Aktuálna prístupová fráza… + Prístupová fráza k šifrovaniu databáze bude aktualizovaná. + Prístupová fráza k šifrovaniu databáze bude aktualizovaná a uložená v nastaveniach. + Prístupová fráza k šifrovaniu databáze bude aktualizovaná a uložená v Keystore. + Databáza je šifrovaná pomocou náhodnej prístupovej frázy, môžete ju zmeniť. + Prístupová fráza k databáze + Prístupová fráza k databáze a export + Prístupová fráza k databáze sa líši od tej uloženej v Keystore. + Prístupová fráza k databáze je potrebná na otvorenie chatu. + Databáza bude šifrovaná a prístupová fráza bude uložená v nastaveniach. + Databáza bude šifrovaná a prístupová fráza bude uložená v Keystore. + Zadajte správnu prístupovú frázu. + Zadajte prístupovú frázu + Zadajte prístupovú frázu… + Chyba čítania prístupovej frázy k databáze + Chyba overenia prístupovej frázy: + Nová prístupová fráza… + Prístupová fráza v Keystore sa nedá prečítať, prosím, zadajte ju ručne. Toto sa môže stať po aktualizácii systému nekompatibilnou s aplikáciou. Ak to tak nie je, kontaktujte prosím vývojárov. + Prístupová fráza v Keystore sa nedá prečítať. Toto sa môže stať po aktualizácii systému nekompatibilnou s aplikáciou. Ak to nie je váš prípad, kontaktujte prosím vývojárov. + Prístupová fráza je potrebná + Prístupová fráza nebola nájdená v Keystore, prosím zadajte ju ručne. Toto sa môže stať ak ste obnovili dáta aplikácie pomocou zálohovacieho nástroja. Ak to tak nie je, prosím kontaktujte vývojárov. + Zadajte prosím správnu a aktuálnu prístupovú frázu. + Prosím bezpečne uložte prístupovú frázu, ak ju stratíte, NEBUDE možné pristupovať k chatu. + Prosím bezpečne si uložte prístupovú frázu, ak ju stratíte, NEBUDE možné ju zmeniť. + Náhodná prístupová fráza je uložená v nastaveniach ako nešifrovaný text.\nMôžete si ju neskôr zmeniť. + Vymazať + Vymazať prístupovú frázu z Keystore? + Vymazať prístupovú frázu z nastavení? + Uložiť prístupovú frázu a otvoriť chat + Uložiť prístupovú frázu v Keystore + Uložiť prístupovú frázu v nastaveniach + Nastaviť prístupovú frázu k databáze + Nastaviť prístupovú frázu + Nastavenie prístupovej frázy k databáze + Pokus o zmenu prístupovej frázy k databáze nebol dokončený. + Prístupová fráza je uložená v nastaveniach ako nešifrovaný text. + Prístupová fráza bude uložená v nastaveniach ako nešifrovaný text potom ako ju zmeníte alebo reštartujete aplikáciu. + Ak chcete dostávať notifikácie, prosím, zadajte prístupovú frázu k databáze. + Aktualizovať prístupovú frázu k databáze + Použiť náhodnú prístupovú frázu + Overiť prístupovú frázu k databáze + Overiť prístupovú frázu + Nesprávna prístupová fráza k databáze + Nesprávna prístupová fráza! + Musíte zadať prístupovú frázu pri každom zapnutí aplikácie - nie je uložená na zariadení. + Chatová databáza nie je šifrovaná - nastavte prístupovú frázu na jej ochranu. + %dh + %d hodina + %d hodín + Hlasový hovor + Hlasové/video hovory + Hlasové/video hovory sú zakázané. + zablokované + zablokoval %s + Vy aj váš kontakt môžete pridávať reakcie na správy. + Vy aj váš kontakt môžete nezvratne vymazať odoslané správy. (24 hodín) + Vy aj váš kontakt môžete volať. + Vy aj váš kontakt môžete posielať miznúce správy. + Vy aj váš kontakt môžete posielať súbory a médiá. + Vy aj váš kontakt môžete posielať hlasové správy. + Dolná lišta + Kontaktu sa nedá zavolať + Členovi skupiny sa nedá zavolať + Profil sa nedá zmeniť + nemôže posielať správy + mení sa adresa… + mení sa adresa… + mení sa adresa pre %s… + Profil kanálu je uložený na zariadeniach odberateľov a na relé chatu. + profil kanálu aktualizovaný + Chat je spustený + Chat je zastavený + Chat je zastavený + Chat je zastavený. Ak ste už použili túto databázu na inom zariadení, mali by ste ju pred spustením chatu previesť späť. + Vymazať obsah chatu + Vymazať obsah chatu? + Vymazať obsah súkromných poznámok? + Kliknite na info tlačidlo blízko poľa adresy na povolenie použitia mikrofónu. + Tlačidlo zavrieť + Porovnajte bezpečnostné kódy s vašimi kontaktmi. + %s.]]> + Podmienky budú prijaté pre povolených operátorov po 30 dňoch. + %s.]]> + %s.]]> + Podmienky budú prijaté dňa: %s. + Podmienky budú automaticky prijaté pre povolených operátorov dňa: %s. + Konfigurácia ICE serverov + Kontakt skontrolovaný + kontakt vypnutý + kontakt má e2e šifrovanie + kontakt nemá e2e šifrovanie + Kontakty môžu označiť správy na vymazanie; vy si ich budete môcť zobraziť. + kontakt by mal prijať… + Kontakt bude vymazaný – toto je nezvratné! + Skupina bude vymazaná u všetkých členov – toto je nezvratné! + Skupina bude vymazaná u vás – toto je nezvratné! + Správy člena budú vymazané – toto je nezvratné! + Členovia budú odstránení z chatu – toto je nezvratné! + Členovia budú odstránení zo skupiny – toto je nezvratné! + Člen bude odstránený z chatu – toto je nezvratné! + Člen bude odstránený zo skupiny – toto je nezvratné! + Správy budú vymazané – toto je nezvratné! + Správa bude vymazaná – toto je nezvratné! + Relé bude odstránené z kanálu – toto je nezvratné! + Štatistiky serveru budú vymazané – toto je nezvratné! + Odberateľ bude odstránený z kanálu – toto je nezvratné! + Toto je nezvratné – všetky prijaté a odoslané súbory a médiá budú vymazané. Obrázky s nízkym rozlíšením ostanú. + Toto je nezvratné – správy odoslané a prijaté skôr, ako bolo zvolené, budú vymazané. Môže to trvať niekoľko minút. + Toto je nezvratné – správy odoslané a prijaté v tomto chate skôr, ako bolo zvolené, budú vymazané. + Toto je nezvratné – váš profil, kontakty, správy a súbory budú nezvratne stratené. + Vaša aktuálna chatová databáza bude VYMAZANÁ a NAHRADENÁ importovanou databázou.\nToto je nezvratné – váš profil, kontakty, správy a súbory budú nezvratne stratené. + Vymazať a informovať kontakt + Vymazať správy chatu z vášho zariadenia. + Vymazať chat s členom? + Vymazané: %s + Vymazať správu člena? + Vymazať správy člena + Vymazať správy člena? + Vymazať rad + Vymazať relé + Vymazať hlásenie + Vymazať server + Vymazať až 20 správ naraz. + Chyby mazania + Opis + Opis + Opis je príliš dlhý + Počítač + Podrobné štatistiky + Detaily + Nástroje pre vývojárov + Zariadenie + Zariadenia + %d súbor(y) s celkovou veľkosťou %s + %d udalostí skupiny + priamo + Priame správy + Priame správy medzi členmi sú zakázané. + Priame správy medzi členmi sú v tomto chate zakázané. + Priame správy medzi členmi sú v tejto skupine zakázané. + Priame správy medzi odberateľmi sú zakázané. + Vypnúť + Vypnúť + Vypnúť + Vypnúť automatické mazanie správ? + vypnuté + vypnuté + Vypnuté + Vypnúť mazanie správ + Vypnúť pre všetkých + Vypnúť pre všetky skupiny + Pripájam k počítaču + Spojenie ukončené + Pripojiť sa k počítaču + Adresa počítača + Verzia aplikácie na počítači %s nie je kompatibilná s touto aplikáciou. + Počítačové zariadenia + Počítač má nepodporovanú verziu. Prosím, uistite sa že máte rovnakú verziu na oboch zariadeniach + Počítač má chybný kód pozvánky + Počítač je zaneprázdnený + Počítač je neaktívny + Naskenovať QR kód.]]> + Počítač bol odpojený + Odpojiť počítač? + Chyba zobrazenia notifikácie, kontaktujte vývojárov. + Počítač nájdený + Nekompatibilná verzia + (nový)]]> + Prepojené počítače + Prepojiť mobilné a počítačové aplikácie! 🔗 + Nová počítačová aplikácia! + Použiť z počítača v mobilnej aplikácii a naskenujte QR kód.]]> + Použiť z počítača + Otvorte nastavenia Safari / Webové stránky / mikrofón, potom vyberte povoliť pre localhost. + Vložiť adresu počítača + Prosím skontrolujte, že mobilná a počítačová aplikácia sú pripojené ku rovnakej miestnej sieti, a že počítačový firewall povoľuje pripojenie.\nProsím zdieľajte akékoľvek iné problémy s vývojármi. + Skenovať QR kód z počítača + Tento odkaz bol použitý s iným mobilným zariadením, prosím vytvorte na počítači nový odkaz. + Vypršal čas pri pripojovaní k počítaču + Aby ste povolili mobilnej aplikácii sa pripojiť k počítaču, otvorte tento port vo vašom firewalle, ak ho máte povolený + nesprávny hash správy + Nesprávny hash správy + Lepšie používanie aplikácie + Fotoaparát + Fotoaparát + Fotoaparát a mikrofón + Fotoaparát nie je k dispozícii + Nie je možné inicializovať databázu + Nie je možné pozvať kontakt! + Nie je možné pozvať kontakty! + Nie je možné poslať správu členovi skupiny + zmenil adresu pre vás + Chaty + Chaty + Chat s členmi než sa pripoja. + Obsah porušuje podmienky používania + Kontextová ikona + Kontrolujte svoju sieť + Vytvorte webovú stránku aby ste zobrazili náhľad vášho kanálu pre návštevníkov predtým, ako začnú odoberať. Hostujte si ju sami alebo použite akékoľvek statické hostovanie. + Vytvoriť webový náhľad. + Tmavý + Tmavý + Tmavá téma + Downgrade databázy + ID databázy a možnosti izolácie transportu. + Dekódovať odkaz + Chyba dekódovania + predvolené (%s) + predvolené (%s) + Povolenie miznúcich správ v predvolených nastaveniach. + Obnoviť pôvodné nastavenia + Obnoviť pôvodnú tému + Predvolený webový prehliadač je potrebný na hovory. Prosím, nakonfigurujte predvolený prehliadač v systéme a zdieľajte s vývojármi viac informácií. + Vypnúť notifikácie + Miznúca správa + Miznúce správy + Miznúce správy + Miznúce správy sú zakázané. + Miznúce správy sú v tomto chate zakázané. + Zmizne v + Zmizne: %s + Odpojiť + Odpojiť + Odpojené + %s z dôvodu: %s]]> + Odpojené z dôvodu: %s + Odpojiť mobily + Objaviteľné cez lokálnu sieť + Objavte a pripojte sa ku skupinám + Objavte cez lokálnu sieť + %dm + %d správ + %d správ zablokovaných + %d správ zablokovaných správcom + %d správ označených ako vymazané + %d min + %d minút + %d mesiac + %d mesiacov + %dmes + Neposielať históriu novým členom. + Neposielať históriu novým odberateľom. + Nevytvárať adresu + Nepovoliť + Nenechajte si ujsť dôležité správy. + Znova nezobrazovať + Stiahnuť + Stiahnuť + Stiahnuté + Stiahnuté súbory + Chyby sťahovania + Stiahnutie zlyhalo + Stiahnuť súbor + Sťahuje sa aktualizácia, nezatvárajte aplikáciu + Francúzske rozhranie + Maďarské a turecké rozhranie + Talianske rozhranie + Japonské a portugalské rozhranie + Litovské rozhranie + Poľské rozhranie + Vďaka používateľom – prispejte prostredníctvom Weblate! + Vďaka používateľom – prispejte prostredníctvom Weblate! + Vďaka používateľom – prispejte prostredníctvom Weblate! + Vďaka používateľom – prispejte prostredníctvom Weblate! + Stiahnuť novú verziu z GitHubu. + Stiahnuť %s (%s) + %d relé vybratých + %d hlásení + %ds + %d sek + %d sekúnd + %dt + %d týždeň + %d týždňov + e2e šifrovanie + e2e šifrovaný hlasový hovor + e2e šifrovaný video hovor + Rôzne mená, avatary a izolácie prenosu. + Ďalšie zníženie spotreby batérie + Odkazy na skupiny + Moderácia skupín + Uvítacia správa pre skupinu + Skryté profily chatu + Skrytie obrazovky aplikácie v zobrazení nedávnych aplikácií. + Vylepšenie ochrany súkromia a zabezpečenia + Vylepšená konfigurácia serverov + Nezvratné mazanie správ + Živé správy + Max. 40 sekúnd, prijíma sa okamžite. + Návrh správy + Ďalšie vylepšenia už čoskoro! + Ďalšie vylepšenia už čoskoro! + Viac profilov chatu + Zachovanie posledného návrhu správy, aj s prílohami. + Súkromné názvy súborov + Príjemci vidia aktualizácie počas toho, ako ich píšete. + Zníženie spotreby batérie + Odoslané správy budú po uplynutí nastavenej doby vymazané. + Nastavte správu zobrazenú novým členom! + Bezpečnosť SimpleX Chat bola preverená spoločnosťou Trail of Bits. + Podpora bluetooth a ďalšie vylepšenia. + Na ochranu časového pásma, obrazové/hlasové súbory používajú UTC. + Izolácia prenosu + Overenie zabezpečenia pripojenia + Hlasové správy + S voliteľnou uvítaciou správou. + Vaše kontakty môžu povoliť úplné mazanie správ. + Preverenie bezpečnosti + Povoliť v priamych chatoch (BETA)! + Šifrovanie uložených súborov a médií + Aj pri vypnutí v konverzácii. + Rýchlo a bez čakania, než bude odosielateľ online! + Rýchlejšie pripojovanie a spoľahlivejšie správy. + Filtrovať neprečítané a obľúbené chaty. + Konečne ich máme! 🚀 + Nájdite chaty rýchlejšie + Opraviť šifrovanie po obnovení zálohy. + Preposlať a uložiť správy + Vylepšené doručovanie správ + Vylepšené doručovanie správ + Zvuky v hovore + Inkognito skupiny + Pripojiť sa ku skupinovej konverzácii + Nechať jednu správu zmiznúť + Upravte si svoje chaty, aby vyzerali inak! + Reakcie na správy + Zdroje správ zostávajú súkromné. + Migrovať na iné zariadenie pomocou QR kódu. + Spoľahlivejšie sieťové pripojenie. + - stabilnejšie doručovanie správ.\n- trochu lepšie skupiny.\n- a viac! + Správa siete + Nové témy pre chaty + Zastarané možnosti + Možnosti pre vývojárov + Možnosti prepojeného počítača + Nové možnosti médií + Uložiť + Zobraziť možnosti pre vývojárov + - voliteľné oznámenie odstráneným kontaktom.\n- mená profilov s medzerami.\n- a viac! + Perzské rozhranie + Volanie obraz v obraze + Súkromné smerovanie správ 🚀 + Súkromné poznámky + Bezpečnejšie skupiny + Zjednodušený režim inkognito + Štvorec, kruh, alebo čokoľvek medzi. + Na skrytie nežiadúcich správ. + Videá a súbory až do veľkosti 1 gb + - hlasové správy dĺžky až 5 minút.\n- voliteľný čas na zmiznutie správ.\n- história úprav. + Bude povolené v priamych chatoch! + So šifrovanými súbormi a médiami. + So zníženou spotrebou batérie. + So zníženou spotrebou batérie. + Vymažte alebo moderujte až 200 správ naraz. + Ľahšie pozývanie priateľov 👋 + Povoľte Flux v nastaveniach siete a serverov pre lepšie súkromie metadát. + Rýchlejšie mazanie skupín. + Rýchlejšie odosielanie správ. + Preposlať až 20 správ naraz. + Ak vás niekto zmieni, dostanete notifikáciu. + Pomôžte správcom moderovať ich skupiny. + Vylepšená navigácia chatu + Zväčšiť veľkosť písma. + Chráni vašu IP adresu a pripojenia. + Udržujte si svoje chaty čisté + Decentralizácia siete + Neziskové riadenie + Organizujte chaty do zoznamov + Vlastníctvo: môžete prevádzkovať vlastné relé. + Súkromie: pre majiteľov a odberateľov. + Súkromie pre vašich zákazníkov. + Súkromné názvy súborov a médií. + Verejné kanály - hovorte slobodne 🚀 + Spoľahlivosť: viacero relé na kanál. + Bezpečné odkazy + Zabezpečenie: majitelia majú kľúče ku kanálu. + Nastavte bio profilu a uvítaciu správu. + Zdieľajte vašu adresu + Ľahšie čitateľné. + Spravujte si svoje relé. + Verejné mená pre vaše kanály alebo firmy. + SimpleX verejné mená (BETA) + Upraviť + Upraviť + Upraviť profil kanálu + Upraviť opis + upravené + Upraviť profil skupiny + Upraviť obrázok + E-mail + Povoliť + Povoliť + Povoliť + Povoľte aspoň jedno chatové relé na vytvorenie kanálu. + Povoliť automatické mazanie správ? + Povoliť hovory zo zamknutej obrazovky prostredníctvom Nastavení. + Povoliť prístup ku fotoaparátu + Povoliť chat so správcami? + povolené + Povolené pre + Šifrovať + Zašifrovať databázu? + Zašifrovaná databáza + šifrovanie dohodnuté + šifrovanie dohodnuté pre %s + šifrovanie ok + šifrovanie ok pre %s + Šifrovať lokálne súbory + Zadajte opis (voliteľné) + Zadajte názov skupiny: + Zadajte názov profilu… + Zadajte názov relé… + Zadajte uvítaciu správu… + Zadajte vaše meno: + chyba + Chyba + Chyba + Chyba + Chyba + Chyba + Chyba: %1$s + Ukončiť bez uloženia + Experimentálny + Exportovať databázu + Exportovaný súbor neexistuje + Exportovať tému + zlyhal + zlyhalo + zlyhalo + Nepodarilo sa načítať chat + Nepodarilo sa načítať chaty + Obľúbené + Obľúbené + Súbor + Súbor + Súbor: %s + Súbory + Súbory + Súbory + Súbory a médiá + Súbory a médiá sú zakázané. + Súbory a médiá sú zakázané v tomto chate. + Súbory a médiá nie sú povolené + Súbory a médiá sú zakázané! + Súbor uložený + povolené pre kontakt + Povoliť zámok + Povoliť TCP keep-alive + Status súboru + Súbory a médiá + Status súboru: %s + Súbor bude vymazaný zo serverov. + Filter + Opraviť + Opraviť + Opraviť pripojenie + Opraviť pripojenie? + Opraviť pripojenie? + Pre všetkých moderátorov + Pre všetkých + Pre mňa + Preposlať + Preposlať %1$s správ(u)? + preposlané + Preposlané + Preposlané od + Preposielam %1$s správ + Preposielaciemu serveru %1$s sa nepodarilo pripojiť k cieľovému serveru %2$s. Prosím, skúste to neskôr. + Adresa preposielacieho serveru je nekompatibilná s nastaveniami siete: %1$s. + Verzia preposielacieho serveru je nekompatibilná s nastaveniami siete: %1$s. + Preposlať správu… + Preposlať správy… + Preposlať správy bez súborov? + Z Galérie + Z histórie + (od majiteľa) + Celé meno: + Plne decentralizovaná – viditeľná iba pre členov. + Celý odkaz + Celý odkaz + Začať + Dobré ráno! + Dobré popoludnie! + Skupina + Skupina + Skupina už existuje! + skupina vymazaná + Skupina je neaktívna + skupina je vymazaná + Odkaz skupiny + Odkaz skupiny + profil skupiny aktualizovaný + Skupiny + Webová stránka skupiny + Slúchadlá + pomoc + Pomoc + Pomoc a podpora + Ahoj!\nSpoj sa so mnou cez SimpleX Chat: %s + Skryté + Skryť + Skryť + Skryť + Skryť: + Skryť kontakt aj správu + Skryť profil + História + História nie je odoslaná novým členom. + Host + História nie je odoslaná novým odberateľom. + hodín + Ako to ovplyvňuje batériu + Ako to pomáha súkromiu + Ako to funguje + Ako na to + https:// + Ignorovať + Obrázok + Obrázok + Obrázky + Obrázok odoslaný + Okamžite + Importovať + neaktívny + neaktívny + Nevhodný obsah + Nevhodný profil + Inkognito + Nekompatibilná verzia databázy + Info + Nainštalované úspešne + Nainštalovať aktualizácie + Okamžité + Okamžité notifikácie + Okamžité notifikácie! + Okamžité notifikácie sú vypnuté! + 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 c7313e76a3..da2aea2868 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/th/strings.xml @@ -1078,6 +1078,10 @@ คุณไม่มีการแชท แชท คุณเป็นผู้สังเกตการณ์ + คุณเป็นผู้สังเกตการณ์ + คุณเป็นสมาชิก + คุณเป็นผู้ดูแลระบบ + คุณเป็นเจ้าของ ภาพไม่สามารถถอดรหัส ได้ โปรดลองใช้รูปภาพอื่นหรือติดต่อนักพัฒนา คุณไม่สามารถส่งข้อความได้! กําลังรอภาพ @@ -1280,7 +1284,7 @@ เร็วๆ นี้! อนุญาตให้มีการเจรจา encryption อีกครั้งสําหรับ %s %s: %s - การตั้งค่าเหล่านี้ใช้สำหรับโปรไฟล์ปัจจุบันของคุณ + การตั้งค่าเหล่านี้ใช้สำหรับโปรไฟล์ปัจจุบันของคุณ สามารถลบล้างได้ในการตั้งค่าผู้ติดต่อและกลุ่ม ปิดใช้งาน (เก็บการแทนที่) เปิดใช้งาน (เก็บการแทนที่) 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 a1ead8c55e..bacc84b0f7 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/tr/strings.xml @@ -842,6 +842,13 @@ Gruba davetlisiniz Hiç sohbetiniz yok Gözlemcisiniz + Gözlemcisiniz + Üyesiniz + Yöneticisiniz + Yöneticisiniz + Sahipsiniz + Abonesiniz + Katkıda bulunansınız sen gözlemcisin Güvenlik kodunu görüntüle Sesli mesaj gönderebilmeniz için kişinizin de sesli mesaj göndermesine izin vermeniz gerekir. @@ -1204,7 +1211,7 @@ SimpleX Kilit aktif değil! SimpleX Kilit Doğrudan bağlanılsın mı? - Bu ayarlar mevcut profiliniz içindir + Bu ayarlar mevcut profiliniz içindir Sunucu testi başarısız! Bağlantıyı onayla Yeni sohbet başlat @@ -1720,7 +1727,7 @@ Litvanya Kullanıcı Arayüzü Diğer kaydedildi - %s tarafından kaydedildi + kaydedildi: İletildi Kaydedildi İndir 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 9a59fec674..457fd4abe1 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/uk/strings.xml @@ -1155,6 +1155,12 @@ Забагато зображень! Забагато відео! ви спостерігач + Ви спостерігач + Ви учасник + Ви модератор + Ви адміністратор + Ви власник + Ви автор кольоровий дзвінок завершено %1$s помилка дзвінка @@ -1271,7 +1277,7 @@ Скасувати Виберіть файл Контакти - Ці налаштування стосуються вашого поточного профілю + Ці налаштування стосуються вашого поточного профілю Вимкнути повідомлення про доставку? Увімкнути повідомлення про доставку? можлива перезапис шифрування @@ -1807,7 +1813,7 @@ Квадрат, коло або щось середнє між ними. Буде ввімкнено в прямих чатах! збережено - збережено з %s + збережено з Дротова мережа Ethernet Невідомі сервери! Без Tor або VPN ваша IP-адреса буде видимою для цих XFTP-ретрансляторів: 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 c285722200..aac2109d11 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/vi/strings.xml @@ -1594,7 +1594,7 @@ Các tùy chọn của cuộc trò chuyện được chọn không cho phép tin nhắn này. Quét mã QR đã lưu - đã lưu từ %s + đã lưu từ Đã lưu từ Quét mã QR từ máy tính Đã được bảo mật @@ -1972,7 +1972,7 @@ Văn bản bạn vừa dán không phải là một đường dẫn SimpleX. Chức vụ sẽ được đổi thành %s. Thành viên sẽ nhận được một lời mời mới. Mật khẩu sẽ được lưu trữ trong cài đặt dưới dạng thuần văn bản sau khi bản đổi nó hoặc khởi động lại ứng dụng. - Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn + Các cài đặt này là cho hồ sơ trò chuyện hiện tại của bạn Chức vụ sẽ được đổi thành %s. Tất cả mọi người trong nhóm sẽ được thông báo. Bản lưu trữ cơ sở dữ liệu đã được tải lên sẽ bị xóa vĩnh viễn khỏi các máy chủ. Việc này không thể được hoàn tác - hồ sơ, các liên hệ, tin nhắn và tệp của bạn sẽ biến mất mà không thể khôi phục. @@ -2213,6 +2213,12 @@ Bạn có thể tùy chỉnh các máy chủ thông qua cài đặt. Bạn có thể đặt tên kết nối, để nhớ xem đường dẫn đã được chia sẻ với ai. bạn là quan sát viên + Bạn là quan sát viên + Bạn là thành viên + Bạn là kiểm duyệt viên + Bạn là quản trị viên + Bạn là chủ sở hữu + Bạn là người theo dõi Bạn có thể sao chép và giảm kích thước tin nhắn để gửi nó đi. Bạn có thể bật chúng vào lúc sau thông qua cài đặt Quyền riêng tư & Bảo mật của ứng dụng. Bạn có thể ẩn hoặc tắt thông báo một hồ sơ người dùng - giữ nó trong phần menu. 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 9dea7a5a0a..2d51120ac2 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 @@ -927,6 +927,13 @@ 删除成员消息? 观察员 你是观察者 + 你是观察员 + 你是成员 + 你是协管 + 你是管理员 + 你是群主 + 你是订阅者 + 你是贡献者 更新群链接错误 你是观察员 初始角色 @@ -1344,7 +1351,7 @@ 连接请求将发送给该群成员。 密码以明文形式存储在设置中。 同步连接时出错 - 这些设置适用于你当前的个人资料 + 这些设置适用于你当前的个人资料 允许为 %s 重新协商加密 为所有人启用 需要重新协商加密 @@ -1718,7 +1725,7 @@ 已保存 已保存 保存自 - 保存自%s + 保存自 已转发 转发自 蓝牙 diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/zh-rTW/strings.xml index c6cf22f427..05e3cd8030 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 @@ -926,6 +926,13 @@ 該訊息將對所有成員標記為已移除。 你是觀察員 你是觀察員 + 你是觀察員 + 你是成員 + 你是審核員 + 你是管理員 + 你是擁有者 + 你是訂閱者 + 你是貢獻者 觀察員 更新群組連接時出錯 請聯絡群組管理員。 @@ -1935,7 +1942,7 @@ 已封存的報告 只有你和審核員能夠檢視 只有傳送者和審核員能夠檢視 - 已儲存自 %s + 已儲存自 此聊天受到端對端加密保護。 另一個原因 不當的個人檔案 @@ -2444,7 +2451,7 @@ 未使用 Tor 或 VPN 時,你的 IP 地址會對檔案伺服器可見。 移除連結追蹤 此設定適用於你目前的個人檔案 - 這些設定適用於你目前的個人檔案 + 這些設定適用於你目前的個人檔案 可在聯絡人和群組設定中覆寫這些設定。 已為 %d 個聯絡人啟用送達回條 已為 %d 個聯絡人停用送達回條 diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_1.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/crowdfunding_4.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg new file mode 100644 index 0000000000..cd6f033c62 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/assets/default/MR/images/own_stake_light.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt index c2f37fd5d9..f5bba2d344 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/other/videoplayer/SkiaBitmapVideoSurface.kt @@ -23,6 +23,7 @@ import javax.swing.SwingUtilities internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) { private val videoSurface = SkiaBitmapVideoSurface() + @Volatile private var mediaPlayer: MediaPlayer? = null private lateinit var imageInfo: ImageInfo private lateinit var frameBytes: ByteArray private val skiaBitmap: Bitmap = Bitmap() @@ -31,6 +32,7 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid val bitmap: State = composeBitmap override fun attach(mediaPlayer: MediaPlayer) { + this.mediaPlayer = mediaPlayer videoSurface.attach(mediaPlayer) } @@ -39,9 +41,17 @@ internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVid private var sourceHeight: Int = 0 override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat { - this.sourceWidth = sourceWidth - this.sourceHeight = sourceHeight - return RV32BufferFormat(sourceWidth, sourceHeight) + // libvlc passes the size the decoder padded the picture to, not the size of the picture (dav1d + // pads to a multiple of 128, so 1920x1080 arrives as 1920x1152), and vlc stretches the picture to + // fill whatever size is returned. Ask for the size of the track being played instead. The format + // is negotiated more than once, and vlc has not selected the track yet on the first calls + val player = mediaPlayer + val tracks = player?.media()?.info()?.videoTracks() + val playingTrack = player?.video()?.track() + val track = tracks?.firstOrNull { it.id() == playingTrack } ?: tracks?.singleOrNull() + this.sourceWidth = track?.width()?.takeIf { it > 0 } ?: sourceWidth + this.sourceHeight = track?.height()?.takeIf { it > 0 } ?: sourceHeight + return RV32BufferFormat(this.sourceWidth, this.sourceHeight) } override fun allocatedBuffers(buffers: Array) { diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt new file mode 100644 index 0000000000..e12bae4280 --- /dev/null +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/AnimatedImage.desktop.kt @@ -0,0 +1,199 @@ +package chat.simplex.common.platform + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asComposeImageBitmap +import chat.simplex.common.simplexWindowState +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.first +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Codec +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.Data + +// Animated images are decoded from data received from other users, which is what the bounds below are for + +// In bytes as the file chooses the color type, 1920x1920 at 4 bytes a pixel is ~15MB +private const val MAX_ANIMATED_RASTER_BYTES: Long = 1920L * 1920 * 4 +// 65535x32 is only 2.1MP, so each side is bounded as well +private const val MAX_ANIMATED_SIDE = 4096 +// Skia copies the encoded bytes into native memory and scans them to count frames +private const val MAX_ANIMATED_FILE_SIZE = 32 * 1024 * 1024 +// Counting frames builds a table the codec holds while it plays, several times the file's size for minimal ones +private const val MAX_ANIMATED_FRAMES = 10_000 +// 10ms or less is how "as fast as possible" is written, and browsers substitute 100ms for it +private const val MAX_UNSPECIFIED_FRAME_DURATION_MS = 10 +private const val DEFAULT_FRAME_DURATION_MS = 100L +private const val MIN_FRAME_DURATION_MS = 20L +// A frame costing more than this holds most of a core to show under 10 frames a second +private const val MAX_FRAME_DECODE_MS = 100L +// Far above what a frame within the bounds above can cost, so only a stall reaches it +private const val MAX_WAITED_FRAME_COST_MS = 10 * MAX_FRAME_DECODE_MS +private const val SLOW_FRAME_COST = 2 +internal const val MAX_SLOW_FRAME_DEBT = 4 +private const val NO_PRIOR_FRAME = -1 +// A frame given no prior frame is rebuilt by recursing down its chain, so a long enough one overflows the +// native stack, which no catch can stop. Real animations rebuild nothing. +private const val MAX_REBUILT_FRAMES = 64 + +// Read once, as asking the codec about a frame allocates and the loop may repeat forever +private class Animation(val codec: Codec, val priorFrames: IntArray, val frameDelays: LongArray) + +/** + * The current frame of [data], or [still] when it is not an animation, falls outside the bounds above, or + * fails before showing a frame; after that it stops on the frame it reached. Decoding runs off the UI thread. + */ +@Composable +fun rememberAnimatedImage(data: ByteArray, still: ImageBitmap, hidden: () -> Boolean = { false }): ImageBitmap { + // Keyed as the decoding is, so frames are not written into a replaced state, and hidden is not a key so it + // pauses instead of restarting. Every frame is a new wrapper, and only its identity says the image changed. + val frame = remember(data, still) { mutableStateOf(still, neverEqualPolicy()) } + LaunchedEffect(data, still) { + withContext(animationDecoder) { + val animation = openAnimation(data) ?: return@withContext + try { + playFrames(animation, hidden) { frame.value = it } + } finally { + animation.codec.close() + } + } + } + return frame.value +} + +// Decoding several large animations must not starve the long running calls that share this pool +@OptIn(ExperimentalCoroutinesApi::class) +private val animationDecoder = Dispatchers.Default.limitedParallelism(2) + +private fun openAnimation(data: ByteArray): Animation? { + if (!looksAnimatable(data) || !fileSizeWithinBounds(data.size)) return null + var codec: Codec? = null + var animation: Animation? = null + try { + // Skia retains the encoded bytes, so this native buffer is freed as soon as the codec has taken it + val encoded = Data.makeFromBytes(data) + codec = try { + Codec.makeFromData(encoded) + } finally { + encoded.close() + } + animation = boundedAnimation(codec) + } catch (e: Throwable) { + Log.e(TAG, "Unable to read animated image: $e") + } + // The codec is only left open for an animation that took it, so no bound can return past closing it + if (animation == null) codec?.close() + return animation +} + +private fun boundedAnimation(codec: Codec): Animation? { + val info = codec.imageInfo + if (!rasterWithinBounds(info.width, info.height, info.bytesPerPixel)) return null + // Counting frames scans the file, while dimensions are only read from the header + val frameCount = codec.frameCount + if (!frameCountWithinBounds(frameCount)) return null + val requiredFrames = IntArray(frameCount) + val frameDelays = LongArray(frameCount) + for (i in 0 until frameCount) { + val frameInfo = codec.getFrameInfo(i) + requiredFrames[i] = frameInfo.requiredFrame + frameDelays[i] = frameDuration(frameInfo.duration) + } + if (!rebuiltFramesWithinBounds(requiredFrames)) return null + return Animation(codec, IntArray(frameCount) { priorFrame(it, requiredFrames[it]) }, frameDelays) +} + +internal fun looksAnimatable(data: ByteArray): Boolean = + data.startsWith("GIF8") || (data.startsWith("RIFF") && data.startsWith("WEBP", offset = 8)) + +private fun ByteArray.startsWith(ascii: String, offset: Int = 0): Boolean { + if (size < offset + ascii.length) return false + return ascii.indices.all { this[offset + it] == ascii[it].code.toByte() } +} + +internal fun rasterWithinBounds(width: Int, height: Int, bytesPerPixel: Int): Boolean { + if (width !in 1..MAX_ANIMATED_SIDE || height !in 1..MAX_ANIMATED_SIDE) return false + // 0 bytes per pixel would let any raster pass the bound below + if (bytesPerPixel < 1) return false + // The sides are bounded before they are multiplied, so the product cannot overflow + return width.toLong() * height * bytesPerPixel <= MAX_ANIMATED_RASTER_BYTES +} + +private suspend fun playFrames(animation: Animation, hidden: () -> Boolean, showFrame: (ImageBitmap) -> Unit) { + try { + val codec = animation.codec + val bitmap = Bitmap() + // The codec reports only the first frame's alpha type, and a frame with alpha cannot be read into an + // opaque bitmap. allocPixels returns false rather than throwing. + if (!bitmap.allocPixels(codec.imageInfo.withColorAlphaType(ColorAlphaType.PREMUL))) return + var loopsLeft = codec.repetitionCount // negative repeats forever + var debt = 0 + while (true) { + for (i in animation.priorFrames.indices) { + awaitFramesAreSeen(hidden) + val startedDecoding = System.nanoTime() + codec.readPixels(bitmap, i, animation.priorFrames[i]) + // Wall time, so a frame can overrun by being descheduled rather than by being expensive + val decodedIn = System.nanoTime() - startedDecoding + debt = slowFrameDebt(debt, decodedIn > MAX_FRAME_DECODE_MS * 1_000_000) + // The bitmap is never closed, as the wrapper points at its pixels and a frame may still be drawn + showFrame(bitmap.asComposeImageBitmap()) + if (debt >= MAX_SLOW_FRAME_DEBT) { + Log.d(TAG, "Animation too expensive to decode, stopping on this frame") + return + } + delay(frameWait(animation.frameDelays[i], decodedIn / 1_000_000)) + } + if (loopsLeft == 0) return + if (loopsLeft > 0) loopsLeft-- + } + } catch (e: CancellationException) { + throw e // the view is gone, not a decoding failure + } catch (e: Throwable) { + Log.e(TAG, "Unable to play animated image: $e") + } +} + +// Composition survives the window being minimized or hidden, and the caller knows when its image cannot be seen +private suspend fun awaitFramesAreSeen(hidden: () -> Boolean) { + if (framesAreSeen(hidden)) return + snapshotFlow { framesAreSeen(hidden) }.first { it } +} + +private fun framesAreSeen(hidden: () -> Boolean): Boolean = + simplexWindowState.windowVisible.value && !simplexWindowState.windowState.isMinimized && !hidden() + +// Waiting out the cost as well as the delay leaves an animation about half a decoder thread. The cost is +// wall time, so a stall is only waited out so far. +internal fun frameWait(delayMs: Long, costMs: Long): Long = + maxOf(delayMs, costMs.coerceAtMost(MAX_WAITED_FRAME_COST_MS)) + +internal fun fileSizeWithinBounds(size: Int): Boolean = size <= MAX_ANIMATED_FILE_SIZE + +// A file of no frames would spin the playback loop uncancellably, as it only suspends inside the range +internal fun frameCountWithinBounds(frameCount: Int): Boolean = frameCount in 2..MAX_ANIMATED_FRAMES + +// The frame the codec may decode this one from, which is the one before it when the bitmap still holds it. +// Rebuilding the chain instead costs 9.10ms a frame against 0.05ms, and Skia refuses a frame it did not ask for. +internal fun priorFrame(index: Int, requiredFrame: Int): Int = + if (requiredFrame == index - 1) index - 1 else NO_PRIOR_FRAME + +// requiredFrames is what each frame continues; one that continues nothing starts a chain of its own +internal fun rebuiltFramesWithinBounds(requiredFrames: IntArray): Boolean { + val chain = IntArray(requiredFrames.size) + requiredFrames.forEachIndexed { index, required -> + val continues = required in 0 until index + chain[index] = if (continues) chain[required] + 1 else 1 + if (continues && priorFrame(index, required) == NO_PRIOR_FRAME && chain[required] > MAX_REBUILT_FRAMES) return false + } + return true +} + +// Two expensive frames in a row reach the debt, and so do frames that alternate with cheap ones, which a +// count that reset would miss +internal fun slowFrameDebt(debt: Int, tooSlow: Boolean): Int = + (debt + if (tooSlow) SLOW_FRAME_COST else -1).coerceAtLeast(0) + +internal fun frameDuration(declaredMs: Int): Long = + if (declaredMs <= MAX_UNSPECIFIED_FRAME_DURATION_MS) DEFAULT_FRAME_DURATION_MS + else declaredMs.toLong().coerceAtLeast(MIN_FRAME_DURATION_MS) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt index c3b6dc3a4c..768d2f421d 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/VideoPlayer.desktop.kt @@ -7,6 +7,10 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.MR import kotlinx.coroutines.* import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface +import uk.co.caprica.vlcj.media.Media +import uk.co.caprica.vlcj.media.MediaEventAdapter +import uk.co.caprica.vlcj.media.MediaParsedStatus +import uk.co.caprica.vlcj.media.ParseFlag import uk.co.caprica.vlcj.media.VideoOrientation import uk.co.caprica.vlcj.player.base.* import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent @@ -255,6 +259,43 @@ actual class VideoPlayer actual constructor( return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) } + // Parsing a local container header takes a few dozen ms, this is only a guard against a stuck parse + private const val PARSE_TIMEOUT_MS = 3000L + + // Reads container metadata to tell whether there is a video track at all, without decoding a frame. + // libvlc signals the end of parsing with an event, so no polling or frame-decoding budget is needed. + suspend fun hasVideoTrack(uri: URI): Boolean = withContext(previewThread.asCoroutineDispatcher()) { + if (!uri.toFile().exists()) return@withContext false + val media = try { + vlcPreviewFactory.media().newMedia(uri.toFile().absolutePath) + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack unable to create media: ${e.stackTraceToString()}") + null + } ?: return@withContext false + try { + val parsed = CompletableDeferred() + media.events().addMediaEventListener(object: MediaEventAdapter() { + // vlcj maps an unknown status int to null, and a null here would throw on its event thread + override fun mediaParsedChanged(parsedMedia: Media?, newStatus: MediaParsedStatus?) { + parsed.complete(newStatus) + } + }) + if (!media.parsing().parse(PARSE_TIMEOUT_MS.toInt(), ParseFlag.PARSE_LOCAL)) { + return@withContext false + } + if (withTimeoutOrNull(PARSE_TIMEOUT_MS) { parsed.await() } != MediaParsedStatus.DONE) { + media.parsing().stop() + return@withContext false + } + media.info().videoTracks().isNotEmpty() + } catch (e: Exception) { + Log.e(TAG, "hasVideoTrack error: ${e.stackTraceToString()}") + false + } finally { + media.release() + } + } + val playerThread = Executors.newSingleThreadExecutor() private val previewThread = Executors.newSingleThreadExecutor() private val playersPool: ArrayList = ArrayList() diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt index e9924914ef..3293d4f5bd 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt @@ -9,5 +9,6 @@ fun isVideo(uri: URI): Boolean { path.endsWith(".mp4") || path.endsWith(".mpg") || path.endsWith(".mpeg") || - path.endsWith(".mkv") + path.endsWith(".mkv") || + path.endsWith(".webm") } diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt index b4a24e3572..98ffa7c8a4 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/CIImageView.desktop.kt @@ -1,6 +1,7 @@ package chat.simplex.common.views.chat.item import androidx.compose.runtime.Composable +import androidx.compose.runtime.State import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter @@ -15,10 +16,15 @@ actual fun SimpleAndAnimatedImageView( file: CIFile?, imageProvider: () -> ImageGalleryProvider, smallView: Boolean, + blurred: State, ImageView: @Composable (painter: Painter, onClick: () -> Unit) -> Unit ) { - // LALAL make it animated too - ImageView(BitmapPainter(imageBitmap)) { + // The small view is the chat list preview, which the layout keeps on screen without pause, so it stays a + // still image. A full screen modal is shown beside the chat rather than in place of it, so this item keeps + // composing under one and would otherwise decode where nobody can see it. + val frame = if (smallView) imageBitmap + else rememberAnimatedImage(data, imageBitmap) { blurred.value || ModalManager.fullscreen.hasModalsOpen() } + ImageView(BitmapPainter(frame)) { if (getLoadedFilePath(file) != null) { ModalManager.fullscreen.showCustomModal(animated = false) { close -> ImageFullScreenView(imageProvider, close) diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt index bd395c2c97..583aa8f52f 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.desktop.kt @@ -19,8 +19,10 @@ import kotlin.math.max @Composable actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { + // Decoded once, as an animation recomposes this on every frame + val still = remember(data) { getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap() } Image( - getBitmapFromByteArray(data, false) ?: MR.images.decentralized.image.toComposeImageBitmap(), + rememberAnimatedImage(data, still), contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Fit, modifier = modifier, diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt index 3ccb915661..d4c42790d2 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/helpers/Utils.desktop.kt @@ -255,6 +255,8 @@ actual suspend fun getBitmapFromVideo(uri: URI, timestamp: Long?, random: Boolea return VideoPlayer.getBitmapFromVideo(null, uri, withAlertOnException) } +actual suspend fun hasVideoTrack(uri: URI): Boolean = VideoPlayer.hasVideoTrack(uri) + @OptIn(ExperimentalEncodingApi::class) actual fun ByteArray.toBase64StringForPassphrase(): String = Base64.encode(this) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt new file mode 100644 index 0000000000..b79dd69779 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/AnimatedImageBoundsTest.kt @@ -0,0 +1,252 @@ +package chat.simplex.app + +import chat.simplex.common.platform.MAX_SLOW_FRAME_DEBT +import chat.simplex.common.platform.frameWait +import chat.simplex.common.platform.fileSizeWithinBounds +import chat.simplex.common.platform.frameCountWithinBounds +import chat.simplex.common.platform.frameDuration +import chat.simplex.common.platform.looksAnimatable +import chat.simplex.common.platform.priorFrame +import chat.simplex.common.platform.rasterWithinBounds +import chat.simplex.common.platform.rebuiltFramesWithinBounds +import chat.simplex.common.platform.slowFrameDebt +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +// The bounds an animated image must satisfy, checked as arithmetic: skiko's native library is not on the +// test runtime classpath, and these numbers are the part that has to be right about someone else's file. +class AnimatedImageBoundsTest { + private val BYTES_PER_PIXEL = 4 // what a GIF or WebP decodes to + + @Test + fun testOrdinaryAnimationIsWithinBounds() { + assertTrue(rasterWithinBounds(64, 64, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(1244, 554, BYTES_PER_PIXEL)) + } + + @Test + fun testHugeDeclaredDimensionsAreRejected() { + // A 17GB raster, declared by a GIF of 35 bytes + assertFalse(rasterWithinBounds(65535, 65535, BYTES_PER_PIXEL)) + } + + @Test + fun testDimensionsOverRasterBudgetAreRejected() { + // Plausible-looking, but one raster of this size is ~64MB and a chat shows several at once + assertFalse(rasterWithinBounds(4000, 4000, BYTES_PER_PIXEL)) + } + + @Test + fun testAspectRatioIsBoundedOnEachSideSeparately() { + // Only 2.1MP, so the raster bound alone would animate this with a 65535-pixel scanline + assertFalse(rasterWithinBounds(65535, 32, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(32, 65535, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(3000, 500, BYTES_PER_PIXEL)) + assertTrue(rasterWithinBounds(4096, 900, BYTES_PER_PIXEL)) + } + + @Test + fun testBudgetBoundariesAreExact() { + assertTrue(rasterWithinBounds(1920, 1920, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(1921, 1920, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(4097, 100, BYTES_PER_PIXEL)) + } + + @Test + fun testEmptyDimensionsAreRejected() { + assertFalse(rasterWithinBounds(0, 64, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(64, 0, BYTES_PER_PIXEL)) + assertFalse(rasterWithinBounds(-1, 64, BYTES_PER_PIXEL)) + } + + @Test + fun testWiderColorTypesCountAgainstTheSameBudget() { + // The file chooses its color type, so the 1920x1920 that fits at four bytes is twice the raster at eight + assertFalse(rasterWithinBounds(1920, 1920, 8)) + assertTrue(rasterWithinBounds(1357, 1357, 8)) + // A color type claiming no bytes per pixel would otherwise make any raster look free + assertFalse(rasterWithinBounds(4096, 4096, 0)) + } + + @Test + fun testAnimatableContainersAreRecognized() { + assertTrue(looksAnimatable("GIF89a...".toByteArray())) + assertTrue(looksAnimatable("GIF87a...".toByteArray())) + assertTrue(looksAnimatable("RIFF????WEBPVP8X".toByteArray())) + } + + @Test + fun testPhotosNeverReachTheAnimationDecoder() { + assertFalse(looksAnimatable(bytes(0x89, 'P'.code, 'N'.code, 'G'.code, 0x0D, 0x0A, 0x1A, 0x0A))) + assertFalse(looksAnimatable(bytes(0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46))) + // A RIFF container that is not WebP, a wave file say + assertFalse(looksAnimatable("RIFF????WAVEfmt ".toByteArray())) + } + + @Test + fun testShortDataIsRejectedWithoutReadingPastTheEnd() { + assertFalse(looksAnimatable(ByteArray(0))) + assertFalse(looksAnimatable("GIF".toByteArray())) + // Long enough for the RIFF tag, too short for the format that follows it + assertFalse(looksAnimatable("RIFF".toByteArray())) + assertFalse(looksAnimatable("RIFF1234WEB".toByteArray())) + } + + @Test + fun testPriorFrameIsReusedOnlyWhenTheBitmapHoldsIt() { + assertEquals(4, priorFrame(5, 4)) + // An older required frame is no longer in the bitmap, which is also how a predecessor disposed to what + // came before it is skipped, as Skia never requires one + assertEquals(-1, priorFrame(5, 2)) + assertEquals(-1, priorFrame(5, -1)) + // For the first frame, -1 is both its required frame and no prior frame + assertEquals(-1, priorFrame(0, -1)) + } + + @Test + fun testOnlyFilesSmallEnoughToScanAreWithinBounds() { + assertTrue(fileSizeWithinBounds(0)) + // The largest animation in this repository + assertTrue(fileSizeWithinBounds(6_013_354)) + assertTrue(fileSizeWithinBounds(32 * 1024 * 1024)) + assertFalse(fileSizeWithinBounds(32 * 1024 * 1024 + 1)) + } + + @Test + fun testOnlyAnimationsWorthHoldingFramesForAreWithinBounds() { + assertFalse(frameCountWithinBounds(0)) + assertFalse(frameCountWithinBounds(1)) + assertFalse(frameCountWithinBounds(-1)) + assertTrue(frameCountWithinBounds(2)) + // The longest animation in this repository, and the bound itself + assertTrue(frameCountWithinBounds(1041)) + assertTrue(frameCountWithinBounds(10_000)) + assertFalse(frameCountWithinBounds(10_001)) + } + + @Test + fun testAnimationsThatRebuildNothingAreWithinBounds() { + // What a real animation looks like: each frame continues the one before it, so nothing is rebuilt + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it - 1 })) + assertTrue(rebuiltFramesWithinBounds(intArrayOf(-1, -1, -1))) + } + + @Test + fun testShortRebuiltChainsAreWithinBounds() { + // A GIF disposing to what came before it: frame 2 continues frame 0, rebuilding two frames + assertTrue(rebuiltFramesWithinBounds(intArrayOf(-1, 0, 0, 2, 2, 4))) + } + + @Test + fun testLongRebuiltChainsAreRejected() { + // Alternating disposal makes every other frame rebuild the chain before it, which Skia recurses through: + // 8000 frames of that overflows the native stack and kills the app + val alternating = IntArray(8000) { if (it % 2 == 0) it - 2 else it - 1 } + assertFalse(rebuiltFramesWithinBounds(alternating)) + // The bound is on what a rebuild costs, not on how long the animation is + assertTrue(rebuiltFramesWithinBounds(IntArray(8000) { it - 1 })) + } + + @Test + fun testRebuiltChainBoundIsExact() { + fun chainOf(length: Int) = IntArray(length + 2) { if (it == length + 1) it - 2 else it - 1 } + assertTrue(rebuiltFramesWithinBounds(chainOf(64))) + assertFalse(rebuiltFramesWithinBounds(chainOf(65))) + } + + @Test + fun testFramesContinuingSomethingImpossibleStartTheirOwnChain() { + // A file is not trusted to say a frame continues itself, a later frame, or one that is not there + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it })) + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { it + 1 })) + assertTrue(rebuiltFramesWithinBounds(IntArray(5000) { 9999 })) + } + + @Test + fun testAFrameCheaperThanItsDelayWaitsAsItAlwaysDid() { + assertEquals(70, frameWait(70, 0)) + assertEquals(70, frameWait(70, 2)) + assertEquals(70, frameWait(70, 70)) + } + + @Test + fun testAFrameDearerThanItsDelayIsWaitedOut() { + assertEquals(85, frameWait(20, 85)) + assertEquals(500, frameWait(20, 500)) + assertEquals(1000, frameWait(20, 1000)) + } + + @Test + fun testAStallIsNotWaitedOut() { + // Wall time counts a machine that suspended mid-decode, which the frame never spent + assertEquals(1000, frameWait(20, 30_000)) + assertEquals(1000, frameWait(20, 8L * 60 * 60 * 1000)) + assertEquals(5000, frameWait(5000, 30_000)) + } + + @Test + fun testTwoExpensiveFramesInARowStopTheAnimation() { + var debt = slowFrameDebt(0, tooSlow = true) + assertTrue(debt < MAX_SLOW_FRAME_DEBT) + debt = slowFrameDebt(debt, tooSlow = true) + assertTrue(debt >= MAX_SLOW_FRAME_DEBT) + } + + @Test + fun testAFrameThatOnlyOverranIsPaidOff() { + // One expensive frame among cheap ones is a busy machine, not an expensive animation + var debt = slowFrameDebt(0, tooSlow = true) + repeat(4) { debt = slowFrameDebt(debt, tooSlow = false) } + assertEquals(0, debt) + } + + @Test + fun testAlternatingExpensiveFramesStillStopTheAnimation() { + // Frames that alternate are never expensive twice in a row, which is what a count that resets would miss + var debt = 0 + var frames = 0 + while (debt < MAX_SLOW_FRAME_DEBT && frames < 100) { + debt = slowFrameDebt(debt, tooSlow = frames % 2 == 0) + frames++ + } + assertEquals(5, frames) + } + + @Test + fun testCheapFramesEarnNoCreditAgainstLaterExpensiveOnes() { + var debt = 0 + repeat(1000) { debt = slowFrameDebt(debt, tooSlow = false) } + assertEquals(0, debt) + debt = slowFrameDebt(debt, tooSlow = true) + debt = slowFrameDebt(debt, tooSlow = true) + assertTrue(debt >= MAX_SLOW_FRAME_DEBT) + } + + @Test + fun testFrameDurationSubstitutesTheDefaultForFramesInAHurry() { + // Skia reports a GIF delay in milliseconds, so "no delay" and "one centisecond" arrive as 0 and 10 + assertEquals(100, frameDuration(0)) + assertEquals(100, frameDuration(10)) + // Not expected from Skia, but read from the file + assertEquals(100, frameDuration(-1)) + } + + @Test + fun testFrameDurationKeepsAuthoredDelays() { + assertEquals(70, frameDuration(70)) + assertEquals(600, frameDuration(600)) + assertEquals(Int.MAX_VALUE.toLong(), frameDuration(Int.MAX_VALUE)) + } + + @Test + fun testFrameDurationRaisesDelaysBelowTheFloor() { + assertEquals(20, frameDuration(11)) + assertEquals(20, frameDuration(19)) + assertEquals(20, frameDuration(20)) + assertEquals(21, frameDuration(21)) + } + + private fun bytes(vararg values: Int): ByteArray = values.map { it.toByte() }.toByteArray() +} diff --git a/apps/multiplatform/external/nanohttpd/build.gradle.kts b/apps/multiplatform/external/nanohttpd/build.gradle.kts new file mode 100644 index 0000000000..fb24922208 --- /dev/null +++ b/apps/multiplatform/external/nanohttpd/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + `java-library` +} + +// Built from the upstream submodule pinned at efb2ebf85a2b06f7c508aba9eaad5377e3a01e81, because +// upstream never released the org.nanohttpd packages and JitPack no longer serves or builds that +// commit. Only the core and websocket modules are used, the samples are not. +group = "org.nanohttpd" +version = "efb2ebf" + +val upstream = layout.projectDirectory.dir("upstream") + +sourceSets { + main { + java { + setSrcDirs(listOf(upstream.dir("core/src/main/java"), upstream.dir("websocket/src/main/java"))) + exclude("org/nanohttpd/samples/**") + } + resources.setSrcDirs(listOf(upstream.dir("core/src/main/resources"))) + } +} + +java { + val jvmVersion = JavaVersion.toVersion(providers.gradleProperty("kotlin.jvm.target").get()) + sourceCompatibility = jvmVersion + targetCompatibility = jvmVersion +} + +// Without this the jar records the build machine's timestamps, file order and file modes, +// which makes the desktop packages unreproducible +tasks.jar { + // Checked here and not during configuration, so that Android builds, which don't use nanohttpd, + // work without the submodule + doFirst { + if (!upstream.file("core/src/main/java").asFile.isDirectory) { + throw GradleException("nanohttpd sources are missing, run: git submodule update --init --recursive") + } + } + isPreserveFileTimestamps = false + isReproducibleFileOrder = true + filePermissions { unix("644") } + dirPermissions { unix("755") } +} diff --git a/apps/multiplatform/external/nanohttpd/upstream b/apps/multiplatform/external/nanohttpd/upstream new file mode 160000 index 0000000000..efb2ebf85a --- /dev/null +++ b/apps/multiplatform/external/nanohttpd/upstream @@ -0,0 +1 @@ +Subproject commit efb2ebf85a2b06f7c508aba9eaad5377e3a01e81 diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index b4a7b4319a..a904d2859b 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,13 +24,11 @@ android.nonTransitiveRClass=true kotlin.mpp.androidSourceSetLayoutVersion=2 kotlin.jvm.target=11 -android.version_name=7.0 -android.version_code=366 +android.version_name=7.1-beta.1 +android.version_code=374 -android.bundle=false - -desktop.version_name=7.0 -desktop.version_code=155 +desktop.version_name=7.1-beta.1 +desktop.version_code=158 kotlin.version=2.1.20 gradle.plugin.version=8.7.0 diff --git a/apps/multiplatform/product/gaps.md b/apps/multiplatform/product/gaps.md index 25535d8003..aae24bdca8 100644 --- a/apps/multiplatform/product/gaps.md +++ b/apps/multiplatform/product/gaps.md @@ -222,9 +222,10 @@ Desktop users cannot send voice messages. The record button either does nothing Several other Desktop features are also marked with `LALAL` placeholders: - **QR Code Scanner** (`QRCodeScanner.desktop.kt:12`) -- scanning QR codes is not implemented on Desktop -- **Animated Drawables** (`Utils.desktop.kt:179`) -- animated image support (e.g., GIF in-line rendering) is not implemented -- **Animated Chat Images** (`CIImageView.desktop.kt:19`) -- animated image rendering in chat items -- **isImage detection** (`Images.desktop.kt:168`) -- image type detection (implemented but marked as incomplete) +- **Animated Drawables** (`Utils.desktop.kt:236`) -- `getDrawableFromUri` returns null, so `isAnimImage` falls back to the file extension +- **isImage detection** (`Images.desktop.kt:189`) -- image type detection (implemented but marked as incomplete) + +Desktop cannot decode WebP in chat: `decodeBoundedBufferedImage` (`Utils.desktop.kt:191`) reads through ImageIO, which has no WebP reader, so a received `.webp` renders only as the sender's preview and never opens full screen, and a picked one is skipped. Wallpapers and link previews decode WebP, as they read through Skia instead (`Images.desktop.kt:204`). Received GIFs do animate in chat items and full screen; the animated image decoder accepts WebP but is never reached for it. --- diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index 64abda7ee6..7862574f04 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -55,7 +55,7 @@ Each type has a dedicated composable in `views/chat/item/`: | Type | Composable | Description | |---|---|---| | Text | `FramedItemView` | Rendered with markdown (bold, italic, code, links, `@mentions`) via `CIMarkdownText` | -| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView` | +| Image | `CIImageView` | Thumbnail with tap-to-fullscreen via `ImageFullScreenView`; animated GIFs play inline and full screen | | Video | `CIVideoView` | Video thumbnail with play button; inline playback via `VideoPlayerHolder` | | Voice | `CIVoiceView` | Waveform visualization with playback controls and duration | | File | `CIFileView` | File icon, name, size; download/open actions with progress indicator | diff --git a/apps/multiplatform/settings.gradle.kts b/apps/multiplatform/settings.gradle.kts index 50a50d531d..461ae54fc3 100644 --- a/apps/multiplatform/settings.gradle.kts +++ b/apps/multiplatform/settings.gradle.kts @@ -18,4 +18,4 @@ pluginManagement { rootProject.name = "app" -include(":android", ":desktop", ":common") +include(":android", ":desktop", ":common", ":external:nanohttpd") diff --git a/apps/multiplatform/spec/architecture.md b/apps/multiplatform/spec/architecture.md index cfef4d06c2..9911a2670f 100644 --- a/apps/multiplatform/spec/architecture.md +++ b/apps/multiplatform/spec/architecture.md @@ -370,6 +370,8 @@ var platform: PlatformInterface = object : PlatformInterface {} | `androidCreateActiveCallState()` | empty `Closeable` | Create `ActiveCallState` | | `androidIsXiaomiDevice()` | `false` | Check device brand | | `androidApiLevel` | `null` | `Build.VERSION.SDK_INT` | +| `androidIsPlayStoreBuild` | `false` | `BuildConfig.PLAY_STORE` | +| `androidLoadPlayStoreCountry()` | no-op | Request the Play account country (google flavor only) | | `androidLockPortraitOrientation()` | no-op | Lock to `SCREEN_ORIENTATION_PORTRAIT` | | `androidAskToAllowBackgroundCalls()` | `true` | Show battery restriction dialog | | `desktopShowAppUpdateNotice()` | no-op | Show update notice (Desktop only) | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index 728ace4936..a6691c2878 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -202,6 +202,18 @@ Long-press or right-click opens a dropdown menu with context-sensitive actions ( | `InvalidJSON` | -- | `CIInvalidJSONView` | `CIInvalidJSONView.kt` | | `CIMemberCreatedContact` | -- | `CIMemberCreatedContactView` | `CIMemberCreatedContactView.kt` | +### Animated Images + +`SimpleAndAnimatedImageView` is `expect`/`actual`. Android delegates to coil, which drives the animation +itself. Desktop decodes frames with Skia's `Codec` in `platform/AnimatedImage.desktop.kt`, where +`rememberAnimatedImage(data, still, hidden)` returns the frame to draw and falls back to the still image when +the data is not an animation, exceeds the decode bounds, or fails before showing a frame. Decoding runs off the UI thread +on two threads of the shared pool, and pauses while the window is minimized or hidden, while the image is behind the +privacy blur, and while a full screen modal covers the chat. An animation whose frames cost too much to +decode stops on the frame it reached rather than falling back to the still. The chat list preview (`smallView`) stays a +still image. Only GIF reaches this path: desktop decodes stills with ImageIO, which has no WebP reader, so a +received `.webp` renders only as the sender's preview and never opens full screen. + --- ## 6. Context Menu Actions diff --git a/apps/multiplatform/spec/database.md b/apps/multiplatform/spec/database.md index f6ecedb721..8981fe7055 100644 --- a/apps/multiplatform/spec/database.md +++ b/apps/multiplatform/spec/database.md @@ -345,7 +345,7 @@ class ArchiveConfig( ### Import Flow 1. User selects an archive file. -2. UI copies it to a temp location and constructs an `ArchiveConfig`. +2. UI copies it into `databaseExportDir` and constructs an `ArchiveConfig`. The destination is confined to that folder: `getFileName` returns a bare file name on every platform, and `saveArchiveFromURI` checks the canonical destination before copying. 3. Calls `apiImportArchive(config)` which sends `CC.ApiImportArchive` to the Haskell core. 4. The core extracts and replaces both databases. 5. Returns `CR.ArchiveImported` with a list of `ArchiveError` (non-fatal issues during import). diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index f808cf31ba..3a96638310 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -424,6 +424,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `platform/Videos.desktop.kt` | PC10 | Low | Desktop video utilities | | `platform/Notifications.desktop.kt` | PC18 | Low | Desktop notification setup | | `platform/Images.desktop.kt` | PC10 | Low | Desktop image processing | +| `platform/AnimatedImage.desktop.kt` | PC10 | Low | Desktop animated image frame decoding (bounded) | | `platform/PlatformTextField.desktop.kt` | PC4 | Low | Desktop text field actual implementation | | `platform/Share.desktop.kt` | PC10 | Low | Desktop clipboard/share | | `platform/Back.desktop.kt` | PC1 | Low | Desktop back navigation | diff --git a/apps/simplex-directory-service/README.md b/apps/simplex-directory-service/README.md index 5c397b6492..09df74abb0 100644 --- a/apps/simplex-directory-service/README.md +++ b/apps/simplex-directory-service/README.md @@ -143,11 +143,12 @@ The bot sends a welcome message automatically when you connect. ### 2. Registering a Group -Registration is a three-step process — see [DIRECTORY.md](../../docs/DIRECTORY.md) for full details: +Registration is a two-step process — see [DIRECTORY.md](../../docs/DIRECTORY.md) for full details: 1. Invite the directory bot to your group as `admin`. -2. Add the link the bot sends you to the group's welcome message. -3. Wait for admin approval (usually within a day, except holidays). +2. Wait for admin approval (usually within a day, except holidays). + +On approval the bot creates the join link, sends it to you, and recommends adding it to the group welcome message. Adding or removing this link in the welcome message keeps the group listed; other profile changes require re-approval. If a group with the same display name is already registered (but not yet listed or suspended), the bot asks you to confirm with `/confirm`. If the name is already listed or suspended in the directory, registration is blocked. @@ -277,33 +278,29 @@ Forward path, from invitation to being listed: └────────────┬─────────────┘ ▼ Proposed - │ bot joins the group and creates the link - ▼ - PendingUpdate - │ owner adds the link to the group welcome + │ bot joins the group ▼ PendingApproval - │ admin runs /approve + │ admin runs /approve; the bot creates the join link ▼ Active (listed; visible in search) ``` **Transitions out of Active:** -- → **PendingUpdate** — the directory bot link is removed from the welcome message. -- → **PendingApproval** — most other profile changes (see ** below); the `approval-id` shown to admins is bumped each time, so stale `/approve` commands are rejected. +- → **PendingApproval** — profile changes other than the bot link (see ** below); the `approval-id` shown to admins is bumped each time, so stale `/approve` commands are rejected. - → **Suspended** — an admin runs `/suspend`; `/resume` re-lists the group. - → **SuspendedBadRoles** — the directory bot loses its `admin` role, or the registering owner loses their `owner` role, in the group; automatically restored to **Active** once the roles are corrected. - → **Removed** — the owner runs `/delete`, the owner is removed from or leaves the group, the bot is removed from the group, or the group is deleted. The group can be re-registered afterwards. \* Only when the duplicate is registered but not yet listed or suspended. If the name is already listed or suspended, registration is blocked entirely. -\*\* Profile changes only trigger re-approval when fields other than the directory bot link are modified. If the only change is swapping the old bot link for the new one, or changing only whitespace in the description, the group stays Active. +\*\* Profile changes only trigger re-approval when fields other than the directory bot link are modified. Adding, removing, or replacing the bot link line in the welcome message, or changing only whitespace in the description, keeps the group Active. **State notes:** - **PendingConfirmation** — the bot was invited but a group with the same display name is already registered (in a pending state); the owner must run `/confirm` to proceed. - **Proposed** — the name is unique (or the duplicate was confirmed via `/confirm`); the bot is joining the group. -- **PendingUpdate** — the bot has joined the group and created the join link; the owner must add it to the group's welcome message. -- **PendingApproval** — submitted for admin review. The join link works even before approval. +- **PendingUpdate** — legacy state of registrations created before link-at-approval; any profile change moves such a group to PendingApproval. +- **PendingApproval** — submitted for admin review. The join link is created at first approval, so a new registration has no working link until approved. - **Active** — listed in the directory and visible in search results. diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 73f4505c87..428c825f3a 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -55,10 +55,10 @@ data DirectoryEvent | DEPendingMember GroupInfo GroupMember | DEPendingMemberMsg GroupInfo GroupMember ChatItemId Text | DEGroupItemProhibited GroupInfo GroupMember ChatItemId GroupFeature -- a member posted content prohibited by the group's settings - | DEContactRoleChanged GroupInfo ContactId GroupMemberRole -- contactId here is the contact whose role changed + | DEContactRoleChanged GroupInfo ContactId GroupMemberId GroupMemberRole -- contactId/memberId here identify the member whose role changed | DEServiceRoleChanged GroupInfo GroupMemberRole - | DEContactRemovedFromGroup ContactId GroupInfo - | DEContactLeftGroup ContactId GroupInfo + | DEContactRemovedFromGroup ContactId GroupMemberId GroupInfo + | DEContactLeftGroup ContactId GroupMemberId GroupInfo | DEServiceRemovedFromGroup GroupInfo | DEGroupDeleted GroupInfo | DEChatLinkReceived {contact :: Contact, chatItemId :: ChatItemId, chatLink :: MsgChatLink, ownerSig :: Maybe LinkOwnerSig} @@ -95,9 +95,9 @@ crDirectoryEvent_ = \case _ -> Nothing CEvtMemberRole {groupInfo, member, toRole} | groupMemberId' member == groupMemberId' (membership groupInfo) -> Just $ DEServiceRoleChanged groupInfo toRole - | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId toRole) <$> memberContactId member - CEvtDeletedMember {groupInfo, deletedMember} -> (`DEContactRemovedFromGroup` groupInfo) <$> memberContactId deletedMember - CEvtLeftMember {groupInfo, member} -> (`DEContactLeftGroup` groupInfo) <$> memberContactId member + | otherwise -> (\ctId -> DEContactRoleChanged groupInfo ctId (groupMemberId' member) toRole) <$> memberContactId member + CEvtDeletedMember {groupInfo, deletedMember} -> (\ctId -> DEContactRemovedFromGroup ctId (groupMemberId' deletedMember) groupInfo) <$> memberContactId deletedMember + CEvtLeftMember {groupInfo, member} -> (\ctId -> DEContactLeftGroup ctId (groupMemberId' member) groupInfo) <$> memberContactId member CEvtDeletedMemberUser {groupInfo} -> Just $ DEServiceRemovedFromGroup groupInfo CEvtGroupDeleted {groupInfo} -> Just $ DEGroupDeleted groupInfo CEvtUnknownMemberAnnounced {groupInfo, unknownMember, announcedMember} -> Just $ DEMemberUpdated {groupInfo, fromMember = unknownMember, toMember = announcedMember} diff --git a/apps/simplex-directory-service/src/Directory/Listing.hs b/apps/simplex-directory-service/src/Directory/Listing.hs index c84f2a488b..fd78757978 100644 --- a/apps/simplex-directory-service/src/Directory/Listing.hs +++ b/apps/simplex-directory-service/src/Directory/Listing.hs @@ -1,5 +1,6 @@ {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} @@ -34,12 +35,30 @@ import Data.Time.Format.ISO8601 (iso8601Show) import Directory.Store import Simplex.Chat.Markdown import Simplex.Chat.Types +import Simplex.Chat.View (simplexChatContact) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, taggedObjectJSON) import System.Directory import System.FilePath +-- the line the directory recommends adding to the group welcome message +groupLinkLine :: Text -> Text -> Text +groupLinkLine name link = groupLinkLinePrefix name <> link + +groupLinkLinePrefix :: Text -> Text +groupLinkLinePrefix name = "Link to join the group " <> name <> ": " + +matchesGroupLink :: CreatedLinkContact -> FormattedText -> Bool +matchesGroupLink (CCLink cReq sLnk_) = \case + FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of + CLFull cReq' -> sameConnReqContact cReq' cReq + CLShort sLnk' -> maybe False (sameShortLinkContact sLnk') sLnk_ + _ -> False + +descriptionContainsLink :: CreatedLinkContact -> Text -> Bool +descriptionContainsLink gLink = maybe False (any (matchesGroupLink gLink)) . parseMaybeMarkdownList + directoryDataPath :: String directoryDataPath = "data" @@ -107,7 +126,13 @@ groupDirectoryEntry now g@GroupInfo {groupProfile, chatTs, createdAt, groupSumma let gtStr = case gt' of GTChannel -> "channel"; _ -> "group" linkLine = "Link to join the " <> gtStr <> " " <> displayName <> ": " <> decodeUtf8 (strEncode sLnk) in Just $ maybe linkLine (<> "\n\n" <> linkLine) description - Nothing -> description + Nothing -> case connLinkContact <$> gLink_ of + Just gLink@(CCLink cReq sLnk_) + | not (maybe False (descriptionContainsLink gLink) description) -> + let linkText = maybe (strEncode $ simplexChatContact cReq) strEncode sLnk_ + linkLine = groupLinkLine displayName $ decodeUtf8 linkText + in Just $ maybe linkLine (<> "\n\n" <> linkLine) description + _ -> description entry groupLink = let de = DirectoryEntry diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index a80fd86f0d..a593e1074e 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -25,6 +25,7 @@ import Control.Logger.Simple import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class +import Control.Monad.Reader (runReaderT) import qualified Data.Attoparsec.Text as A import qualified Data.Aeson as J import qualified Data.Aeson.KeyMap as JM @@ -33,11 +34,11 @@ import Data.Bifunctor (first) import qualified Data.ByteString.Lazy.Char8 as LB import Data.Either (fromRight, isRight) import Data.Foldable (foldl') -import Data.List (find, intercalate) +import Data.Functor (($>)) +import Data.List (intercalate) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.Map.Strict as M -import Data.Functor (($>)) -import Data.Maybe (fromMaybe, isJust, isNothing, maybeToList) +import Data.Maybe (fromMaybe, isJust, isNothing, listToMaybe, maybeToList) import qualified Data.Set as S import Data.Text (Text) import qualified Data.Text as T @@ -58,6 +59,7 @@ import Simplex.Chat.Bot import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller import Simplex.Chat.Core +import Simplex.Chat.Library.Internal (setGroupLinkData) import Simplex.Chat.Markdown (Format (..), FormattedText (..), SimplexLinkType (..), parseMaybeMarkdownList, viewName) import Simplex.Chat.Messages import Simplex.Chat.Options @@ -72,7 +74,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.View (groupSimplexDomain, serializeChatError, serializeChatResponse, simplexChatContact, viewContactName, viewGroupName) -import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), SimplexDomain, sameConnReqContact, sameShortLinkContact) +import Simplex.Messaging.Agent.Protocol (AConnectionLink (..), ACreatedConnLink (..), AgentErrorType (..), ConnectionLink (..), CreatedConnLink (..), SConnectionMode (..), SimplexDomain) +import Simplex.Messaging.Client (NetworkRequestMode (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Encoding.String import Simplex.Messaging.Protocol (ErrorType (..)) @@ -85,13 +88,6 @@ import System.Exit (exitFailure) import System.Process (readProcess) import Text.Read (readMaybe) -data GroupProfileUpdate - = GPNoServiceLink - | GPServiceLinkAdded {linkNow :: Text} - | GPServiceLinkRemoved - | GPHasServiceLink {linkBefore :: Text, linkNow :: Text} - | GPServiceLinkError - data DuplicateGroup = DGUnique -- display name or full name is unique | DGRegistered -- the group with the same names is registered, additional confirmation is required @@ -182,7 +178,7 @@ directoryServiceCLI opts = do acceptMember = Just $ acceptMemberHook opts env } raceAny_ $ - [ simplexChatCLI' terminalChatConfig {chatHooks} (mkChatOpts opts) Nothing, + [ simplexChatCLI' terminalChatConfig {chatHooks, updateGroupLinksFromApp = True} (mkChatOpts opts) Nothing, processEvents env ] <> maybeToList (updateListingsThread_ opts env) @@ -274,7 +270,7 @@ directoryService opts cfg = do postStartHook = Just $ directoryPostStartHook opts env, acceptMember = Just $ acceptMemberHook opts env } - simplexChatCore cfg {chatHooks} (mkChatOpts opts) $ \user cc -> + simplexChatCore cfg {chatHooks, updateGroupLinksFromApp = True} (mkChatOpts opts) $ \user cc -> raceAny_ $ [ forever $ do (_, resp) <- atomically . readTBQueue $ outputQ cc @@ -343,10 +339,10 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o DEPendingMember g m -> dePendingMember g m DEPendingMemberMsg g m ciId t -> dePendingMemberMsg g m ciId t DEGroupItemProhibited g m ciId gf -> when prohibitedToObserver $ deGroupItemProhibited g m ciId gf - DEContactRoleChanged g ctId role -> deContactRoleChanged g ctId role + DEContactRoleChanged g ctId gmId role -> deContactRoleChanged g ctId gmId role DEServiceRoleChanged g role -> deServiceRoleChanged g role - DEContactRemovedFromGroup ctId g -> deContactRemovedFromGroup ctId g - DEContactLeftGroup ctId g -> deContactLeftGroup ctId g + DEContactRemovedFromGroup ctId gmId g -> deContactRemovedFromGroup ctId gmId g + DEContactLeftGroup ctId gmId g -> deContactLeftGroup ctId gmId g DEServiceRemovedFromGroup g -> deServiceRemovedFromGroup g DEGroupDeleted g -> deGroupDeleted g DEChatLinkReceived {contact = ct, chatLink, ownerSig} -> deChatLinkReceived ct chatLink ownerSig @@ -393,25 +389,22 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o directorySearch searchText cursor_ = searchListedGroups cc user (STSearch searchText) cursor_ searchResults >>= \case Left e -> logError ("searchListedGroups error: " <> T.pack e) $> DRError "search failed" - Right (gs, n) -> - getGroupLinks cc user (map fst gs) >>= \case - Left e -> logError ("getGroupLinks error: " <> T.pack e) $> DRError "search failed" - Right links -> do - now <- getCurrentTime - let rows = zipWith (\gr@(g, _) l -> (gr, searchEntry now g l)) gs links - -- rows with no link cannot be connected to, so they are not sent - entryRows = [(gr, e) | (gr, Just e) <- rows] - (sent, lastFitted) = fitPage entryRows - fittedAll = length sent == length entryRows - -- when the whole page fitted, the cursor covers every row read, including - -- rows dropped for having no link; otherwise it stops where sending stopped - cursorRow = if fittedAll then fst <$> lastMaybe rows else lastFitted - more = not fittedAll || n > length gs - pure - DRSearchResults - { entries = map snd sent, - searchCursor = if more then rowCursor <$> cursorRow else Nothing - } + Right (gs, n) -> do + now <- getCurrentTime + let rows = map (\row@(g, _, gLink_) -> (row, searchEntry now g gLink_)) gs + -- rows with no link cannot be connected to, so they are not sent + entryRows = [(row, e) | (row, Just e) <- rows] + (sent, lastFitted) = fitPage entryRows + fittedAll = length sent == length entryRows + -- when the whole page fitted, the cursor covers every row read, including + -- rows dropped for having no link; otherwise it stops where sending stopped + cursorRow = if fittedAll then fst <$> lastMaybe rows else lastFitted + more = not fittedAll || n > length gs + pure + DRSearchResults + { entries = map snd sent, + searchCursor = if more then rowCursor <$> cursorRow else Nothing + } where -- Send as many entries as the padded envelope allows, and report the last row consumed -- so the cursor can move past rows that were read but not sent. A lone entry that does @@ -430,7 +423,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o page rows = DRSearchResults {entries = map snd rows, searchCursor = rowCursor . fst <$> lastMaybe rows} lastMaybe = foldl' (\_ x -> Just x) Nothing - rowCursor (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}) = + rowCursor (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}, _) = SearchCursor {lastMembers = currentMembers, lastCreatedAt = createdAt, lastGroupId = groupId} groupLinkText (CCLink cReq sLnk_) = maybe (strEncodeTxt $ simplexChatContact cReq) strEncodeTxt sLnk_ withAdminUsers action = void . forkIO $ do @@ -440,6 +433,15 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyAdminUsers s = withAdminUsers $ \contactId -> sendMessage' cc contactId s notifyOwner = sendMessage' cc . dbContactId ctId `isOwner` GroupReg {dbContactId} = ctId == dbContactId + -- Whether the leaving/removed/role-changed member is the registration owner. + -- Comparing by member id (not contact id) is required because a non-owner + -- member can be associated with the owner's contact by the probe-and-merge + -- mechanism, which would otherwise make its departure de-list the group. + -- Registrations recorded before owner_member_id existed keep the contact-id + -- comparison. + isOwnerMember :: GroupReg -> GroupMemberId -> ContactId -> Bool + isOwnerMember GroupReg {dbContactId, dbOwnerMemberId} gmId ctId = + ctId == dbContactId && maybe True (gmId ==) dbOwnerMemberId withGroupReg :: GroupInfo -> Text -> (GroupReg -> IO ()) -> IO () withGroupReg GroupInfo {groupId, localDisplayName} err action = getGroupReg cc groupId >>= \case @@ -578,33 +580,18 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let msg = "Error updating group " <> tshow groupId <> " owner: " <> T.pack e logError msg notifyOwner gr msg - Right () -> do - notifyOwner gr $ "Joined the group " <> displayName <> ", creating the link…" - sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case - Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact = gLink}} -> - setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner - gr' - "Created the public link to join the group via this directory service that is always online.\n\n\ - \Please add it to the group welcome message.\n\ - \For example, add:" - notifyOwner gr' $ "Link to join the group " <> displayName <> ": " <> groupLinkText gLink - notifyOwner gr' $ recommendedSettingsNotice (userGroupRegId gr') - Left (ChatError e) -> case e of - CEGroupUserRole {} -> notifyOwner gr "Failed creating group link, as service is no longer an admin." - CEGroupMemberUserRemoved -> notifyOwner gr "Failed creating group link, as service is removed from the group." - CEGroupNotJoined _ -> notifyOwner gr $ unexpectedError "group not joined" - CEGroupMemberNotActive -> notifyOwner gr $ unexpectedError "service membership is not active" - _ -> notifyOwner gr $ unexpectedError "can't create group link" - _ -> notifyOwner gr $ unexpectedError "can't create group link" + Right () -> + setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval 1) $ \gr' -> do + notifyOwner gr' $ "Joined the group " <> displayName <> ". Registration is pending approval — it may take up to 48 hours." + notifyOwner gr' $ recommendedSettingsNotice (userGroupRegId gr') + verifyAndSendToApprove g gr' 1 deGroupUpdated :: GroupMember -> GroupInfo -> GroupInfo -> IO () deGroupUpdated m@GroupMember {memberProfile = LocalProfile {displayName = mName}} fromGroup toGroup = do logInfo $ "group updated " <> viewGroupName toGroup unless (sameProfile p p') $ do withGroupReg toGroup "group updated" $ \gr@GroupReg {groupRegStatus} -> do - let userGroupRef = userGroupReference gr toGroup - byMember = case memberContactId m of + let byMember = case memberContactId m of Just ctId | ctId `isOwner` gr -> "" -- group registration owner, not any group owner. _ -> " by " <> mName -- owner notification from directory will include the name. case publicGroup p' of @@ -615,26 +602,11 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Nothing -> case groupRegStatus of GRSPendingConfirmation -> pure () GRSProposed -> pure () - GRSPendingUpdate -> - groupProfileUpdate >>= \case - GPNoServiceLink -> - notifyOwner gr $ "The profile updated for " <> userGroupRef <> byMember <> ", but the group link is not added to the welcome message." - GPServiceLinkAdded _ -> groupLinkAdded gr byMember - GPServiceLinkRemoved -> - notifyOwner gr $ - "The group link of " <> userGroupRef <> " is removed from the welcome message" <> byMember <> ", please add it." - GPHasServiceLink {} -> groupLinkAdded gr byMember - GPServiceLinkError -> do - notifyOwner gr $ - ("Error: " <> serviceName <> " has no group link for " <> userGroupRef) - <> " after profile was updated" - <> byMember - <> ". Please report the error to the developers." - logError $ "Error: no group link for " <> userGroupRef - GRSPendingApproval n -> processProfileChange gr byMember False $ n + 1 - GRSActive -> processProfileChange gr byMember True 1 - GRSSuspended -> processProfileChange gr byMember False 1 - GRSSuspendedBadRoles -> processProfileChange gr byMember False 1 + GRSPendingUpdate -> sendForApproval byMember 1 + GRSPendingApproval n -> processProfileChange gr byMember $ n + 1 + GRSActive -> processProfileChange gr byMember 1 + GRSSuspended -> processProfileChange gr byMember 1 + GRSSuspendedBadRoles -> processProfileChange gr byMember 1 GRSRemoved -> pure () where GroupInfo {groupId, groupProfile = p} = fromGroup @@ -669,73 +641,46 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Nothing -> logError $ "no owner member set for " <> groupRef _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') (`updatedNotification` toGroup) - groupLinkAdded gr byMember = - getDuplicateGroup toGroup >>= \case - Left e -> notifyOwner gr $ "Error: getDuplicateGroup. Please notify the developers.\n" <> T.pack e - Right DGReserved -> notifyOwner gr $ groupAlreadyListed toGroup - _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval gaId) $ \gr' -> do - notifyOwner gr' $ - ("Thank you! The group link for " <> userGroupReference gr' toGroup <> " is added to the welcome message" <> byMember) - <> ".\nYou will be notified once the group is added to the directory - it may take up to 48 hours." - checkRolesSendToApprove gr' gaId - where - gaId = 1 - processProfileChange gr byMember isActive n' = do - let userGroupRef = userGroupReference gr toGroup - groupRef = groupReference toGroup - groupProfileUpdate >>= \case - GPNoServiceLink -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner gr' $ - ("The group profile is updated for " <> userGroupRef <> byMember <> ", but no link is added to the welcome message.\n\n") - <> "The group will remain hidden from the directory until the group link is added and the group is re-approved." - GPServiceLinkRemoved -> setGroupStatus notifyAdminUsers env cc groupId GRSPendingUpdate $ \gr' -> do - notifyOwner gr' $ - ("The group link for " <> userGroupRef <> " is removed from the welcome message" <> byMember) - <> ".\n\nThe group is hidden from the directory until the group link is added and the group is re-approved." - notifyAdminUsers $ "The group link is removed from " <> groupRef <> ", de-listed." - GPServiceLinkAdded _ -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do - notifyOwner gr' $ - ("The group link is added to " <> userGroupRef <> byMember) - <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group link is added to " <> groupRef <> byMember <> "." - checkRolesSendToApprove gr n' - GPHasServiceLink {linkBefore, linkNow} - | isActive && onlyLinkChanged p p' -> do - notifyOwner gr $ - ("The group " <> userGroupRef <> " is updated" <> byMember) - <> "!\nThe group is listed in directory." - notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." - | otherwise -> setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do - notifyOwner gr' $ - ("The group " <> userGroupRef <> " is updated" <> byMember) - <> "!\nIt is hidden from the directory until approved." - notifyAdminUsers $ "The group " <> groupRef <> " is updated" <> byMember <> "." - checkRolesSendToApprove gr' n' - where - onlyLinkChanged - GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} - GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = - dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && (T.words . T.replace linkBefore "" <$> d) == (T.words . T.replace linkNow "" <$> d') - GPServiceLinkError -> logError $ "Error: no group link for " <> groupRef <> " pending approval." - groupProfileUpdate = profileUpdate <$> sendChatCmd cc (APIGetGroupLink groupId) + sendForApproval byMember n' = + setGroupStatus notifyAdminUsers env cc groupId (GRSPendingApproval n') $ \gr' -> do + notifyOwner gr' $ + ("The group " <> userGroupReference gr' toGroup <> " is updated" <> byMember) + <> "!\nIt is hidden from the directory until approved." + notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> "." + checkRolesSendToApprove gr' n' + processProfileChange gr byMember n' = + withDB' "getGroupLink" cc (\db -> runExceptT $ getGroupLink db user toGroup) >>= \case + Left e -> linkReadError $ T.pack e + Right (Left SEGroupLinkNotFound {}) -> profileChange Nothing + Right (Left e) -> linkReadError $ tshow e + Right (Right gLink) -> profileChange $ Just gLink where - profileUpdate = \case - Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cr sl_}} -> - let linkBefore_ = profileGroupLinkText fromGroup - linkNow_ = profileGroupLinkText toGroup - profileGroupLinkText GroupInfo {groupProfile = GroupProfile {description = descr_}} = - maybe Nothing (fmap (\(FormattedText _ t) -> t) . find ftHasLink) $ parseMaybeMarkdownList =<< descr_ - ftHasLink = \case - FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of - CLFull cr' -> sameConnReqContact cr' cr - CLShort sl' -> maybe False (sameShortLinkContact sl') sl_ - _ -> False - in case (linkBefore_, linkNow_) of - (Just linkBefore, Just linkNow) -> GPHasServiceLink linkBefore linkNow - (Just _, Nothing) -> GPServiceLinkRemoved - (Nothing, Just linkNow) -> GPServiceLinkAdded linkNow - (Nothing, Nothing) -> GPNoServiceLink - _ -> GPServiceLinkError + linkReadError e = logError $ "Error reading group link for " <> groupReference toGroup <> ": " <> e + profileChange gLink_ + | not (linkOnlyChange gLink_) = sendForApproval byMember n' + | groupRegStatus gr == GRSActive = do + notifyOwner gr $ + ("The group " <> userGroupReference gr toGroup <> " is updated" <> byMember) + <> "!\nThe group is listed in directory." + notifyAdminUsers $ "The group " <> groupReference toGroup <> " is updated" <> byMember <> " - only link or whitespace changes.\nThe group remained listed in directory." + forM_ gLink_ $ \gLink -> + updateGroupLinkData cc user toGroup gLink >>= \case + Right _ -> pure () + Left e -> logError $ "Error updating group link data for " <> groupReference toGroup <> ": " <> tshow e + | otherwise = pure () + linkOnlyChange gLink_ = + dn == dn' && fn == fn' && i == i' && sd == sd' && ma == ma' && descrWords d == descrWords d' + where + GroupProfile {displayName = dn, fullName = fn, shortDescr = sd, image = i, description = d, memberAdmission = ma} = p + GroupProfile {displayName = dn', fullName = fn', shortDescr = sd', image = i', description = d', memberAdmission = ma'} = p' + -- drop the recommended link line (link token and prefix) so adding or removing it is not a content change + descrWords = maybe [] $ case gLink_ of + Just GroupLink {connLinkContact} -> + T.words . T.replace (groupLinkLinePrefix dn) "" . withoutLink connLinkContact + Nothing -> T.words + withoutLink gl descr = + maybe descr (T.concat . map ftText . filter (not . matchesGroupLink gl)) $ parseMaybeMarkdownList descr + ftText (FormattedText _ t) = t checkRolesSendToApprove gr gaId = do (badRolesMsg <$$> getGroupRolesStatus toGroup gr) >>= \case Left e -> notifyOwner gr $ "Error: getGroupRolesStatus. Please notify the developers.\n" <> T.pack e @@ -953,13 +898,13 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o sendToApprove g' gr (n + 1) _ -> pure () - deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberRole -> IO () - deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId contactRole = do + deContactRoleChanged :: GroupInfo -> ContactId -> GroupMemberId -> GroupMemberRole -> IO () + deContactRoleChanged g@GroupInfo {groupId, membership = GroupMember {memberRole = serviceRole}} ctId gmId contactRole = do logInfo $ "contact ID " <> tshow ctId <> " role changed in group " <> viewGroupName g <> " to " <> tshow contactRole withGroupReg g "contact role changed" $ \gr@GroupReg {groupRegStatus} -> do let userGroupRef = userGroupReference gr g uCtRole = "Your role in the group " <> userGroupRef <> " is changed to " <> ctRole - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ case groupRegStatus of GRSSuspendedBadRoles | rStatus == GRSOk -> setGroupStatus notifyAdminUsers env cc groupId GRSActive $ \gr' -> do @@ -1008,23 +953,23 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o getOwnerGroupMember groupId gr >>= mapM_ (\cm@GroupMember {memberRole} -> when (memberRole == GROwner && memberActive cm) action) - deContactRemovedFromGroup :: ContactId -> GroupInfo -> IO () - deContactRemovedFromGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + deContactRemovedFromGroup :: ContactId -> GroupMemberId -> GroupInfo -> IO () + deContactRemovedFromGroup ctId gmId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " removed from group " <> viewGroupName g withGroupReg g "contact removed" $ \gr -> - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do notifyOwner gr' $ "You are removed from the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner is removed)." when (isJust pg_) $ leavePublicGroup g - deContactLeftGroup :: ContactId -> GroupInfo -> IO () - deContactLeftGroup ctId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do + deContactLeftGroup :: ContactId -> GroupMemberId -> GroupInfo -> IO () + deContactLeftGroup ctId gmId g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}} = do let gt = maybe "group" groupTypeStr' pg_ logInfo $ "contact ID " <> tshow ctId <> " left group " <> viewGroupName g withGroupReg g "contact left" $ \gr -> - when (ctId `isOwner` gr) $ + when (isOwnerMember gr gmId ctId) $ setGroupStatus notifyAdminUsers env cc groupId GRSRemoved $ \gr' -> do notifyOwner gr' $ "You left the " <> gt <> " " <> userGroupReference gr' g <> ".\n\nThe " <> gt <> " is no longer listed in the directory." notifyAdminUsers $ "The " <> gt <> " " <> groupReference g <> " is de-listed (" <> gt <> " owner left)." @@ -1180,11 +1125,9 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o \*To register a channel*, use _Share via chat_ to send its link to " <> serviceName <> " bot.\n\n\ - \*To register a group*:\n\ - \1️⃣ *Invite* " + \*To register a group*, *invite* " <> serviceName - <> " bot to your group as *admin* - it will create a link for new members to join.\n\ - \2️⃣ *Add* this link to the group's welcome message.\n\n\ + <> " bot to your group as *admin* - once the group is approved, it will create a link for new members to join.\n\n\ \Once your group or channel *approved*, it can be found here or at [simplex.chat/directory](https://simplex.chat/directory).\n\n\ \_We usually review within a day, except holidays_. [More details](https://simplex.chat/docs/directory.html#adding-groups-to-the-directory)." DCHelp DHSCommands -> @@ -1194,22 +1137,25 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o \/list - list the groups you registered.\n\ \`/role ` - view and set default member role for your group.\n\ \`/filter ` - view and set spam filter settings for group.\n\ - \`/link ` - view and upgrade group link.\n\ + \`/link ` - view group link.\n\ \`/delete :` - remove the group you submitted from directory, with _ID_ and _name_ as shown by /list command.\n\n\ \To search for groups, send the search text." - DCSearchGroup s ft -> - sendFoundListedGroups (STSearch s) Nothing notFound $ \gs n -> - let more = if n > length gs then ", sending top " <> tshow (length gs) else "" - in "Found " <> tshow n <> " group(s)" <> more <> "." + DCSearchGroup s ft -> case ft >>= groupLinkUri of + Just uri -> + getRegisteredGroupByLink uri >>= \case + Just (g, gr, ccLink) + | isAdmin -> sendGroupsInfo ct ciId True ([(g, gr)], 1) + | groupRegStatus gr == GRSActive -> sendFoundGroups "Found group:" [(g, gr, Just ccLink)] 0 + _ + | isAdmin -> sendReply "This link is not registered in the directory" + | otherwise -> sendReply linkNotFound + Nothing -> + sendFoundListedGroups (STSearch s) Nothing "No groups found" $ \gs n -> + let more = if n > length gs then ", sending top " <> tshow (length gs) else "" + in "Found " <> tshow n <> " group(s)" <> more <> "." where - notFound - | hasSimplexGroupLink ft = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." - | otherwise = "No groups found" - hasSimplexGroupLink = \case - Just fts -> any isGroupLink fts - Nothing -> False - isGroupLink (FormattedText (Just SimplexLink {linkType}) _) = linkType == XLGroup || linkType == XLChannel - isGroupLink _ = False + linkNotFound = "No groups found.\nTo register a group or a channel, please use \"Share via chat\" feature." + groupLinkUri fts = listToMaybe [uri | FormattedText (Just SimplexLink {linkType, simplexUri = uri}) _ <- fts, linkType == XLGroup || linkType == XLChannel] DCSearchNext -> atomically (TM.lookup (contactId' ct) searchRequests) >>= \case Just SearchRequest {searchType, searchTime, searchCursor} -> do @@ -1248,7 +1194,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o when (isJust pg_) $ leavePublicGroup g Left e -> sendReply $ "Error deleting " <> gt <> " " <> displayName <> ": " <> T.pack e DCMemberRole gId gName_ mRole_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g gr -> ifPublicGroup g (sendReply "This command is not available for public groups.") $ do let GroupInfo {groupProfile = GroupProfile {displayName = n}} = g case mRole_ of @@ -1260,14 +1206,17 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o initialRole n acceptMemberRole <> ("Send /'role " <> tshow gId <> " " <> textEncode anotherRole <> "' to change it.\n\n") <> onlyViaLink gLink - Left _ -> sendReply $ "Error: failed reading the initial member role for the group " <> n + Left _ -> sendReply $ roleError gr n $ "Error: failed reading the initial member role for the group " <> n Just mRole -> do setGroupLinkRole cc g mRole >>= \case Just gLink -> sendReply $ initialRole n mRole <> "\n" <> onlyViaLink gLink - Nothing -> sendReply $ "Error: the initial member role for the group " <> n <> " was NOT upgated." + Nothing -> sendReply $ roleError gr n $ "Error: the initial member role for the group " <> n <> " was NOT updated." where initialRole n mRole = "The initial member role for the group " <> n <> " is set to *" <> textEncode mRole <> "*\n" onlyViaLink gLink = "*Please note*: it applies only to members joining via this link: " <> groupLinkText gLink + roleError gr n err = case groupRegStatus gr of + GRSActive -> err + _ -> "The group link for " <> n <> " is created when the group is approved." DCGroupFilter gId gName_ acceptance_ -> (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g _gr -> ifPublicGroup g (sendReply "This command is not available for public groups.") $ do @@ -1301,7 +1250,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o Just PCAll -> "_enabled_" Just PCNoImage -> "_enabled for profiles without image_" DCShowUpgradeGroupLink gId gName_ -> - (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} _ -> case pg_ of + (if isAdmin then withGroupAndReg_ sendReply else withUserGroupReg_) gId gName_ $ \g@GroupInfo {groupId, groupProfile = GroupProfile {publicGroup = pg_}, localDisplayName = gName} gr -> case pg_ of Just pg@PublicGroupProfile {groupLink} -> sendReply $ "The link to join the " <> groupTypeStr' pg <> " " <> groupReference' gId gName <> ":\n" <> strEncodeTxt groupLink <> maybe "" (("\nSimpleX name: " <>) . simplexNameStr) (verifiedGroupDomain g) @@ -1309,7 +1258,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let groupRef = groupReference' gId gName withGroupLinkResult groupRef (sendChatCmd cc $ APIGetGroupLink groupId) $ \GroupLink {connLinkContact = gLink@(CCLink _ sLnk_), acceptMemberRole, shortLinkDataSet, shortLinkLargeDataSet = BoolDef slLargeDataSet} -> do - let shouldBeUpgraded = isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet + let shouldBeUpgraded = (isNothing sLnk_ || not shortLinkDataSet || not slLargeDataSet) && groupRegStatus gr == GRSActive sendReply $ T.unlines $ [ "The link to join the group " <> groupRef <> ":", @@ -1343,7 +1292,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o a >>= \case Right CRGroupLink {groupLink} -> cb groupLink Left (ChatErrorStore (SEGroupLinkNotFound _)) -> - sendReply $ "The group " <> groupRef <> " has no public link." + sendReply $ "The group " <> groupRef <> " has no public link.\nThe group link is created when the group is approved." Right r -> do ts <- getCurrentTime tz <- getCurrentTimeZone @@ -1373,39 +1322,56 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o sendReply notFound Right (gs, n) -> do let moreGroups = n - length gs - updateSearchRequest searchType $ last gs - sendFoundGroups (replyStr gs n) gs moreGroups + gs' = map (\(g, gr, gLink_) -> (g, gr, (\GroupLink {connLinkContact = cl} -> cl) <$> gLink_)) gs + updateSearchRequest searchType $ last gs' + sendFoundGroups (replyStr gs' n) gs' moreGroups Left e -> sendReply $ "Error: searchListedGroups. Please notify the developers.\n" <> T.pack e allGroupsReply sortName gs n = let more = if n > length gs then ", sending " <> sortName <> " " <> tshow (length gs) else "" in tshow n <> " group(s) listed" <> more <> "." - updateSearchRequest :: SearchType -> (GroupInfo, GroupReg) -> IO () - updateSearchRequest searchType (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}) = do + updateSearchRequest :: SearchType -> (GroupInfo, GroupReg, Maybe CreatedLinkContact) -> IO () + updateSearchRequest searchType (GroupInfo {groupId, groupSummary = GroupSummary {currentMembers}}, GroupReg {createdAt}, _) = do searchTime <- getCurrentTime let searchCursor = SearchCursor {lastMembers = currentMembers, lastCreatedAt = createdAt, lastGroupId = groupId} search = SearchRequest {searchType, searchTime, searchCursor} atomically $ TM.insert (contactId' ct) search searchRequests + getRegisteredGroupByLink :: AConnectionLink -> IO (Maybe (GroupInfo, GroupReg, CreatedLinkContact)) + getRegisteredGroupByLink uri = + sendChatCmd cc (APIConnectPlan userId (Just (aConnectTarget uri)) PRMNever Nothing) >>= \case + Right (CRConnectionPlan _ (ACCL SCMContact ccLink) _ _ (CPGroupLink glp)) -> case glp of + GLPOwnLink g -> groupReg g ccLink + GLPKnown {groupInfo = g} -> groupReg g ccLink + GLPConnectingProhibit (Just g) -> groupReg g ccLink + _ -> pure Nothing + _ -> pure Nothing + where + groupReg :: GroupInfo -> CreatedLinkContact -> IO (Maybe (GroupInfo, GroupReg, CreatedLinkContact)) + groupReg g ccLink = fmap (\gr -> (g, gr, ccLink)) . eitherToMaybe <$> getGroupReg cc (groupId' g) sendFoundGroups reply gs moreGroups = void . forkIO $ sendComposedMessages_ cc (SRDirect $ contactId' ct) msgs where msgs = replyMsg :| map foundGroup gs <> [moreMsg | moreGroups > 0] replyMsg = (Just ciId, MCText reply) - foundGroup (g@GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _) = + foundGroup (g@GroupInfo {groupId, groupProfile = p@GroupProfile {image = image_, memberAdmission}, groupSummary}, _, cLink_) = let membersStr = "_" <> membersCountStr p groupSummary <> "_" showId = if isAdmin then tshow groupId <> ". " else "" - text = T.unlines $ [showId <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p, membersStr] ++ knockingStr memberAdmission + text = T.unlines $ [showId <> groupInfoText (simplexNameStr <$> verifiedGroupDomain g) p] <> foundGroupLinkLine p cLink_ <> [membersStr] <> knockingStr memberAdmission in (Nothing, maybe (MCText text) (\image -> MCImage {text, image}) image_) moreMsg = (Nothing, MCText $ "Send /next for " <> tshow moreGroups <> " more result(s).") - + -- link line for a non-public group in search results, unless its welcome message already contains it + foundGroupLinkLine GroupProfile {displayName = n, description, publicGroup} cLink_ = case (publicGroup, cLink_) of + (Nothing, Just gLink) + | not (maybe False (descriptionContainsLink gLink) description) -> [groupLinkLine n (groupLinkText gLink)] + _ -> [] deAdminCommand :: Contact -> ChatItemId -> DirectoryCmd 'DRAdmin -> IO () deAdminCommand ct ciId cmd | knownCt `elem` adminUsers || knownCt `elem` superUsers = case cmd of DCApproveGroup {groupId, displayName = n, groupApprovalId, promote} -> - withGroupAndReg sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} -> + withGroupRegLink sendReply groupId n $ \g gr@GroupReg {userGroupRegId = ugrId, promoted} curLink_ -> case groupRegStatus gr of GRSPendingApproval gaId | gaId == groupApprovalId -> do - let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_}} = g + let GroupInfo {groupProfile = GroupProfile {publicGroup = pg_, description = descr_}} = g isPublicGroup_ = isJust pg_ gt = maybe "group" groupTypeStr' pg_ getDuplicateGroup g >>= \case @@ -1418,28 +1384,37 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o let grPromoted' | promoted || knownCt `elem` superUsers = fromMaybe promoted promote | otherwise = False - setGroupStatusPromo sendReply env cc gr GRSActive grPromoted' $ do - let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved" - let commands - | isPublicGroup_ = "" - | otherwise = - "\n\nSupported commands:\n" - <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") - <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") - <> ("/'link " <> tshow ugrId <> "' - to view/upgrade group link.") - notifyOwner gr $ - (approved <> " and listed in directory - please moderate it!\n") - <> "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." - <> commands - invited <- - forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do - inviteToOwnersGroup og gr $ \case - Right () -> do - owner <- groupOwnerInfo groupRef $ dbContactId gr - pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName - Left err -> pure err - sendReply $ T.toTitle gt <> " approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited - notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited + gLink_ <- if isPublicGroup_ then pure (Right Nothing) else approvedGroupLink g curLink_ + case gLink_ of + Left e -> sendReply e + Right gLink' -> + setGroupStatusPromo sendReply env cc gr GRSActive grPromoted' $ do + let approved = "The " <> gt <> " " <> userGroupReference' gr n <> " is approved" + addLink = maybe False (\l -> not $ maybe False (descriptionContainsLink l) descr_) gLink' + commands + | isPublicGroup_ = "" + | otherwise = + "\n\nSupported commands:\n" + <> ("/'filter " <> tshow ugrId <> "' - to configure anti-spam filter.\n") + <> ("/'role " <> tshow ugrId <> "' - to set default member role.\n") + <> ("/'link " <> tshow ugrId <> "' - to view group link.") + notifyOwner gr $ + (approved <> " and listed in directory - please moderate it!\n") + <> ( if addLink + then "To help people join, copy the next message with the group link and add it to the end of the group welcome message. The group will remain listed. Any other change to the group profile hides it from the directory until it is re-approved." + else "_Please note_: if you change the " <> gt <> " profile it will be hidden from directory until it is re-approved." + ) + <> commands + when addLink $ forM_ gLink' $ \l -> notifyOwner gr $ groupLinkLine n (groupLinkText l) + invited <- + forM ownersGroup $ \og@KnownGroup {localDisplayName = ogName} -> do + inviteToOwnersGroup og gr $ \case + Right () -> do + owner <- groupOwnerInfo groupRef $ dbContactId gr + pure $ "Invited " <> owner <> " to owners' group " <> viewName ogName + Left err -> pure err + sendReply $ T.toTitle gt <> " approved" <> (if grPromoted' then " (promoted)" else "") <> "!" <> maybe "" ("\n" <>) invited + notifyOtherSuperUsers $ approved <> " by " <> viewName (localDisplayName' ct) <> maybe "" ("\n" <>) invited Right GRSServiceNotAdmin -> replyNotApproved serviceNotAdmin Right GRSContactNotOwner -> replyNotApproved "user is not an owner." Right GRSBadRoles -> replyNotApproved $ "user is not an owner, " <> serviceNotAdmin @@ -1448,9 +1423,24 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o replyNotApproved reason = sendReply $ "Group is not approved: " <> reason serviceNotAdmin = serviceName <> " is not an admin." | otherwise -> sendReply "Incorrect approval code" - _ -> sendReply $ "Error: the group " <> groupRef <> " is not pending approval." + status -> sendReply $ "Error: the group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it is not pending approval." where groupRef = groupReference' groupId n + approvedGroupLink g = \case + Just gLink -> + updateGroupLinkData cc user g gLink >>= \case + Right GroupLink {connLinkContact} -> pure $ Right $ Just connLinkContact + Left e -> pure $ Left $ "Error updating group link data: " <> tshow e + Nothing -> + sendChatCmd cc (APICreateGroupLink groupId GRMember) >>= \case + Right CRGroupLinkCreated {groupLink = GroupLink {connLinkContact}} -> pure $ Right $ Just connLinkContact + Left (ChatError e) -> pure $ Left $ case e of + CEGroupUserRole {} -> "Failed creating group link, as service is no longer an admin." + CEGroupMemberUserRemoved -> "Failed creating group link, as service is removed from the group." + CEGroupNotJoined _ -> unexpectedError "group not joined" + CEGroupMemberNotActive -> unexpectedError "service membership is not active" + _ -> unexpectedError "can't create group link" + _ -> pure $ Left $ unexpectedError "can't create group link" DCRejectGroup _gaId _gName -> pure () DCSuspendGroup groupId gName -> do let groupRef = groupReference' groupId gName @@ -1461,7 +1451,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyOwner gr' $ suspended <> " and hidden from directory. Please contact the administrators." sendReply "Group suspended!" notifyOtherSuperUsers $ suspended <> " by " <> viewName (localDisplayName' ct) - _ -> sendReply $ "The group " <> groupRef <> " is not active, can't be suspended." + status -> sendReply $ "The group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it can't be suspended." DCResumeGroup groupId gName -> do let groupRef = groupReference' groupId gName withGroupAndReg sendReply groupId gName $ \_ gr -> @@ -1471,7 +1461,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o notifyOwner gr' $ groupStr <> " is listed in the directory again!" sendReply "Group listing resumed!" notifyOtherSuperUsers $ groupStr <> " listing resumed by " <> viewName (localDisplayName' ct) - _ -> sendReply $ "The group " <> groupRef <> " is not suspended, can't be resumed." + status -> sendReply $ "The group " <> groupRef <> " status is " <> groupRegStatusText status <> ", it can't be resumed." DCListLastGroups count -> listLastGroups cc user count >>= \case Left e -> sendReply $ "Error reading groups: " <> T.pack e @@ -1558,18 +1548,25 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o mkSendReply :: Contact -> ChatItemId -> Text -> IO () mkSendReply ct ciId = sendComposedMessage cc ct (Just ciId) . MCText + withGroupRegLink :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink sendReply gId = withGroupRegLink_ sendReply gId . Just + + withGroupRegLink_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> Maybe GroupLink -> IO ()) -> IO () + withGroupRegLink_ sendReply gId gName_ action = + getGroupAndRegLink cc user gId >>= \case + Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e + Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr, gLink_) + | maybe False (displayName ==) gName_ -> + action g gr gLink_ + | otherwise -> + sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName + withGroupAndReg :: (Text -> IO ()) -> GroupId -> GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg sendReply gId = withGroupAndReg_ sendReply gId . Just withGroupAndReg_ :: (Text -> IO ()) -> GroupId -> Maybe GroupName -> (GroupInfo -> GroupReg -> IO ()) -> IO () withGroupAndReg_ sendReply gId gName_ action = - getGroupAndReg cc user gId >>= \case - Left e -> sendReply $ "Group " <> tshow gId <> " error (getGroup): " <> T.pack e - Right (g@GroupInfo {groupProfile = GroupProfile {displayName}}, gr) - | maybe False (displayName ==) gName_ -> - action g gr - | otherwise -> - sendReply $ "Group ID " <> tshow gId <> " has the display name " <> displayName + withGroupRegLink_ sendReply gId gName_ $ \g gr _ -> action g gr getOwnersInfo :: [(GroupInfo, GroupReg)] -> IO [((GroupInfo, GroupReg), Maybe (Either String Contact))] getOwnersInfo gs = @@ -1647,6 +1644,9 @@ getGroupLink' :: ChatController -> User -> GroupInfo -> IO (Either String GroupL getGroupLink' cc user gInfo = withDB "getGroupLink" cc $ \db -> withExceptT groupDBError $ getGroupLink db user gInfo +updateGroupLinkData :: ChatController -> User -> GroupInfo -> GroupLink -> IO (Either ChatError GroupLink) +updateGroupLinkData cc user gInfo gLink = runReaderT (runExceptT $ setGroupLinkData NRMBackground user gInfo gLink) cc + setGroupLinkRole :: ChatController -> GroupInfo -> GroupMemberRole -> IO (Maybe CreatedLinkContact) setGroupLinkRole cc GroupInfo {groupId} mRole = resp <$> sendChatCmd cc (APIGroupLinkMemberRole groupId mRole) where diff --git a/apps/simplex-directory-service/src/Directory/Store.hs b/apps/simplex-directory-service/src/Directory/Store.hs index f55149e745..6b2d776b52 100644 --- a/apps/simplex-directory-service/src/Directory/Store.hs +++ b/apps/simplex-directory-service/src/Directory/Store.hs @@ -33,12 +33,11 @@ module Directory.Store getAllGroupRegs_, getDuplicateGroupRegs, getGroupReg, - getGroupAndReg, + getGroupAndRegLink, listLastGroups, listPendingGroups, getAllListedGroups, getAllListedGroups_, - getGroupLinks, searchListedGroups, verifiedGroupDomain, groupRegStatusText, @@ -77,7 +76,8 @@ import Simplex.Chat.Store import Simplex.Chat.Store.Groups import Simplex.Chat.Store.Shared (groupInfoQueryFields, groupInfoQueryFrom) import Simplex.Chat.Types -import Simplex.Messaging.Agent.Protocol (SimplexDomain) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) +import Simplex.Messaging.Agent.Protocol (CreatedConnLink (..), SimplexDomain) import Simplex.Messaging.Agent.Store.DB (BoolInt (..), fromTextField_) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Encoding.String @@ -309,11 +309,11 @@ getGroupReg_ db gId = |] (Only gId) -getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg)) -getGroupAndReg cc user@User {userId, userContactId} gId = - withDB "getGroupAndReg" cc $ \db -> do +getGroupAndRegLink :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg, Maybe GroupLink)) +getGroupAndRegLink cc user@User {userId, userContactId} gId = + withDB "getGroupAndRegLink" cc $ \db -> do currentTs <- liftIO getCurrentTime - ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ + ExceptT $ firstRow (toGroupInfoRegLink currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $ DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId) getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg)) @@ -336,18 +336,10 @@ getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllList getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)] getAllListedGroups_ db cxt user@User {userId, userContactId} = do currentTs <- getCurrentTime - DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) - >>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user) - where - withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g) + map (toGroupInfoRegLink currentTs cxt user) + <$> DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive) --- only the RPC search needs links, so they are read for the returned page rather than in the search query -getGroupLinks :: ChatController -> User -> [GroupInfo] -> IO (Either String [Maybe GroupLink]) -getGroupLinks cc user gs = - withDB' "getGroupLinks" cc $ \db -> - mapM (\g -> eitherToMaybe <$> runExceptT (getGroupLink db user g)) gs - -searchListedGroups :: ChatController -> User -> SearchType -> Maybe SearchCursor -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int)) +searchListedGroups :: ChatController -> User -> SearchType -> Maybe SearchCursor -> Int -> IO (Either String ([(GroupInfo, GroupReg, Maybe GroupLink)], Int)) searchListedGroups cc user@User {userId, userContactId} searchType cursor_ pageSize = withDB' "searchListedGroups" cc $ \db -> do currentTs <- getCurrentTime @@ -391,7 +383,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType cursor_ pageS _ -> s countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? " where - groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>) + groups currentTs = (map (toGroupInfoRegLink currentTs (storeCxt cc) user) <$>) count = maybeFirstRow' 0 fromOnly listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? " countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id " @@ -444,9 +436,12 @@ listPendingGroups cc user@User {userId, userContactId} count = n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'" pure (gs, n) -toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg) -toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) = - (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow) +toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg) +toGroupInfoReg currentTs cxt user row = let (g, gr, _) = toGroupInfoRegLink currentTs cxt user row in (g, gr) + +toGroupInfoRegLink :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow :. GroupLinkRow) -> (GroupInfo, GroupReg, Maybe GroupLink) +toGroupInfoRegLink currentTs cxt User {userContactId} (groupRow :. grRow :. linkRow) = + (toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow, toMaybeGroupLink linkRow) type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime) @@ -454,10 +449,30 @@ rowToGroupReg :: GroupRegRow -> GroupReg rowToGroupReg (dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, BI promoted, createdAt) = GroupReg {dbGroupId, userGroupRegId, dbContactId, dbOwnerMemberId, groupRegStatus, promoted, createdAt} +type GroupLinkRow = (Maybe Int64, Maybe ConnReqContact, Maybe ShortLinkContact, Maybe BoolInt, Maybe BoolInt, Maybe GroupLinkId, Maybe GroupMemberRole) + +toMaybeGroupLink :: GroupLinkRow -> Maybe GroupLink +toMaybeGroupLink (Just userContactLinkId, Just cReq, shortLink, slDataSet, slLarge, Just groupLinkId, mRole_) = + Just + GroupLink + { userContactLinkId, + connLinkContact = CCLink cReq shortLink, + shortLinkDataSet = boolInt slDataSet, + shortLinkLargeDataSet = BoolDef $ boolInt slLarge, + groupLinkId, + acceptMemberRole = fromMaybe GRMember mRole_ + } + where + boolInt = maybe False (\(BI b) -> b) +toMaybeGroupLink _ = Nothing + +-- group with its registration and its join link (user_contact_links) in one query groupReqQuery :: Query -groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupInfoQueryFrom <> groupRegFromCond +groupReqQuery = groupInfoQueryFields <> groupRegFields <> groupLinkFields <> groupInfoQueryFrom <> groupLinkJoin <> groupRegFromCond where groupRegFields = ", r.group_id, r.user_group_reg_id, r.contact_id, r.owner_member_id, r.group_reg_status, r.group_promoted, r.created_at " + groupLinkFields = ", uc.user_contact_link_id, uc.conn_req_contact, uc.short_link_contact, uc.short_link_data_set, uc.short_link_large_data_set, uc.group_link_id, uc.group_link_member_role " + groupLinkJoin = " LEFT JOIN user_contact_links uc ON uc.group_id = g.group_id AND uc.user_id = g.user_id " groupRegFromCond = " JOIN sx_directory_group_regs r ON r.group_id = g.group_id WHERE g.user_id = ? AND mu.contact_id = ? " instance StrEncoding GroupRegStatus where diff --git a/apps/simplex-support-bot-light/.gitignore b/apps/simplex-support-bot-light/.gitignore new file mode 100644 index 0000000000..b8fdfac347 --- /dev/null +++ b/apps/simplex-support-bot-light/.gitignore @@ -0,0 +1,28 @@ +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +build/ +dist/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.pyright_cache/ + +.venv/ +.venv-*/ +venv/ + +config.toml +.env +*.db +*.db-* + +# Tracked as an empty directory: Docker creates a missing bind-mount source as +# root, which the unprivileged bot user cannot write to. +state/* +!state/.gitkeep + +bot-config/*.png +bot-config/*.jpg +bot-config/*.jpeg diff --git a/apps/simplex-support-bot-light/Dockerfile b/apps/simplex-support-bot-light/Dockerfile new file mode 100644 index 0000000000..66427be1a0 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile @@ -0,0 +1,130 @@ +# syntax=docker/dockerfile:1 +# +# Built from the repository root, not this directory: the image is made from the +# Haskell core and the Python library in this tree, neither of them released. +# +# docker compose build # from apps/simplex-support-bot-light +# docker build -f apps/simplex-support-bot-light/Dockerfile . +# +# The first stage compiles libsimplex from src/. That is a full GHC build of +# simplexmq and simplex-chat: hours on a cold cache, and it needs ~15 GB. + +ARG UBUNTU=24.04 +# The released libs are built on 22.04; a lib built here has to load on a runtime +# with the same glibc or newer, not the other way round. +ARG UBUNTU_LIBS=22.04 +ARG GHC=9.6.3 +ARG CABAL=3.10.2.0 + +# --------------------------------------------------------------------------- # +# libsimplex — the cabal invocation of scripts/desktop/build-lib-linux.sh, which +# is what produces the .so the published libs archive is repackaged from. +# --------------------------------------------------------------------------- # +FROM ubuntu:${UBUNTU_LIBS} AS libsimplex + +ARG GHC +ARG CABAL +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl git libgmp3-dev libnuma-dev \ + libsqlite3-dev libssl-dev llvm pkg-config zlib1g-dev && \ + rm -rf /var/lib/apt/lists/* + +ENV BOOTSTRAP_HASKELL_NONINTERACTIVE=1 \ + BOOTSTRAP_HASKELL_GHC_VERSION=${GHC} \ + BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL} \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true \ + BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true +RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh +ENV PATH="/root/.ghcup/bin:/root/.cabal/bin:$PATH" +# Explicit, so the cache mount below is where cabal actually keeps its store. +ENV CABAL_DIR=/root/.cabal + +WORKDIR /src +COPY cabal.project simplex-chat.cabal README.md PRIVACY.md ./ +COPY scripts/cabal.project.local.linux ./cabal.project.local +COPY src ./src + +# Cache mounts, not layers: the Haskell store and the build tree survive a +# source change, which is the difference between minutes and hours. The RTS and +# package libraries are copied next to libsimplex.so because its rpath is $ORIGIN. +RUN --mount=type=cache,target=/root/.cabal \ + --mount=type=cache,target=/src/dist-newstyle \ + set -eu; \ + cabal update; \ + cabal build lib:simplex-chat \ + --ghc-options='-optl-Wl,-rpath,$ORIGIN -optl-Wl,-soname,libsimplex.so -flink-rts -threaded' \ + --constraint 'simplexmq +client_library' \ + --constraint 'simplex-chat +client_library'; \ + lib=$(ls -t /src/dist-newstyle/build/*/ghc-${GHC}/simplex-chat-*/build/libHSsimplex-chat-*-inplace-ghc${GHC}.so | head -1); \ + build_dir=$(dirname "$lib"); \ + mv "$lib" "$build_dir/libsimplex.so"; \ + mkdir -p /libs; \ + ldd "$build_dir/libsimplex.so" | grep ghc | cut -d' ' -f 3 | xargs -I {} cp {} /libs/; \ + cp "$build_dir/libsimplex.so" /libs/ + +# --------------------------------------------------------------------------- # +# the bot +# --------------------------------------------------------------------------- # +# libsimplex is a glibc build and will not load on musl, and it is compiled +# against this image's libraries in the stage above. +FROM ubuntu:${UBUNTU} + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl dumb-init libffi8 libgmp10 libnuma1 && \ + rm -rf /var/lib/apt/lists/* + +RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv + +# The ids that must own ./state on the host; a bind mount keeps host ownership. +# Build with your own to avoid needing root to read the bot's state: +# USER_UID=$(id -u) USER_GID=$(id -g) docker compose build +ARG USER_UID=1000 +ARG USER_GID=1000 + +# ubuntu:24.04 ships a default user at 1000, so drop whoever holds the ids. +RUN if existing_user=$(getent passwd ${USER_UID} | cut -d: -f1) && [ -n "${existing_user}" ]; then \ + userdel -r "${existing_user}" 2>/dev/null || userdel "${existing_user}"; \ + fi && \ + if existing_group=$(getent group ${USER_GID} | cut -d: -f1) && [ -n "${existing_group}" ]; then \ + groupdel "${existing_group}" 2>/dev/null || true; \ + fi && \ + groupadd -g ${USER_GID} supportbot && \ + useradd -u ${USER_UID} -g ${USER_GID} -m -d /home/supportbot supportbot + +# Applies only when /data is not bind-mounted; a bind mount keeps the host +# directory's ownership and mode. +RUN mkdir -p /data && chown supportbot:supportbot /data && chmod 0700 /data + +# Read by simplex_chat._native instead of downloading a release, which is what +# makes the bot run against the core built above rather than the published one. +COPY --from=libsimplex /libs /opt/simplex/libs +ENV SIMPLEX_LIBS_DIR=/opt/simplex/libs + +USER supportbot +WORKDIR /home/supportbot + +ENV VIRTUAL_ENV=/home/supportbot/.venv +ENV PATH="$VIRTUAL_ENV/bin:$PATH" + +# The library is installed from this tree: the APIs the bot uses +# (install_signal_handlers, sync_profile, api_merge_*_custom_data) are unreleased. +COPY --chown=supportbot:supportbot packages/simplex-chat-python /home/supportbot/simplex-chat-python +RUN uv venv --python 3.12 "$VIRTUAL_ENV" && \ + uv pip install /home/supportbot/simplex-chat-python + +COPY --chown=supportbot:supportbot apps/simplex-support-bot-light /home/supportbot/app +RUN uv pip install -e /home/supportbot/app + +ENV PYTHONUNBUFFERED=1 + +# No HEALTHCHECK: /health is published for an external monitor, and a container +# check would restart a bot whose chat controller is merely slow. + +# Exec form: shell form would run under `sh -c`, which does not forward SIGTERM +# to the bot, so the graceful-stop path in __main__.py would never fire. +ENTRYPOINT ["dumb-init", "--", "support-bot-light", "--config", "/etc/support-bot-light/config.toml"] diff --git a/apps/simplex-support-bot-light/Dockerfile.dockerignore b/apps/simplex-support-bot-light/Dockerfile.dockerignore new file mode 100644 index 0000000000..b27b4a7eb5 --- /dev/null +++ b/apps/simplex-support-bot-light/Dockerfile.dockerignore @@ -0,0 +1,40 @@ +# The build context is the repository root. Exclude everything, then add back +# what the image is built from, one directory level at a time. +* + +!cabal.project +!simplex-chat.cabal +!README.md +!PRIVACY.md +!src + +!scripts +scripts/* +!scripts/cabal.project.local.linux + +!packages +packages/* +!packages/simplex-chat-python + +!apps +apps/* +!apps/simplex-support-bot-light + +# Build output, caches, and anything holding an identity or a secret. +**/__pycache__ +**/*.py[cod] +**/*.egg-info +**/.venv +**/.venv-* +**/.pytest_cache +**/.ruff_cache +**/.pyright_cache +**/.mypy_cache +**/dist +**/*.db +**/*.db-* +apps/simplex-support-bot-light/plans +apps/simplex-support-bot-light/state +apps/simplex-support-bot-light/bot-config +apps/simplex-support-bot-light/config.toml +apps/simplex-support-bot-light/.env diff --git a/apps/simplex-support-bot-light/README.md b/apps/simplex-support-bot-light/README.md new file mode 100644 index 0000000000..058f539d3f --- /dev/null +++ b/apps/simplex-support-bot-light/README.md @@ -0,0 +1,148 @@ +# simplex-support-bot-light + +A [SimpleX Chat](https://simplex.chat) bot that adds a roster of people to +incoming business chats. + +Anyone who connects to the bot's address gets a business chat with a welcome +message, and every active roster member is added to it. People join the roster +themselves, from a command menu in a separate roster group. + +## Docker + +From `apps/simplex-support-bot-light`: + +```bash +cp bot-config/config.toml.example bot-config/config.toml # required; edit before starting +printf 'USER_UID=%s\nUSER_GID=%s\n' "$(id -u)" "$(id -g)" > .env # see Ownership +chmod 0700 state +docker compose up --build -d +docker compose logs -f support-bot-light +``` + +Use the template in `bot-config/`, whose paths are container paths, not the +top-level one. Place the avatar beside it if `bot.image` is set. + +| Path | Mount | Notes | +| --- | --- | --- | +| `./bot-config` | `/etc/support-bot-light` (read-only) | `bot.image` resolves against this directory. | +| `./state` | `/data` | All bot state. `bot.db_prefix` must point here. | + +The monitoring endpoint is published on `127.0.0.1:8080`, and the container +config must set `health.host = "0.0.0.0"`, as the template does. + +Run detached. Under an attached `docker compose up`, Ctrl+C stops the container +but compose re-attaches it; press Ctrl+C twice or use +`--abort-on-container-exit`. + +### State directory + +`./state` holds the bot's identity and address. Deleting it produces a new +address and a new roster group, and every roster member must repeat the +handshake. Back it up. + +It must be owned by the uid the container runs as, set in `.env`. Both ids +default to 1000; root is not supported. `chmod 0700` it on a shared host, since +the databases hold the bot's identity keys. + +## Manual installation + +```bash +uv venv && uv pip install -e ../../packages/simplex-chat-python && uv pip install -e '.[dev]' +cp config.toml.example config.toml +uv run support-bot-light --config config.toml +``` + +The library is installed from this repository, since the APIs the bot uses are +unreleased. `libsimplex` is downloaded on first use unless `SIMPLEX_LIBS_DIR` +points at a local build. + +`--config` defaults to `config.toml` in the working directory. `Ctrl+C` stops +the bot; a second `Ctrl+C` exits immediately. + +## Configuration + +`config.toml.example` is the committed template; `config.toml` is gitignored. + +| Key | Required | Description | +| --- | --- | --- | +| `bot.display_name` | yes | Name shown to anyone who connects. | +| `bot.image` | no | Profile image path (`.png`, `.jpg`, `.jpeg`). Relative paths resolve against the directory containing `config.toml`. The encoded image must not exceed 12500 characters, roughly a 128x128 avatar. | +| `bot.db_prefix` | yes | SQLite path prefix. Creates `_chat.db` and `_agent.db`. Under Docker it must point inside `/data`. | +| `bot.welcome` | yes | Message posted into each new business chat, sent as the address auto-reply. Multi-line TOML strings are supported. | +| `roster.group_name` | yes | Name of the roster group, applied when it is created. | +| `roster.member_role` | no | Role roster members receive in business chats: `observer`, `author`, `member`, `moderator`, `admin` or `owner`. Defaults to `owner`. | +| `health.enabled` | no | Set `false` to switch the monitoring endpoint off. On by default. | +| `health.host` | no | Interface the endpoint binds. Defaults to `127.0.0.1`; `0.0.0.0` under Docker. | +| `health.port` | no | Port for the endpoint. Defaults to `8080`. Setting either key makes a bind failure fatal. | + +Changing `bot.welcome` or `bot.image` applies on the next start. + +The first start logs two links: the business address, for customers, and the +roster group link, for people who should answer. Anyone who joins the roster +group can add themselves to every incoming chat. + +## Monitoring + +The bot serves `GET /health` unless `health.enabled` is `false`: + +| Status | Meaning | +| --- | --- | +| `200 {"status":"ok"}` | The core answered a query against the roster group. | +| `503 {"status":"unavailable"}` | It returned an error, or did not answer within 5 seconds. | + +A bot whose messaging servers are unreachable still answers `200`. + +There is no authentication. Bind it to `127.0.0.1`, or to an interface only the +monitoring system can reach. If `health.host` or `health.port` is set and the +address cannot be bound, the bot exits; otherwise a busy default port only logs +a warning. + +## Commands + +Available in the roster group. + +| Command | Effect | +| --- | --- | +| `/dm` | Join the roster. If the bot has no direct contact, it sends a contact request first; membership becomes active once that request is accepted. | +| `/list` | List active members, members who are no longer reachable, and those pending a contact request. | +| `/leave` | Leave the roster. Chats already joined are unaffected. | +| `/help` | Summarise the above. | + +Leaving the roster group, or being removed from it, also takes a member off the +roster. The bot is the group's only owner, so removing another member requires a +client signed in as the bot. + +## State + +All state is in the databases at `bot.db_prefix`. Roster membership is stored in +each contact's `custom_data`, and the roster group is found by a marker in the +group's `custom_data` rather than by name. + +Startup reconciles what downtime missed: acceptances that arrived while the bot +was stopped, members who left the roster group, and business chats left without +their roster members. + +## Development + +```bash +source .venv/bin/activate +ruff check && ruff format --check src tests && pyright && pytest tests/ -v +``` + +Scope `ruff format` to `src tests`. An unscoped run also reformats Python +fenced inside markdown files. + +## Limitations + +- Joining the roster never grants access to earlier conversations, including + chats a returning customer reopens. +- `bot.display_name` cannot be changed to a name any contact, group or past + customer already holds. The bot logs this and keeps its current name. +- Every active member is added to every incoming chat. There is no routing or + per-customer selection. +- There is no command to remove someone else from the roster, and `/leave` does + not remove anyone from chats they have already joined. + +## License + +[AGPL-3.0](../../LICENSE) diff --git a/apps/simplex-support-bot-light/bot-config/config.toml.example b/apps/simplex-support-bot-light/bot-config/config.toml.example new file mode 100644 index 0000000000..08228a47b3 --- /dev/null +++ b/apps/simplex-support-bot-light/bot-config/config.toml.example @@ -0,0 +1,30 @@ +# Copy to ./bot-config/config.toml. Paths here are container paths; use the +# top-level config.toml.example when running the bot directly on the host. + +[bot] +display_name = "Support" +# Optional. .png, .jpg or .jpeg, resolved against the directory holding this +# file. The encoded data URI must not exceed 12500 characters, roughly a +# 128x128 avatar. +# image = "./avatar.png" +# Must be under /data (bind-mounted from ./state). Creates _chat.db and +# _agent.db, which hold the bot's identity. +db_prefix = "/data/support_bot_light" +welcome = "Hi! Someone from the team will join this chat in a moment." + +[roster] +# Renaming the group later has no effect: it is found by a marker in its custom +# data, not by name. +group_name = "Invite roster" +# One of: observer, author, member, moderator, admin, owner. "relay" is also +# accepted by the core but is an infrastructure role. +member_role = "owner" + +# Monitoring endpoint: GET /health answers 200 while the chat controller +# responds to a command, 503 when it does not. It must bind 0.0.0.0 to be +# reachable through the published port; docker-compose.yml publishes it on the +# host loopback, because the endpoint has no authentication. +[health] +# enabled = false +host = "0.0.0.0" +port = 8080 diff --git a/apps/simplex-support-bot-light/config.toml.example b/apps/simplex-support-bot-light/config.toml.example new file mode 100644 index 0000000000..7c7b3e6942 --- /dev/null +++ b/apps/simplex-support-bot-light/config.toml.example @@ -0,0 +1,25 @@ +[bot] +display_name = "Support" +# Optional. .png, .jpg or .jpeg, resolved against the directory holding this +# file rather than the working directory. The encoded data URI must not exceed +# 12500 characters, roughly a 128x128 avatar. +# image = "./avatar.png" +# Creates _chat.db and _agent.db. +db_prefix = "./support_bot_light" +welcome = "Hi! Someone from the team will join this chat in a moment." + +[roster] +# Renaming the group later has no effect: it is found by a marker in its custom +# data, not by name. +group_name = "Invite roster" +# One of: observer, author, member, moderator, admin, owner. "relay" is also +# accepted by the core but is an infrastructure role. +member_role = "owner" + +# Monitoring endpoint: GET /health answers 200 while the chat controller +# responds to a command, 503 when it does not. On by default, at the values +# below. It has no authentication, so keep it off a public interface. +[health] +# enabled = false +host = "127.0.0.1" +port = 8080 diff --git a/apps/simplex-support-bot-light/docker-compose.yml b/apps/simplex-support-bot-light/docker-compose.yml new file mode 100644 index 0000000000..3928bf201b --- /dev/null +++ b/apps/simplex-support-bot-light/docker-compose.yml @@ -0,0 +1,27 @@ +services: + support-bot-light: + build: + # The repository root: the image is built from the Haskell core and the + # Python library in this tree, neither of them released. + context: ../.. + dockerfile: apps/simplex-support-bot-light/Dockerfile + args: + # Defaults to 1000. Set both to your own ids to own ./state yourself. + USER_UID: ${USER_UID:-1000} + USER_GID: ${USER_GID:-1000} + # Bounded on purpose. A config that will not load is not fixed by retrying, + # and an unbounded policy turns it into a log flood that also makes Ctrl+C + # wait out the grace period. Five is enough to ride out a transient fault. + restart: on-failure:5 + volumes: + # Directory, not a single file: bot.image resolves relative paths against + # the directory holding config.toml. + - ./bot-config:/etc/support-bot-light:ro + # Holds the bot's identity, address, roster group and roster. Deleting it + # means a new address and every roster member redoing the handshake. + - ./state:/data + stop_grace_period: 10s + ports: + # GET /health. Published on the host loopback because the endpoint has no + # authentication; widen it only for a monitoring system that needs it. + - "127.0.0.1:7777:8080" diff --git a/apps/simplex-support-bot-light/pyproject.toml b/apps/simplex-support-bot-light/pyproject.toml new file mode 100644 index 0000000000..5788403b66 --- /dev/null +++ b/apps/simplex-support-bot-light/pyproject.toml @@ -0,0 +1,35 @@ +[build-system] +requires = ["hatchling>=1.24"] +build-backend = "hatchling.build" + +[project] +name = "simplex-support-bot-light" +version = "0.1.0" +description = "SimpleX bot that adds a self-service roster to incoming business chats" +readme = "README.md" +license = "AGPL-3.0-only" +requires-python = ">=3.11" +dependencies = ["simplex-chat>=7.1.0b0"] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.23", "pyright>=1.1.380", "ruff>=0.6"] + +[project.scripts] +support-bot-light = "support_bot_light.__main__:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/support_bot_light"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.pyright] +venvPath = "." +venv = ".venv" +include = ["src/support_bot_light"] +exclude = ["**/__pycache__", "**/.venv*"] diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__init__.py b/apps/simplex-support-bot-light/src/support_bot_light/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/src/support_bot_light/__main__.py b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py new file mode 100644 index 0000000000..a7476643a2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/__main__.py @@ -0,0 +1,211 @@ +"""Entry point: load config, start the bot, wire handlers, serve.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import stat +import sys +from pathlib import Path + +from simplex_chat import Bot, BotProfile, ChatError, SqliteDb +from simplex_chat.core import ChatInitError +from simplex_chat.types import CEvt + +from . import business, commands, handlers, health, setup +from .config import Config, ConfigError, load_config +from .context import BotContext + +log = logging.getLogger("support_bot_light") + +# storeError tag the core returns when a display name is already in use. +DUPLICATE_NAME = "duplicateName" + +# ChatInitError is raised only while opening the database, which is why it +# belongs here rather than in the per-command guards elsewhere. +STARTUP_ERRORS = (ChatError, ChatInitError) + + +def _register(bot: Bot, ctx: BotContext) -> None: + """Register handlers. Commands are scoped to the roster group, so the same + keyword typed in a business chat falls through and is ignored.""" + group_id = ctx.roster_group_id + + @bot.on_command(commands.DM, group_id=group_id) + async def _dm(msg, _cmd): + await handlers.dm(ctx, msg) + + @bot.on_command(commands.LIST, group_id=group_id) + async def _list(msg, _cmd): + await handlers.list_roster(ctx, msg) + + @bot.on_command(commands.LEAVE, group_id=group_id) + async def _leave(msg, _cmd): + await handlers.leave(ctx, msg) + + @bot.on_command(commands.HELP, group_id=group_id) + async def _help(msg, _cmd): + await handlers.help_cmd(ctx, msg) + + @bot.on_event("acceptingBusinessRequest") + async def _business(evt: CEvt.AcceptingBusinessRequest): + await business.on_business_request(ctx, evt) + + @bot.on_event("contactConnected") + async def _connected(evt: CEvt.ContactConnected): + await handlers.contact_ready(ctx, evt["contact"]["contactId"]) + + @bot.on_event("contactSndReady") + async def _snd_ready(evt: CEvt.ContactSndReady): + await handlers.contact_ready(ctx, evt["contact"]["contactId"]) + + @bot.on_event("deletedMember") + async def _deleted_member(evt: CEvt.DeletedMember): + await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["deletedMember"]) + + @bot.on_event("leftMember") + async def _left_member(evt: CEvt.LeftMember): + await handlers.member_gone(ctx, evt["groupInfo"]["groupId"], evt["member"]) + + +def startup_error(e: Exception) -> str: + """What the operator can act on, from an exception that names only a tag. + + The core reports a display name already taken by a contact or group as a + bare `errorStore`, and the detail the bot needs is in the store error. + """ + if getattr(e, "store_error_type", None) == DUPLICATE_NAME: + return ( + "bot.display_name is already taken in this database by a contact, a " + "group or a past customer; the core keeps every display name unique. " + "Choose another name." + ) + command_error = getattr(e, "command_error", None) + if command_error is not None: + return command_error + chat_error = getattr(e, "chat_error", None) + return f"{e} {chat_error}" if chat_error else str(e) + + +def bot_profile(config: Config) -> BotProfile: + return BotProfile(display_name=config.display_name, image=config.image) + + +def build_bot(config: Config) -> Bot: + """The bot's identity and address settings. + + business_address is what makes a connection open a group the roster can be + added to; without it every customer would get a plain direct chat and the + bot would have nothing to do. + + The profile is applied after the client starts, not by the startup sync, so + that a name the core refuses does not stop the bot. See `_apply_profile`. + """ + return Bot( + profile=bot_profile(config), + db=SqliteDb(file_prefix=config.db_prefix), + welcome=config.welcome, + business_address=True, + auto_accept=True, + update_profile=False, + # The library logs peer display names verbatim; the bot sanitises every + # name it renders itself, and this is the one path that bypasses it. + log_contacts=False, + ) + + +async def _run(config: Config) -> None: + bot = build_bot(config) + # Before the client starts: a signal during migrations would otherwise hit + # the default disposition and kill the process mid-write. + bot.install_signal_handlers() + await _serve(config, bot) + + +async def _apply_profile(bot: Bot) -> None: + """Apply the configured profile once the database can be reached. + + The core refuses a display name another contact or group holds, and the + profile update broadcasts to every contact, so it is the startup step most + likely to fail. Answering customers matters more than a name or an avatar. + """ + try: + await bot.sync_profile() + except ChatError as e: + log.error("%s", startup_error(e)) + log.warning("Serving without applying the profile change.") + + +async def _serve(config: Config, bot: Bot) -> None: + # Not bot.run(): handlers are scoped with group_id=, which is unknown until + # the roster group is resolved after start. + async with bot: + user = await bot.api.api_get_active_user() + if user is None: + raise RuntimeError("no active user after start") + user_id = user["userId"] + await _apply_profile(bot) + roster_group_id = await setup.ensure_roster_group(bot.api, user_id, config) + ctx = BotContext( + api=bot.api, + user_id=user_id, + roster_group_id=roster_group_id, + config=config, + ) + _register(bot, ctx) + groups = await bot.api.api_list_groups(user_id) + await handlers.reconcile_roster(ctx, groups) + await business.reconcile_chats(ctx, groups) + + if bot.stop_requested: + # A signal arrived during startup; unwind rather than begin serving. + log.info("stopped during startup") + return + + server = await health.serve(ctx, config.health) if config.health else None + try: + await bot.serve_forever() + finally: + if server is not None: + server.close() + await server.wait_closed() + + +def main() -> int: + parser = argparse.ArgumentParser(prog="support-bot-light") + parser.add_argument("--config", type=Path, default=Path("config.toml")) + args = parser.parse_args() + + if not logging.getLogger().handlers: + logging.basicConfig( + level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s" + ) + # The core creates its databases with the process umask, and they hold the + # bot's identity keys. Docker mounts a 0700 directory; a manual install + # would otherwise put them in the working directory at 0644. + os.umask(stat.S_IRWXG | stat.S_IRWXO) + + try: + config = load_config(args.config) + except ConfigError as e: + log.error("%s", e) + return 2 + try: + asyncio.run(_run(config)) + except ConfigError as e: + # Raised past load_config only by the health endpoint, which cannot know + # its port is taken until it binds. + log.error("%s", e) + return 2 + except STARTUP_ERRORS as e: + # Startup rejections the core only reports at first use, such as a + # database it will not open. + log.error("%s", startup_error(e)) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/business.py b/apps/simplex-support-bot-light/src/support_bot_light/business.py new file mode 100644 index 0000000000..9e95c0cff2 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/business.py @@ -0,0 +1,180 @@ +"""Incoming business chats: add the roster, log the invite.""" + +from __future__ import annotations + +import logging + +from simplex_chat import ChatError +from simplex_chat.types import CEvt, T + +from . import messages, roster +from .context import BotContext +from .text import safe_name + +log = logging.getLogger(__name__) + +# Written to a business chat's custom data once its roster pass has run. +ROSTERED = "rostered" + + +def _rostered(group: T.GroupInfo) -> bool: + mark = (group.get("customData") or {}).get(roster.NAMESPACE) + return isinstance(mark, dict) and mark.get(ROSTERED) is True + + +async def _mark_rostered(ctx: BotContext, group: T.GroupInfo) -> None: + """Record that this chat has had its roster pass, preserving other keys. + + Without this, startup repair cannot tell a chat a crash left half-finished + from one that was completed before the roster changed — and would add + people who joined the roster later to every conversation the bot has ever + handled. + """ + existing = (group.get("customData") or {}).get(roster.NAMESPACE) + mark: dict[str, object] = dict(existing) if isinstance(existing, dict) else {} + mark[ROSTERED] = True + await ctx.api.api_merge_group_custom_data(group, roster.NAMESPACE, mark) + + +async def _mark(ctx: BotContext, group: T.GroupInfo) -> None: + """Mark the chat, containing the failure: the next start re-derives it.""" + try: + await _mark_rostered(ctx, group) + except ChatError: + log.warning("could not mark business chat %s as rostered", group["groupId"], exc_info=True) + + +async def _add_missing( + ctx: BotContext, group_id: int, entries: list[roster.RosterEntry] +) -> tuple[list[str], list[str]]: + """Add every entry not already in the group. Returns (added, failed).""" + present = await roster.contact_ids_in_group(ctx.api, group_id) + + added: list[str] = [] + failed: list[str] = [] + for entry in entries: + if entry.contact_id in present: + continue + try: + # Final role in one call: promoting a pending invitee re-sends the + # invitation. + await ctx.api.api_add_member(group_id, entry.contact_id, ctx.config.member_role) + added.append(entry.name) + except ChatError: + log.exception("failed adding %s to business chat %s", entry.name, group_id) + failed.append(entry.name) + return added, failed + + +async def _roster_for_chats(ctx: BotContext) -> list[roster.RosterEntry]: + """Active roster members who are still in the roster group. + + Revocation is driven by an event, and the core delivers a queued business + request before a queued departure just as readily as after it, so the mark + alone would let somebody who has left read a conversation started after they + went. Membership of the roster group is the access-control boundary, so it + is what decides: read for each incoming chat, and once per startup pass. + """ + entries = await roster.active(ctx.api, ctx.user_id) + if not entries: + return [] + present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id) + return [e for e in entries if e.contact_id in present] + + +async def reconcile_chats(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None: + """Add active roster members to business chats that are missing them. + + Adding members is the only step with no second chance: it is driven by an + event delivered once, so a crash part-way through the loop would leave that + customer permanently short of the roster. + + Only chats whose roster pass never completed are touched. A chat that was + finished before someone joined the roster is left alone: `/dm` promises to + add you to chats "from now on", and back-filling would hand every past + customer conversation to whoever joined the roster most recently. + """ + try: + entries = await _roster_for_chats(ctx) + if groups is None: + groups = await ctx.api.api_list_groups(ctx.user_id) + except ChatError: + log.warning("could not reconcile business chats on startup", exc_info=True) + return + + repaired = 0 + for group in groups: + if "businessChat" not in group or not roster.in_group(group["membership"]): + continue + if _rostered(group): + continue + group_id = group["groupId"] + try: + added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries) + except ChatError: + log.warning("could not reconcile business chat %s", group_id, exc_info=True) + continue + repaired += 1 + log.info("finished the roster pass for business chat %s on startup", group_id) + # Reported even when nobody had to be added: the chat was left unmarked, + # so the crash took the roster group's record of that customer with it. + await ctx.post_to_roster(_report(_customer_of(group), entries, added, failed)) + if added or not failed: + # Left unmarked means the repair did not finish; the queued event + # should be allowed to retry it in this session. + ctx.repaired.add(group_id) + # Marked even with an empty roster: the pass has run for this chat, + # and leaving it unmarked would back-fill whoever joins later. + await _mark(ctx, group) + if repaired: + log.info("finished %d business chats left incomplete by a restart", repaired) + + +def _report( + customer: str, entries: list[roster.RosterEntry], added: list[str], failed: list[str] +) -> str: + """The roster group's record of one business chat.""" + if not entries: + return messages.EMPTY_ROSTER_LOG.format(customer=customer) + if not added and not failed: + return messages.NOBODY_NEW_LOG.format(customer=customer) + return messages.invite_log(customer, added, failed) + + +def _customer_of(group: T.GroupInfo) -> str: + return safe_name((group.get("groupProfile") or {}).get("displayName") or "") + + +async def on_business_request(ctx: BotContext, evt: CEvt.AcceptingBusinessRequest) -> None: + """Add every active roster member to a new business chat, then log it.""" + group = evt["groupInfo"] + group_id = group["groupId"] + if group_id in ctx.repaired: + # Startup repair already ran for this chat and reported it; the queued + # event would otherwise log the same customer a second time. + ctx.repaired.discard(group_id) + return + # For a business chat the group's display name is the customer's own + # profile string, which the core does not sanitise. + customer = _customer_of(group) + + # A failure before anything is added must still reach the roster group, + # which is the operator's only visibility. + try: + entries = await _roster_for_chats(ctx) + added, failed = ([], []) if not entries else await _add_missing(ctx, group_id, entries) + except ChatError: + log.exception("failed reading roster for business chat %s", group_id) + await ctx.post_to_roster(messages.BUSINESS_FAILED_LOG.format(customer=customer)) + return + + await ctx.post_to_roster(_report(customer, entries, added, failed)) + + # Marked even when the line above failed to send. The marker records that + # the pass ran, and an unmarked chat is repaired by every later start with + # the roster of the day — so withholding it to preserve one log line would + # hand a past customer's conversation to whoever joins the roster next. + # Not marked when every add failed and none succeeded: that chat has no + # roster at all, so the next start should retry rather than skip it. + if added or not failed: + await _mark(ctx, group) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/commands.py b/apps/simplex-support-bot-light/src/support_bot_light/commands.py new file mode 100644 index 0000000000..c081a40e37 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/commands.py @@ -0,0 +1,37 @@ +"""The bot's command menu: declarations plus conversion to group-preference wire dicts.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from simplex_chat import BotCommand +from simplex_chat.types import T + +DM = "dm" +LIST = "list" +LEAVE = "leave" +HELP = "help" + +COMMANDS: tuple[BotCommand, ...] = ( + BotCommand(keyword=DM, label="Add me to incoming chats"), + BotCommand(keyword=LIST, label="Who gets invited"), + BotCommand(keyword=LEAVE, label="Stop adding me"), + BotCommand(keyword=HELP, label="How this works"), +) + + +def to_wire(commands: Sequence[BotCommand]) -> list[T.ChatBotCommand]: + """Convert declarations to `groupPreferences.commands` entries.""" + wire: list[T.ChatBotCommand] = [] + for c in commands: + entry: T.ChatBotCommand_command = { + "type": "command", + "keyword": c.keyword, + "label": c.label, + } + # Omitted rather than empty: the client sends on tap for Nothing, but + # pastes for Just "". + if c.params is not None: + entry["params"] = c.params + wire.append(entry) + return wire diff --git a/apps/simplex-support-bot-light/src/support_bot_light/config.py b/apps/simplex-support-bot-light/src/support_bot_light/config.py new file mode 100644 index 0000000000..79dd3bfefb --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/config.py @@ -0,0 +1,207 @@ +"""Load and validate `config.toml`.""" + +from __future__ import annotations + +import base64 +import stat +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Any, get_args + +from simplex_chat.types import T + +DEFAULT_MEMBER_ROLE: T.GroupMemberRole = "owner" +MEMBER_ROLES: tuple[str, ...] = get_args(T.GroupMemberRole) + +# maxProfileImageSize in src/Simplex/Chat/Library/Commands.hs. Measured against +# the whole data URI, not the raw file. +MAX_PROFILE_IMAGE_SIZE = 12500 + +# Raw bytes that still fit once base64 and the "data:image/png;base64," prefix +# are added. Checked before the file is read. +MAX_IMAGE_BYTES = (MAX_PROFILE_IMAGE_SIZE - 22) // 4 * 3 + +# The welcome is sent as a chat message, so it is held below the core's wire +# limit (maxEncodedMsgLength) with room to spare rather than at it. +MAX_WELCOME_BYTES = 12000 + +# On unless switched off, so a deployment is monitorable without being +# configured for it. Loopback, because the endpoint has no authentication. +DEFAULT_HEALTH_HOST = "127.0.0.1" +DEFAULT_HEALTH_PORT = 8080 +MAX_PORT = 65535 + +# Tag in the data:image/;base64, prefix. The core accepts any "data:" string; +# the clients strip only the png and jpg prefixes, so jpeg renders as nothing. +IMAGE_EXTENSION_TAGS = {".png": "png", ".jpg": "jpg", ".jpeg": "jpg"} + + +class ConfigError(ValueError): + """`config.toml` is missing, malformed, or has an invalid value.""" + + +@dataclass(frozen=True, slots=True) +class Health: + """Where the monitoring endpoint listens.""" + + host: str + port: int + # True when the config names the port. A port the operator chose has to + # work; the default must never be what keeps the bot from starting. + configured: bool = False + + +@dataclass(frozen=True, slots=True) +class Config: + """Validated settings loaded from `config.toml`.""" + + display_name: str + db_prefix: str + welcome: str + group_name: str + member_role: T.GroupMemberRole + image: str | None = None + health: Health | None = None + + +def load_config(path: Path) -> Config: + """Read and validate `config.toml` at `path`, raising `ConfigError` on any problem.""" + try: + raw = tomllib.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as e: + # Names, not a command: under Docker this directory is mounted + # read-only, so the copy is made on the host. + template = path.with_name(path.name + ".example") + hint = f" — copy {template.name} to {path.name} and edit it" if template.exists() else "" + raise ConfigError(f"config file not found: {path}{hint}") from e + except tomllib.TOMLDecodeError as e: + raise ConfigError(f"invalid TOML in {path}: {e}") from e + except UnicodeDecodeError as e: + raise ConfigError(f"config file is not UTF-8: {path}") from e + except OSError as e: + raise ConfigError(f"config file could not be read ({path}): {e}") from e + + bot = _section(raw, "bot") + roster = _section(raw, "roster") + role = roster.get("member_role", DEFAULT_MEMBER_ROLE) + if role not in MEMBER_ROLES: + raise ConfigError( + f"roster.member_role must be one of {', '.join(MEMBER_ROLES)}, got {role!r}" + ) + return Config( + display_name=_text(bot, "bot", "display_name"), + db_prefix=_text(bot, "bot", "db_prefix"), + welcome=_bounded_text(bot, "bot", "welcome", MAX_WELCOME_BYTES), + group_name=_text(roster, "roster", "group_name"), + member_role=role, + image=_image(bot, path.parent), + health=_health(raw), + ) + + +def _health(raw: dict[str, Any]) -> Health | None: + """Where the endpoint listens, or None when `health.enabled` switches it off.""" + health = raw.get("health", {}) + if not isinstance(health, dict): + raise ConfigError("[health] must be a section") + enabled = health.get("enabled", True) + if not isinstance(enabled, bool): + raise ConfigError(f"health.enabled must be true or false, got {enabled!r}") + if not enabled: + return None + port = health.get("port", DEFAULT_HEALTH_PORT) + # bool is an int, and TOML has booleans. + if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= MAX_PORT: + raise ConfigError(f"health.port must be an integer between 1 and {MAX_PORT}, got {port!r}") + host = health.get("host", DEFAULT_HEALTH_HOST) + if not isinstance(host, str) or not host.strip(): + raise ConfigError("health.host must be a non-empty string") + # Either key means the operator chose where it listens, and a bind failure + # there is a misconfiguration rather than a coincidence. + return Health(host=host, port=port, configured=bool({"host", "port"} & health.keys())) + + +def _section(raw: dict[str, Any], name: str) -> dict[str, Any]: + section = raw.get(name) + if not isinstance(section, dict): + raise ConfigError(f"missing [{name}] section") + return section + + +def _text(section: dict[str, Any], section_name: str, key: str) -> str: + value = section.get(key) + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{section_name}.{key} must be a non-empty string") + return value + + +def _bounded_text(section: dict[str, Any], section_name: str, key: str, max_bytes: int) -> str: + value = _text(section, section_name, key) + if len(value.encode()) > max_bytes: + raise ConfigError( + f"{section_name}.{key} is too long: {len(value.encode())} bytes exceeds " + f"the {max_bytes} the core will send; shorten it" + ) + return value + + +def _image(bot: dict[str, Any], config_dir: Path) -> str | None: + """Encode `bot.image` (a file path) as a profile-image data URI, or `None` + if the key is absent. Relative paths resolve against `config_dir` — the + directory containing `config.toml` — not the process's working directory. + """ + if "image" not in bot: + return None + value = _text(bot, "bot", "image") + + image_path = Path(value) + if not image_path.is_absolute(): + image_path = config_dir / image_path + + extension = image_path.suffix.lower() + tag = IMAGE_EXTENSION_TAGS.get(extension) + if tag is None: + supported = ", ".join(sorted(IMAGE_EXTENSION_TAGS)) + raise ConfigError( + f"bot.image has unsupported extension {extension!r} ({image_path}); " + f"supported extensions: {supported}" + ) + + # Inspect before reading: a FIFO would block startup indefinitely and a + # character device such as /dev/zero would exhaust memory. + try: + info = image_path.stat() + except FileNotFoundError as e: + raise ConfigError(f"bot.image file not found: {image_path}") from e + except OSError as e: + raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e + + if not stat.S_ISREG(info.st_mode): + raise ConfigError(f"bot.image is not a regular file: {image_path}") + if info.st_size > MAX_IMAGE_BYTES: + raise ConfigError( + f"bot.image is too large: {info.st_size} bytes exceeds the {MAX_IMAGE_BYTES} " + f"a {MAX_PROFILE_IMAGE_SIZE}-character data URI can hold; shrink the image " + "(a 128x128 avatar) and try again" + ) + + try: + data = image_path.read_bytes() + except OSError as e: + raise ConfigError(f"bot.image could not be read ({image_path}): {e}") from e + + # The core rejects an empty image file rather than broadcasting a profile + # with an undecodable data URI. + if not data: + raise ConfigError(f"bot.image file is empty: {image_path}") + + encoded = base64.b64encode(data).decode("ascii") + data_uri = f"data:image/{tag};base64,{encoded}" + if len(data_uri) > MAX_PROFILE_IMAGE_SIZE: + raise ConfigError( + f"bot.image is too large: encoded size {len(data_uri)} exceeds the " + f"{MAX_PROFILE_IMAGE_SIZE}-character limit the core enforces on profile " + "images; shrink the image (e.g. to a 96x96 or 128x128 avatar) and try again" + ) + return data_uri diff --git a/apps/simplex-support-bot-light/src/support_bot_light/context.py b/apps/simplex-support-bot-light/src/support_bot_light/context.py new file mode 100644 index 0000000000..d168763557 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/context.py @@ -0,0 +1,36 @@ +"""Everything the handlers need, resolved once at startup.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from simplex_chat import ChatApi, ChatError + +from .config import Config + +log = logging.getLogger(__name__) + + +@dataclass(slots=True) +class BotContext: + """API handle plus the ids and config resolved during startup.""" + + api: ChatApi + user_id: int + roster_group_id: int + config: Config + # Business chats repaired by the startup pass, so the queued event for the + # same chat does not report the customer a second time. + repaired: set[int] = field(default_factory=set) + + async def post_to_roster(self, text: str) -> None: + """Send a message to the roster group. + + Never raises: this is the operator's visibility channel, and a failure + to report an event must not also discard the event that caused it. + """ + try: + await self.api.api_send_text_message(["group", self.roster_group_id], text) + except ChatError: + log.warning("could not post to the roster group: %s", text, exc_info=True) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/handlers.py b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py new file mode 100644 index 0000000000..6870c57126 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/handlers.py @@ -0,0 +1,300 @@ +"""Command, connection and membership handlers, plus the roster catch-up pass.""" + +from __future__ import annotations + +import functools +import logging +from collections.abc import Awaitable, Callable + +from simplex_chat import ChatError, Message +from simplex_chat.types import T + +from . import messages, roster, setup +from .context import BotContext +from .text import safe_name + +log = logging.getLogger(__name__) + + +def _reply_on_error(fn: Callable[[BotContext, Message], Awaitable[None]]): + """Turn a failed command into a visible reply instead of silence.""" + + @functools.wraps(fn) + async def wrapper(ctx: BotContext, msg: Message) -> None: + try: + await fn(ctx, msg) + except ChatError: + log.exception("%s failed", fn.__name__) + await msg.reply(messages.COMMAND_FAILED) + + return wrapper + + +async def _contact_of(ctx: BotContext, member: T.GroupMember) -> T.Contact | None: + """The sender's direct contact, resolved against current state. + + `memberContactId` in the message payload is a snapshot taken when the core + built the chat item. `api_create_member_contact` sets that column, so two + commands sent in quick succession both carry the pre-`/dm` value of `None` + and would otherwise be treated as having no contact at all. + """ + contact_id = member.get("memberContactId") + if contact_id is None: + for m in await ctx.api.api_list_members(ctx.roster_group_id): + if m["groupMemberId"] == member["groupMemberId"]: + contact_id = m.get("memberContactId") + break + if contact_id is None: + return None + return await roster.find_contact(ctx.api, ctx.user_id, contact_id) + + +def group_sender(msg: Message) -> T.GroupMember | None: + """The member who sent a group message, or None if it isn't a group receive.""" + chat_dir = msg.chat_item["chatItem"]["chatDir"] + if chat_dir.get("type") != "groupRcv": + return None + return chat_dir.get("groupMember") + + +@_reply_on_error +async def dm(ctx: BotContext, msg: Message) -> None: + """Put the sender on the roster, sending a contact request first if needed.""" + member = group_sender(msg) + if member is None: + return + + contact = await _contact_of(ctx, member) + + if contact is not None: + # api_create_member_contact sets memberContactId before the person has + # accepted, so an existing contact is not necessarily usable. + entry = roster.entry_of(contact) + + if roster.contact_usable(contact): + if entry is not None and entry.state == roster.ACTIVE: + await msg.reply(messages.ALREADY_ACTIVE) + return + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.ACTIVE, since) + await msg.reply(messages.ADDED) + # Every other route to active announces; without this the operator's + # log misses arrivals that took the fast path. + name = roster.contact_name(contact) + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=name)) + return + + if contact.get("contactGroupMemberId") is None: + if roster.accept_started(contact): + # Already accepted; the connection is still completing. Marked + # here too: contact_ready promotes a pending mark and does + # nothing without one, so ACCEPTING would promise a roster place + # that never arrives. + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.ACCEPTING) + return + + if roster.awaiting_accept(contact): + # They connected to us from the group rather than accepting our + # request. Accept it and mark them pending; contactConnected + # then promotes them exactly as it would the other way round. + await ctx.api.api_accept_member_contact(contact["contactId"]) + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.ACCEPTING) + return + + if roster.connecting(contact): + # The core clears contactGroupMemberId when the peer accepts, + # well before the connection reports ready, so this shape is + # also a handshake in progress. Reporting it as gone would send + # the member to CONNECTION_LOST's advice, and connecting + # directly there tears down the connection that was completing. + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + await msg.reply(messages.CONNECTING) + return + + # The core clears this once a member contact has connected, and + # api_send_member_contact_invitation requires it, so the handshake + # cannot be re-driven from this side. The mark is left alone: an + # active one renders under "Not reachable", which is the truth. + await msg.reply(messages.CONNECTION_LOST) + return + + # Reaching here means contactGroupMemberId is still set, which the core + # clears on connect: the person never completed the handshake, so an + # active mark is stale. + if entry is None or entry.state != roster.PENDING: + since = entry.since if entry else roster.utc_now() + await roster.mark(ctx.api, contact, roster.PENDING, since) + + if contact.get("contactGrpInvSent"): + # The core rejects a second invitation; the person has simply not + # accepted the first one yet. + await msg.reply(messages.STILL_PENDING) + return + + # First send failed. api_create_member_contact would raise "member + # contact already exists", so resend on the existing contact. + try: + await ctx.api.api_send_member_contact_invitation( + contact["contactId"], messages.INVITATION_TEXT + ) + except ChatError: + log.warning("invitation resend to contact %s failed", contact["contactId"]) + await msg.reply(messages.INVITATION_FAILED) + return + await msg.reply(messages.INVITATION_SENT) + return + + contact = await ctx.api.api_create_member_contact(ctx.roster_group_id, member["groupMemberId"]) + await roster.mark(ctx.api, contact, roster.PENDING, roster.utc_now()) + new_contact_id = contact["contactId"] + try: + await ctx.api.api_send_member_contact_invitation(new_contact_id, messages.INVITATION_TEXT) + except ChatError: + log.warning("invitation to contact %s failed to send", new_contact_id) + await msg.reply(messages.INVITATION_FAILED) + return + await msg.reply(messages.INVITATION_SENT) + + +async def contact_ready(ctx: BotContext, contact_id: int) -> None: + """Promote a pending contact once its connection is usable. + + Shared by contactConnected and contactSndReady. Re-reads the contact rather + than trusting the event payload. + """ + try: + contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id) + if contact is None: + return + entry = roster.entry_of(contact) + if entry is None or entry.state != roster.PENDING: + return + if not entry.reachable: + # The event says the connection is up, but the record is what + # `active()` will consult, so promote only on what it will see. + return + await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since) + except ChatError: + # Nobody is waiting on a reply here, so without this the failure is a + # bare traceback from the library and the person is stranded pending. + log.warning("could not promote contact %s", contact_id, exc_info=True) + return + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name)) + + +async def reconcile_roster(ctx: BotContext, groups: list[T.GroupInfo] | None = None) -> None: + """Catch up on what happened while the bot was stopped. + + Both events this compensates for are delivered once and never replayed: an + acceptance (`contactConnected`) leaves someone stuck pending, and a removal + from the roster group leaves someone on the roster who should not be. + + `groups` is passed in by startup so the two passes share one listing, which + is the largest thing startup reads and grows with every customer ever seen. + """ + try: + present = await roster.contact_ids_in_group(ctx.api, ctx.roster_group_id) + contacts = await ctx.api.api_list_contacts(ctx.user_id) + if groups is None: + groups = await ctx.api.api_list_groups(ctx.user_id) + except ChatError: + # Startup must not fail because the catch-up pass could not run. + log.warning("could not reconcile the roster on startup", exc_info=True) + return + + # Revocation deletes the bot's only durable state, so it runs only when the + # roster group is unambiguous. An empty member list is deliberately NOT a + # reason to skip: the last member leaving is when revoking matters most. + marked = sum(1 for g in groups if setup.is_roster_group(g)) + revoke = marked == 1 + if not revoke: + log.warning("%d groups carry the roster marker; skipping revocation", marked) + + for contact in contacts: + entry = roster.entry_of(contact) + if entry is None: + continue + try: + if revoke and entry.contact_id not in present: + await roster.unmark(ctx.api, contact) + log.info("removed %s from the roster: no longer in the roster group", entry.name) + await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name)) + elif entry.state == roster.PENDING and entry.reachable: + await roster.mark(ctx.api, contact, roster.ACTIVE, entry.since) + log.info("promoted %s on startup: their connection is ready", entry.name) + await ctx.post_to_roster(messages.NOW_ACTIVE.format(name=entry.name)) + except ChatError: + # One bad contact must not abandon the rest of the pass. + log.warning("could not reconcile contact %s", entry.contact_id, exc_info=True) + + +def _member_name(member: T.GroupMember) -> str: + return member.get("localDisplayName") or (member.get("memberProfile") or {}).get( + "displayName", "" + ) + + +async def member_gone(ctx: BotContext, group_id: int, member: T.GroupMember) -> None: + """Take someone off the roster when they leave or are removed from the group. + + Membership of the roster group is the access-control boundary, so it has to + be revocable: without this, someone removed from the group keeps being added + to every business chat and cannot even run `/leave` to stop it. + """ + if group_id != ctx.roster_group_id: + return + contact_id = member.get("memberContactId") + if contact_id is None: + return + try: + contact = await roster.find_contact(ctx.api, ctx.user_id, contact_id) + if contact is None: + return + entry = roster.entry_of(contact) + if entry is None: + return + await roster.unmark(ctx.api, contact) + except ChatError: + # The only failure in the bot that the roster group would not hear + # about, and it is the one on the access-control path. Access is not at + # risk — every add re-reads roster group membership — but the operator + # is owed the mark still being there until the next start repairs it. + log.warning("could not take contact %s off the roster", contact_id, exc_info=True) + await ctx.post_to_roster( + messages.REVOKE_FAILED.format(name=safe_name(_member_name(member))) + ) + return + log.info("removed %s from the roster: no longer in the roster group", entry.name) + await ctx.post_to_roster(messages.REMOVED_FROM_GROUP.format(name=entry.name)) + + +@_reply_on_error +async def list_roster(ctx: BotContext, msg: Message) -> None: + """Reply with the roster, active and pending.""" + entries = await roster.load(ctx.api, ctx.user_id) + await msg.reply(messages.render_roster(entries)) + + +@_reply_on_error +async def leave(ctx: BotContext, msg: Message) -> None: + """Take the sender off the roster, keeping the direct contact.""" + member = group_sender(msg) + if member is None: + return + contact = await _contact_of(ctx, member) + if contact is None or roster.entry_of(contact) is None: + await msg.reply(messages.NOT_ON_ROSTER) + return + await roster.unmark(ctx.api, contact) + await msg.reply(messages.LEFT) + + +@_reply_on_error +async def help_cmd(ctx: BotContext, msg: Message) -> None: + """Reply with the help text.""" + await msg.reply(messages.HELP) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/health.py b/apps/simplex-support-bot-light/src/support_bot_light/health.py new file mode 100644 index 0000000000..dbef16306e --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/health.py @@ -0,0 +1,166 @@ +"""Optional HTTP endpoint reporting whether the core still answers.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging + +from simplex_chat import ChatError + +from .config import ConfigError, Health +from .context import BotContext + +log = logging.getLogger(__name__) + +PATH = "/health" + +# The probe issues a real command, so it has to give up before the monitor does. +PROBE_TIMEOUT = 5.0 + +# Larger than any request a monitor sends, and the cap on what is read. +MAX_REQUEST_BYTES = 4096 +READ_TIMEOUT = 5.0 + + +def _head(status: str, length: int) -> bytes: + return ( + f"HTTP/1.1 {status}\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {length}\r\n" + "Connection: close\r\n\r\n" + ).encode() + + +def _response(status: str, payload: str) -> tuple[bytes, bytes]: + """(head, body). HEAD answers with the head alone, as HTTP requires.""" + body = f'{{"status":"{payload}"}}\n'.encode() + return _head(status, len(body)), body + + +OK = _response("200 OK", "ok") +UNAVAILABLE = _response("503 Service Unavailable", "unavailable") +NOT_FOUND = _response("404 Not Found", "not found") +NOT_ALLOWED = _response("405 Method Not Allowed", "method not allowed") +BAD_REQUEST = _response("400 Bad Request", "bad request") + + +class Probe: + """One outstanding query at a time, however often the endpoint is polled. + + `asyncio.wait_for` bounds the wait, not the work: the FFI call it abandons + keeps a worker thread in the loop's default executor until the core answers. + Starting a fresh one per poll would exhaust that executor — as few as six + threads on a small container — and the receive loop reads events through the + same executor, so a stalled core would take the bot's own traffic down with + it. The task is therefore reused rather than replaced, and never cancelled. + """ + + def __init__(self, ctx: BotContext) -> None: + self._ctx = ctx + self._task: asyncio.Task[bool] | None = None + + async def check(self) -> bool: + """Whether the core answered within PROBE_TIMEOUT.""" + task = self._task + if task is None or task.done(): + task = asyncio.create_task(self._query()) + self._task = task + done, _pending = await asyncio.wait({task}, timeout=PROBE_TIMEOUT) + if not done: + log.warning("health probe still waiting after %ss", PROBE_TIMEOUT) + return False + return task.result() + + async def _query(self) -> bool: + """Query the roster group. Never raises, whatever the core does. + + Reaching the process proves only that the event loop runs. This reads + the database, so it also waits on the store lock every other operation + takes — unlike `/u`, which the core answers from memory and which would + report healthy while a transaction was wedged. It stays small: the + roster group holds the people who answer, not customers. + """ + try: + await self._ctx.api.api_list_members(self._ctx.roster_group_id) + except ChatError: + log.warning("health probe failed", exc_info=True) + return False + except Exception: + # A malformed reply or a controller that is gone are exactly what + # this endpoint exists to report, and both arrive as something other + # than a chat error. + log.warning("health probe could not reach the core", exc_info=True) + return False + return True + + +async def _handle( + probe: Probe, + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, +) -> None: + try: + try: + line = await asyncio.wait_for(reader.readline(), READ_TIMEOUT) + except ValueError: + # Longer than MAX_REQUEST_BYTES: answered rather than dropped, so a + # monitor sees a reason. + _write(writer, BAD_REQUEST, body=True) + await writer.drain() + return + + request = line.decode("latin-1").split() + method = request[0] if request else "" + if len(request) < 2 or request[1].split("?")[0] != PATH: + _write(writer, NOT_FOUND, body=True) + elif method not in ("GET", "HEAD"): + _write(writer, NOT_ALLOWED, body=True) + else: + _write(writer, OK if await probe.check() else UNAVAILABLE, body=method == "GET") + await writer.drain() + except (TimeoutError, OSError): + # A client that stopped sending, or went away mid-response. + log.debug("health request dropped", exc_info=True) + finally: + writer.close() + with contextlib.suppress(OSError): + await writer.wait_closed() + + +def _write(writer: asyncio.StreamWriter, response: tuple[bytes, bytes], body: bool) -> None: + head, payload = response + writer.write(head + payload if body else head) + + +async def serve(ctx: BotContext, config: Health) -> asyncio.Server | None: + """Start the endpoint, or None when the default port is already taken. + + A port the config names has to work: monitoring that silently failed to + listen reads as health. The default port is different — nothing about it was + asked for, so an unrelated service on it must not keep the bot from running. + """ + probe = Probe(ctx) + + async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None: + await _handle(probe, reader, writer) + + try: + server = await asyncio.start_server( + handle, config.host, config.port, limit=MAX_REQUEST_BYTES + ) + except OSError as e: + if config.configured: + raise ConfigError( + f"health endpoint cannot listen on {config.host}:{config.port}: {e}" + ) from e + log.warning( + "No health endpoint: the default %s:%s could not be bound (%s). Set " + "health.host or health.port, or health.enabled = false.", + config.host, + config.port, + e, + ) + return None + log.info("Health endpoint: http://%s:%s%s", config.host, config.port, PATH) + return server diff --git a/apps/simplex-support-bot-light/src/support_bot_light/messages.py b/apps/simplex-support-bot-light/src/support_bot_light/messages.py new file mode 100644 index 0000000000..f6e136e7ba --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/messages.py @@ -0,0 +1,113 @@ +"""Every user-visible string, and roster rendering.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from .roster import ACTIVE, RosterEntry + +ADDED = "You are on the roster. You will be added to new chats." +ALREADY_ACTIVE = "You are already on the roster." +INVITATION_SENT = "Contact request sent. Accept it to join the roster." +STILL_PENDING = ( + "Contact request not accepted yet. If you declined it, leave this group, " + "join it again with the link, then run /dm." +) +INVITATION_FAILED = "Contact request could not be sent. Run /dm again." +ACCEPTING = "Accepting the connection you started. You will be on the roster shortly." +CONNECTING = "The connection is still completing. You will be on the roster shortly." +CONNECTION_LOST = ( + "The direct connection is gone, so I cannot add you to chats. Open my " + "profile in this group, connect directly, then run /dm." +) +INVITATION_TEXT = "Accept this contact request to be added to incoming chats. Keep the contact." +NOW_ACTIVE = "Now on the roster: {name}" +REMOVED_FROM_GROUP = "Off the roster: {name} left the roster group." +LEFT = "You are off the roster. Chats you have already joined are unchanged." +NOT_ON_ROSTER = "You are not on the roster." +ROSTER_EMPTY = "The roster is empty." + +# Keeps /list under the core's per-message size limit. +MAX_LISTED = 40 + +# Below the core's maxEncodedMsgLength (Protocol.hs). +MAX_REPLY_BYTES = 12000 +TRUNCATED = "\n… truncated" +EMPTY_ROSTER_LOG = "Connected: {customer} → nobody on the roster to add" +NOBODY_NEW_LOG = "Connected: {customer} → everyone on the roster was already in the chat" +COMMAND_FAILED = "The command failed. Try again." +REVOKE_FAILED = "Could not take {name} off the roster — retrying on the next restart." +BUSINESS_FAILED_LOG = "Connected: {customer} → could not set up the chat, nobody added" + +HELP = ( + "I add roster members to chats started by anyone who connects to my address.\n\n" + "/dm — join the roster. Without a direct contact I send a contact request; " + "you join the roster once you accept it.\n" + "/list — roster members, and contact requests not yet accepted.\n" + "/leave — leave the roster. Chats you have already joined are unchanged." +) + + +def _since(label: str, since: str) -> str: + """` — since 2026-08-13`, or empty when the entry has no timestamp.""" + day = since[:10] + return f" — {label} {day}" if day else "" + + +def _section(title: str, label: str, entries: Sequence[RosterEntry]) -> list[str]: + """A `/list` section, capped so the whole reply stays sendable. + + A long enough roster would push `/list` past the core's wire limit + (maxEncodedMsgLength), so it is capped here and what is omitted is stated + rather than silently dropped. + """ + lines = [f"{title} ({len(entries)}):"] + lines += [f" • {e.name}{_since(label, e.since)}" for e in entries[:MAX_LISTED]] + if len(entries) > MAX_LISTED: + lines.append(f" … and {len(entries) - MAX_LISTED} more") + return lines + + +def render_roster(entries: Sequence[RosterEntry]) -> str: + """Format the roster for `/list`, with a section per state.""" + active = [e for e in entries if e.state == ACTIVE and e.reachable] + unreachable = [e for e in entries if e.state == ACTIVE and not e.reachable] + pending = [e for e in entries if e.state != ACTIVE] + + lines: list[str] = [] + if active: + lines += _section("On the roster", "since", active) + else: + lines.append(ROSTER_EMPTY) + if unreachable: + lines.append("") + lines += _section("Not reachable, not being added", "since", unreachable) + if pending: + lines.append("") + lines += _section("Contact request not accepted", "asked", pending) + + return _bounded("\n".join(lines)) + + +def _bounded(out: str) -> str: + """Keep a message inside what the core will send.""" + encoded = out.encode() + if len(encoded) > MAX_REPLY_BYTES: + # A last resort: names are capped in characters, so a section of CJK + # names can still overrun what the core will send. The suffix is inside + # the budget, so the result never exceeds MAX_REPLY_BYTES. + room = MAX_REPLY_BYTES - len(TRUNCATED.encode()) + return encoded[:room].decode(errors="ignore") + TRUNCATED + return out + + +def invite_log(customer: str, added: Sequence[str], failed: Sequence[str]) -> str: + """One line for the roster group recording who was pulled into a business chat.""" + line = ( + f"Connected: {customer} → added {', '.join(added)}" + if added + else f"Connected: {customer} → nobody added" + ) + if failed: + line += f" (failed: {', '.join(failed)})" + return _bounded(line) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/roster.py b/apps/simplex-support-bot-light/src/support_bot_light/roster.py new file mode 100644 index 0000000000..1fc85369f4 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/roster.py @@ -0,0 +1,170 @@ +"""Roster membership, stored in contact custom data.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Literal + +from simplex_chat import ChatApi, util +from simplex_chat.types import T + +from .text import safe_name + +NAMESPACE = "supportBotLight" +ACTIVE = "active" +PENDING = "pending" + +RosterState = Literal["active", "pending"] + +READY_STATUSES = frozenset({"ready", "sndReady"}) + +# Mirrors isInGroup in apps/simplex-support-bot/src/bot.ts. +TERMINAL_STATUSES = frozenset({"rejected", "removed", "left", "deleted", "unknown"}) + +# The connection is gone; nothing about it is still in progress. +DEAD_STATUSES = frozenset({"deleted", "failed"}) + + +def in_group(member: T.GroupMember) -> bool: + return member["memberStatus"] not in TERMINAL_STATUSES + + +async def contact_ids_in_group(api: ChatApi, group_id: int) -> set[int]: + """Contact ids of everyone currently in a group. + + api_list_members keeps rows for people who left or were removed, so the + status filter is what makes this a membership test rather than a history of + everyone who was ever in the group. + """ + members = await api.api_list_members(group_id) + return {cid for m in members if (cid := m.get("memberContactId")) is not None and in_group(m)} + + +def connecting(contact: T.Contact) -> bool: + """Whether a connection is still on its way up. + + Between accepting and `ready` the core parks a member contact in `accepted` + with `contactGroupMemberId` cleared, which is indistinguishable by shape + from a connection the peer deleted. Only the status separates them. + """ + tag = util.conn_status(contact) + return tag is not None and tag not in DEAD_STATUSES + + +def awaiting_accept(contact: T.Contact) -> bool: + """Whether the peer opened a direct connection we have not accepted. + + A member who taps "connect directly" on the bot's profile in the roster + group produces this: a contact in `prepared` state with no + `contactGroupMemberId`, otherwise indistinguishable from one the peer + deleted. + """ + inv = contact.get("groupDirectInv") + if inv is not None: + # The record survives acceptance; only this flag moves, and the core + # rejects a second accept with "connection already started". + return not inv.get("groupDirectInvStartedConnection", False) + return util.conn_status(contact) == "prepared" + + +def accept_started(contact: T.Contact) -> bool: + """Whether we accepted and the connection is still completing. + + UPSTREAM BUG: `groupDirectInv` outlives the connection it describes. Nothing + clears the record when that connection dies, so the started flag alone + reports progress on a contact the peer deleted long ago. + + Workaround: the connection status decides, and the flag only distinguishes + accepted from not yet accepted. + """ + inv = contact.get("groupDirectInv") + if inv is None or not inv.get("groupDirectInvStartedConnection", False): + return False + return util.conn_status(contact) not in DEAD_STATUSES + + +def contact_usable(contact: T.Contact) -> bool: + """Whether the bot can actually add this contact to a group. + + `api_create_member_contact` sets the member's contact id before the person + has accepted anything, so the contact merely existing proves nothing — only + a connected connection does. + """ + return util.conn_status(contact) in READY_STATUSES + + +@dataclass(frozen=True, slots=True) +class RosterEntry: + """One person on the roster, as recorded in their contact's custom data.""" + + contact_id: int + name: str + state: RosterState + since: str + reachable: bool + + +def utc_now() -> str: + """Current UTC time as an ISO-8601 string, second precision.""" + return datetime.now(UTC).isoformat(timespec="seconds") + + +def contact_name(contact: T.Contact) -> str: + """A contact's display name, sanitised for rendering.""" + return safe_name( + contact.get("localDisplayName") or (contact.get("profile") or {}).get("displayName") or "" + ) + + +def entry_of(contact: T.Contact) -> RosterEntry | None: + """The roster entry for a contact, or None if it carries no roster mark.""" + mark = (contact.get("customData") or {}).get(NAMESPACE) + if not isinstance(mark, dict): + return None + state = mark.get("roster") + if state != ACTIVE and state != PENDING: + return None + return RosterEntry( + contact_id=contact["contactId"], + name=contact_name(contact), + state=state, + since=str(mark.get("since", "")), + reachable=contact_usable(contact), + ) + + +async def mark(api: ChatApi, contact: T.Contact, state: RosterState, since: str) -> None: + """Write the roster mark, preserving any other keys in the blob.""" + await api.api_merge_contact_custom_data(contact, NAMESPACE, {"roster": state, "since": since}) + + +async def unmark(api: ChatApi, contact: T.Contact) -> None: + """Remove the roster mark, leaving any other keys and the contact intact.""" + await api.api_merge_contact_custom_data(contact, NAMESPACE, None) + + +async def load(api: ChatApi, user_id: int) -> list[RosterEntry]: + """Every marked contact, sorted by display name.""" + contacts = await api.api_list_contacts(user_id) + entries = [e for c in contacts if (e := entry_of(c)) is not None] + return sorted(entries, key=lambda e: e.name.lower()) + + +async def active(api: ChatApi, user_id: int) -> list[RosterEntry]: + """Marked active and still reachable — the ones added to business chats. + + A contact marked active can stop being usable later, for instance when the + person deletes the bot. `api_add_member` always fails for such a contact, so + it is excluded here rather than failing once per business chat forever. + `/list` reports the same distinction under "Not reachable". + """ + return [e for e in await load(api, user_id) if e.state == ACTIVE and e.reachable] + + +async def find_contact(api: ChatApi, user_id: int, contact_id: int) -> T.Contact | None: + """The contact with this id, or None if it no longer exists.""" + for c in await api.api_list_contacts(user_id): + if c["contactId"] == contact_id: + return c + return None diff --git a/apps/simplex-support-bot-light/src/support_bot_light/setup.py b/apps/simplex-support-bot-light/src/support_bot_light/setup.py new file mode 100644 index 0000000000..b7be384abd --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/setup.py @@ -0,0 +1,146 @@ +"""Find or create the roster group, and keep its command menu in sync.""" + +from __future__ import annotations + +import asyncio +import logging + +from simplex_chat import ChatApi, ChatError +from simplex_chat.types import T + +from . import commands, roster +from .config import Config + +log = logging.getLogger(__name__) + +GROUP_MARKER = "roster" +JOIN_ROLE: T.GroupMemberRole = "member" + +# api_update_group_profile broadcasts, and the core's view queue is bounded +# (tbqSize in Mobile.hs) with a blocking write. Nothing drains that queue until +# the bot serves, so after enough downtime this call cannot return. The bot must +# start anyway: the write completes once the queue drains, and a stale menu is a +# cosmetic problem next to a process that never gets there. +PROFILE_PUSH_TIMEOUT = 30.0 + + +def is_roster_group(group: T.GroupInfo) -> bool: + """Whether this is a roster group the bot is still in. + + api_list_groups keeps groups the bot has left or been removed from. Without + the membership test the marker on a dead group would be chosen on every + start: no command would ever arrive, nothing could be posted, and the marker + would keep a replacement from being created. + """ + mark = (group.get("customData") or {}).get(roster.NAMESPACE) + if not isinstance(mark, dict) or mark.get("group") != GROUP_MARKER: + return False + return roster.in_group(group["membership"]) + + +def _preferences() -> T.GroupPreferences: + return { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + } + + +async def _get_or_create_group_link(api: ChatApi, group_id: int) -> str | None: + """The group's join link, creating one if it doesn't exist yet. + + A link can be missing if the process died between marking the group and + creating the link on a previous run — that must not leave the group + permanently unjoinable. + + `api_get_group_link_str` also fails for reasons other than "no link + exists" — if that happens while a link is actually present, the fallback + create hits the group's unique link index and raises too. A missing link + must never block startup, so that failure is logged and swallowed rather + than left to propagate out of `ensure_roster_group`. + """ + try: + return await api.api_get_group_link_str(group_id) + except ChatError: + pass + try: + return await api.api_create_group_link(group_id, JOIN_ROLE) + except ChatError: + log.warning( + "Could not get or create a join link for roster group %s", group_id, exc_info=True + ) + return None + + +async def ensure_roster_group(api: ChatApi, user_id: int, config: Config) -> int: + """Return the roster group id, creating the group on first run. + + The group is identified by a marker in its custom data, not by name, so an + operator renaming it in the client doesn't cause a second group to appear. + """ + marked = [g for g in await api.api_list_groups(user_id) if is_roster_group(g)] + if len(marked) > 1: + # Reachable when two instances share a database, or after a database is + # restored. Members of the group not chosen here are talking to a bot + # that ignores them, so say which one won. + log.warning( + "%d groups carry the roster marker (%s); using %s", + len(marked), + ", ".join(str(g["groupId"]) for g in marked), + marked[0]["groupId"], + ) + if marked: + group = marked[0] + try: + await _sync_preferences(api, group) + except ChatError: + # The menu is a convenience; the commands work when typed. The core + # requires owner rights to update the profile, so an operator who + # demotes the bot would otherwise brick every later start. + log.warning("could not update the command menu", exc_info=True) + group_id = group["groupId"] + log.info("Roster group: %s:%s", group_id, group["localDisplayName"]) + else: + profile: T.GroupProfile = { + "displayName": config.group_name, + "fullName": "", + "groupPreferences": _preferences(), + } + group = await api.api_new_group(user_id, profile) + group_id = group["groupId"] + await api.api_set_group_custom_data(group_id, {roster.NAMESPACE: {"group": GROUP_MARKER}}) + log.info("Roster group created: %s", group_id) + + link = await _get_or_create_group_link(api, group_id) + if link is not None: + log.info("Roster group link (share with the people who should answer):\n%s", link) + return group_id + + +async def _sync_preferences(api: ChatApi, group: T.GroupInfo) -> None: + """Restore the preferences the roster group needs, only when they differ. + + Both matter: without `commands` there is no menu, and without + `directMessages` the core refuses to create a member contact, so `/dm` + fails with nothing to explain it. An owner can switch either off in a + client, so neither can be assumed to survive from creation. + + `api_update_group_profile` broadcasts to every member, so a no-op update is + traffic for everyone in the group. + """ + profile = group.get("groupProfile") or {} + prefs = profile.get("groupPreferences") or {} + desired = _preferences() + if all(prefs.get(key) == value for key, value in desired.items()): + return + updated: T.GroupProfile = {**profile, "groupPreferences": {**prefs, **desired}} + try: + await asyncio.wait_for( + api.api_update_group_profile(group["groupId"], updated), PROFILE_PUSH_TIMEOUT + ) + except TimeoutError: + log.warning( + "Roster group preferences are still being written after %ss; continuing", + PROFILE_PUSH_TIMEOUT, + ) + return + log.info("Restored roster group preferences on %s", group["groupId"]) diff --git a/apps/simplex-support-bot-light/src/support_bot_light/text.py b/apps/simplex-support-bot-light/src/support_bot_light/text.py new file mode 100644 index 0000000000..e66bb44c10 --- /dev/null +++ b/apps/simplex-support-bot-light/src/support_bot_light/text.py @@ -0,0 +1,48 @@ +"""Sanitising peer-controlled text before it is rendered.""" + +from __future__ import annotations + +import unicodedata + +# mkValidName in src/Simplex/Chat/Library/Commands.hs caps a locally entered +# name at 50 characters. It is not applied to inbound profiles. +MAX_NAME = 50 + +UNNAMED = "(unnamed)" + +# Characters that render as nothing but are neither whitespace nor a control +# category, so `str.split` and `str.isprintable` both let them through. A stock +# client accepts them in a profile name, which makes "ㅤㅤAlice" a +# working impersonation of "Alice". +# Separators the bot's own messages use. A customer chooses their display name, +# and the roster group is the operator's only record of who was added. +SEPARATORS = frozenset("→") + +INVISIBLE = frozenset( + "ᅟᅠㅤᅠ" # Hangul fillers + "⠀" # Braille pattern blank + "឴឵" # Khmer inherent vowels + "⁠" # word joiner, zero-width no-break space +) + + +def safe_name(name: str) -> str: + """Collapse and truncate a display name for rendering. + + The core does not sanitise inbound profiles: a peer's display name reaches + us verbatim and may contain newlines or run to kilobytes. Rendered as-is it + forges lines in the roster group and in the log, and can push a message past + the size the core will send. + """ + # NFKC folds compatibility forms, so a name cannot hide behind an exotic + # encoding of an ordinary character. + collapsed = " ".join(unicodedata.normalize("NFKC", name).split()) + printable = "".join( + c for c in collapsed if c.isprintable() and c not in INVISIBLE and c not in SEPARATORS + ) + stripped = printable.strip() + if not stripped: + return UNNAMED + if len(stripped) > MAX_NAME: + return stripped[: MAX_NAME - 1] + "…" + return stripped diff --git a/apps/simplex-support-bot-light/state/.gitkeep b/apps/simplex-support-bot-light/state/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/__init__.py b/apps/simplex-support-bot-light/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/apps/simplex-support-bot-light/tests/conftest.py b/apps/simplex-support-bot-light/tests/conftest.py new file mode 100644 index 0000000000..d13e012589 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/conftest.py @@ -0,0 +1,275 @@ +"""Fake ChatApi and wire-object factories. No libsimplex, no I/O.""" + +from __future__ import annotations + +import copy +from types import SimpleNamespace +from typing import Any + +import pytest +from simplex_chat import Message, util +from simplex_chat.core import ChatAPIError + +USER_ID = 1 +ROSTER_GROUP_ID = 10 + + +class FakeChatApi: + """Records calls; returns canned wire dicts. + + `fail_on` is a set of method names that raise `ChatAPIError` when called, + used to drive the partial-failure paths. + """ + + def __init__(self, contacts: list[dict] | None = None, fail_on: set[str] | None = None): + self.contacts = contacts or [] + self.groups: list[dict] = [] + self.members: dict[int, list[dict]] = {} + self.fail_on = fail_on or set() + self.custom_data: list[tuple[int, dict | None]] = [] + self.group_custom_data: list[tuple[int, dict | None]] = [] + self.replies: list[str] = [] + self.sent: list[tuple[Any, str]] = [] + self.added: list[tuple[int, int, str]] = [] + self.created_member_contacts: list[tuple[int, int]] = [] + self.invitations: list[tuple[int, str]] = [] + self.new_groups: list[dict] = [] + self.profile_updates: list[tuple[int, dict]] = [] + self.links: list[int] = [] + self.group_links: dict[int, str] = {} + self._next_contact_id = 100 + self._next_item_id = 1000 + self._member_contacts_created: set[tuple[int, int]] = set() + self.accepted_member_contacts: list[int] = [] + + def _check(self, name: str) -> None: + if name in self.fail_on: + raise ChatAPIError(f"fake failure in {name}", {"type": "chatCmdError"}) + + async def api_list_contacts(self, user_id: int) -> list[dict]: + self._check("api_list_contacts") + # Copies, like the core: a caller holding a contact does not see a later + # write to it, so stale reads show up in tests instead of in production. + return copy.deepcopy(self.contacts) + + async def api_set_contact_custom_data(self, contact_id: int, custom_data=None) -> None: + self._check("api_set_contact_custom_data") + self.custom_data.append((contact_id, custom_data)) + for c in self.contacts: + if c["contactId"] == contact_id: + if custom_data is None: + c.pop("customData", None) + else: + c["customData"] = custom_data + + async def api_merge_contact_custom_data(self, contact: dict, key: str, value) -> None: + # Mirrors ChatApi: the column is replaced wholesale, so a merge is a + # read-modify-write through the same set command. + await self.api_set_contact_custom_data( + contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value) + ) + + async def api_merge_group_custom_data(self, group: dict, key: str, value) -> None: + await self.api_set_group_custom_data( + group["groupId"], util.merged_custom_data(group.get("customData"), key, value) + ) + + async def api_create_member_contact(self, group_id: int, group_member_id: int) -> dict: + self._check("api_create_member_contact") + key = (group_id, group_member_id) + if key in self._member_contacts_created: + raise ChatAPIError("member contact already exists", {"type": "chatCmdError"}) + self._member_contacts_created.add(key) + self.created_member_contacts.append((group_id, group_member_id)) + contact = make_contact(self._next_contact_id, f"member{group_member_id}") + self._next_contact_id += 1 + self.contacts.append(contact) + return contact + + async def api_send_member_contact_invitation(self, contact_id: int, message=None) -> dict: + self._check("api_send_member_contact_invitation") + for c in self.contacts: + if c["contactId"] == contact_id and c.get("contactGrpInvSent"): + raise ChatAPIError("x.grp.direct.inv already sent", {"type": "chatCmdError"}) + self.invitations.append((contact_id, message)) + for c in self.contacts: + if c["contactId"] == contact_id: + c["contactGrpInvSent"] = True + return make_contact(contact_id, "invited", grp_inv_sent=True) + + async def api_accept_member_contact(self, contact_id: int) -> dict: + self._check("api_accept_member_contact") + self.accepted_member_contacts.append(contact_id) + for c in self.contacts: + if c["contactId"] == contact_id: + c.setdefault("groupDirectInv", {})["groupDirectInvStartedConnection"] = True + return c + return make_contact(contact_id, "accepted") + + async def api_list_members(self, group_id: int) -> list[dict]: + self._check("api_list_members") + return list(self.members.get(group_id, [])) + + async def api_add_member(self, group_id: int, contact_id: int, member_role: str) -> dict: + self._check("api_add_member") + self.added.append((group_id, contact_id, member_role)) + # Distinct id spaces; keep them apart so a mix-up shows up. + return make_member(group_member_id=contact_id + 1000, contact_id=contact_id) + + async def api_send_text_message(self, chat, text: str, in_reply_to=None) -> list: + self._check("api_send_text_message") + self.sent.append((chat, text)) + return [] + + async def api_send_text_reply(self, chat_item, text: str) -> list: + self._check("api_send_text_reply") + self.replies.append(text) + # Message.reply indexes items[0], so this cannot return []. + self._next_item_id += 1 + sent_item = { + "chatInfo": chat_item["chatInfo"], + "chatItem": { + "chatDir": {"type": "direct"}, + "meta": {"itemId": self._next_item_id}, + "content": {"type": "sndMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + return [sent_item] + + async def api_list_groups(self, user_id: int, contact_id=None, search=None) -> list[dict]: + self._check("api_list_groups") + return list(self.groups) + + async def api_new_group(self, user_id: int, group_profile: dict) -> dict: + self._check("api_new_group") + self.new_groups.append(group_profile) + group = make_group(ROSTER_GROUP_ID, group_profile) + self.groups.append(group) + return group + + async def api_set_group_custom_data(self, group_id: int, custom_data=None) -> None: + self._check("api_set_group_custom_data") + self.group_custom_data.append((group_id, custom_data)) + for g in self.groups: + if g["groupId"] == group_id: + g["customData"] = custom_data + + async def api_update_group_profile(self, group_id: int, group_profile: dict) -> dict: + self._check("api_update_group_profile") + self.profile_updates.append((group_id, group_profile)) + return make_group(group_id, group_profile) + + async def api_create_group_link(self, group_id: int, member_role: str) -> str: + self._check("api_create_group_link") + self.links.append(group_id) + link = f"https://simplex.chat/contact#/?v=2&group={group_id}" + self.group_links[group_id] = link + return link + + async def api_get_group_link_str(self, group_id: int) -> str: + self._check("api_get_group_link_str") + try: + return self.group_links[group_id] + except KeyError: + raise ChatAPIError("no group link", {"type": "chatCmdError"}) from None + + +def make_contact( + contact_id: int, + name: str, + custom_data: dict | None = None, + connected: bool = False, + grp_inv_sent: bool = False, + grp_member_id: int | None = -1, + conn_status: str | None = None, +) -> dict: + contact: dict = { + "contactId": contact_id, + "localDisplayName": name, + "profile": {"profileId": contact_id, "displayName": name, "fullName": ""}, + "contactGrpInvSent": grp_inv_sent, + } + if custom_data is not None: + contact["customData"] = custom_data + if conn_status is not None: + contact["activeConn"] = {"connStatus": {"type": conn_status}} + elif connected: + contact["activeConn"] = {"connStatus": {"type": "ready"}} + # The core sets contactGroupMemberId when a member contact is created and + # clears it once that contact connects (resetMemberContactFields). -1 means + # "use whichever of those matches `connected`". + if grp_member_id == -1: + grp_member_id = None if connected else contact_id + if grp_member_id is not None: + contact["contactGroupMemberId"] = grp_member_id + return contact + + +def make_member( + group_member_id: int = 1, + contact_id: int | None = None, + name: str = "someone", + status: str = "complete", +) -> dict: + member: dict = { + "groupMemberId": group_member_id, + "localDisplayName": name, + "memberProfile": {"displayName": name, "fullName": ""}, + "memberStatus": status, + } + if contact_id is not None: + member["memberContactId"] = contact_id + return member + + +def make_group( + group_id: int, + profile: dict, + custom_data: dict | None = None, + membership_status: str = "creator", +) -> dict: + # The core always sends membership; discovery reads it to skip groups the + # bot has left. + group: dict = { + "groupId": group_id, + "groupProfile": profile, + "localDisplayName": "g", + "membership": make_member(1, name="bot", status=membership_status), + } + if custom_data is not None: + group["customData"] = custom_data + return group + + +def join_roster_group(api: FakeChatApi) -> None: + """Put every contact in the roster group. + + Being on the roster means being in that group; the bot re-checks it before + adding anyone to a customer's chat, so tests have to model it. + """ + api.members[ROSTER_GROUP_ID] = [ + make_member(1000 + c["contactId"], contact_id=c["contactId"], name=c["localDisplayName"]) + for c in api.contacts + ] + + +def make_group_message(api: FakeChatApi, member: dict, text: str, group_id: int = ROSTER_GROUP_ID): + """A `Message` as delivered from a group, wired to the fake api.""" + chat_item = { + "chatInfo": {"type": "group", "groupInfo": make_group(group_id, {"displayName": "r"})}, + "chatItem": { + "chatDir": {"type": "groupRcv", "groupMember": member}, + "meta": {"itemId": 1}, + "content": {"type": "rcvMsgContent", "msgContent": {"type": "text", "text": text}}, + }, + } + return Message( + chat_item=chat_item, + content={"type": "text", "text": text}, + client=SimpleNamespace(api=api), + ) + + +@pytest.fixture +def api() -> FakeChatApi: + return FakeChatApi() diff --git a/apps/simplex-support-bot-light/tests/test_boundaries.py b/apps/simplex-support-bot-light/tests/test_boundaries.py new file mode 100644 index 0000000000..b34158c336 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_boundaries.py @@ -0,0 +1,124 @@ +"""Constants and boundaries pinned at their exact edge. + +Each of these was a surviving mutant: the value could be moved by one, or a +member of a set removed, with the whole suite still green. +""" + +import pytest + +from support_bot_light import commands, config, messages, roster, setup, text +from support_bot_light.config import ConfigError, load_config +from tests.conftest import make_contact, make_member +from tests.test_config import VALID, write + + +def entry(name: str, state: str = "active", reachable: bool = True) -> roster.RosterEntry: + return roster.RosterEntry( + contact_id=1, name=name, state=state, since="2026-08-13", reachable=reachable + ) + + +def test_the_roster_group_link_hands_out_the_member_role(): + # An owner could remove the bot from its own roster group. + assert setup.JOIN_ROLE == "member" + + +def test_both_ready_statuses_make_a_contact_usable(): + # contactSndReady is a distinct event from contactConnected, and a member + # promoted by one must not be treated as unreachable by the other. + for status in ("ready", "sndReady"): + assert roster.contact_usable(make_contact(1, "sh", conn_status=status)) is True + assert roster.contact_usable(make_contact(1, "sh", conn_status="accepted")) is False + + +@pytest.mark.parametrize("status", ["deleted", "failed"]) +def test_a_dead_connection_is_not_accepted_or_connecting(status): + contact = make_contact(1, "sh", conn_status=status) + contact["groupDirectInv"] = {"groupDirectInvLink": "x", "groupDirectInvStartedConnection": True} + assert roster.accept_started(contact) is False + assert roster.connecting(contact) is False + + +def test_a_member_of_the_roster_group_is_in_it_until_a_terminal_status(): + assert roster.in_group(make_member(1, status="pending_approval")) is True + assert roster.in_group(make_member(1, status="invited")) is True + assert roster.in_group(make_member(1, status="left")) is False + + +def test_list_shows_forty_before_it_summarises(): + # 40 keeps the reply inside the core's wire limit with room for two more + # sections; the literal is the point, so a change has to be deliberate. + assert messages.MAX_LISTED == 40 + at_cap = messages.render_roster([entry(f"n{i}") for i in range(40)]) + assert at_cap.count("•") == 40 + assert "more" not in at_cap + + over_cap = messages.render_roster([entry(f"n{i}") for i in range(41)]) + assert over_cap.count("•") == 40 + assert "… and 1 more" in over_cap + + +def test_a_reply_at_the_byte_cap_is_not_truncated(): + room = messages.MAX_REPLY_BYTES - len("On the roster (1):\n • ") - len(" — since 2026-08-13") + assert messages.render_roster([entry("a" * min(room, text.MAX_NAME))]).endswith("2026-08-13") + + over = [entry("漢" * text.MAX_NAME) for _ in range(messages.MAX_LISTED)] + over += [entry("漢" * text.MAX_NAME, state="pending") for _ in range(messages.MAX_LISTED)] + rendered = messages.render_roster(over) + assert len(rendered.encode()) <= messages.MAX_REPLY_BYTES + assert rendered.endswith(messages.TRUNCATED) + + +def test_a_name_of_fifty_is_kept_whole(): + # mkValidName caps a locally entered name at 50; inbound profiles are not + # capped at all, which is why this exists. + assert text.MAX_NAME == 50 + assert text.safe_name("a" * 50) == "a" * 50 + over = text.safe_name("a" * 51) + assert len(over) == 50 and over.endswith("…") + + +def test_a_welcome_of_twelve_thousand_bytes_is_accepted(tmp_path): + assert config.MAX_WELCOME_BYTES == 12000 + at_cap = "w" * 12000 + text_at = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{at_cap}"') + assert load_config(write(tmp_path, text_at)).welcome == at_cap + + over = "w" * 12001 + text_over = VALID.replace('welcome = "Hi! Someone will join shortly."', f'welcome = "{over}"') + with pytest.raises(ConfigError, match="too long"): + load_config(write(tmp_path, text_over)) + + +def test_an_image_of_9357_bytes_is_accepted(tmp_path): + # 9357 raw bytes is what a 12500-character data URI holds once base64 and + # the "data:image/png;base64," prefix are added. The pre-read check must + # admit everything the encoded cap can hold, and no more. + assert config.MAX_IMAGE_BYTES == 9357 + at_cap = tmp_path / "a.png" + at_cap.write_bytes(b"\x89PNG" + b"x" * (9357 - 4)) + conf = VALID.replace("[roster]", f'image = "{at_cap}"\n\n[roster]') + assert load_config(write(tmp_path, conf)).image is not None + + over = tmp_path / "b.png" + over.write_bytes(b"\x89PNG" + b"x" * (9358 - 4)) + conf_over = VALID.replace("[roster]", f'image = "{over}"\n\n[roster]') + with pytest.raises(ConfigError, match="too large"): + load_config(write(tmp_path, conf_over)) + + +@pytest.mark.parametrize("port", [1, 65535]) +def test_the_port_range_ends_are_accepted(tmp_path, port): + assert config.MAX_PORT == 65535 + conf = load_config(write(tmp_path, VALID + f"\n[health]\nport = {port}\n")) + assert conf.health is not None and conf.health.port == port + + +def test_the_command_menu_carries_every_command(): + wire = commands.to_wire(commands.COMMANDS) + assert [c["keyword"] for c in wire] == [ + commands.DM, + commands.LIST, + commands.LEAVE, + commands.HELP, + ] diff --git a/apps/simplex-support-bot-light/tests/test_business.py b/apps/simplex-support-bot-light/tests/test_business.py new file mode 100644 index 0000000000..c555cfbf3f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_business.py @@ -0,0 +1,534 @@ +import pytest +from simplex_chat import ChatCommandError + +from support_bot_light import business, messages +from support_bot_light.config import Config +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + join_roster_group, + make_contact, + make_group, + make_member, +) + +BUSINESS_GROUP_ID = 42 +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") + + +@pytest.fixture +def ctx(api): + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +def event(name="Alex"): + return { + "type": "acceptingBusinessRequest", + "groupInfo": make_group(BUSINESS_GROUP_ID, {"displayName": name, "fullName": ""}), + } + + +async def test_adds_active_roster_members(ctx, api): + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(3, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}), + ] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [ + (BUSINESS_GROUP_ID, 2, "owner"), + (BUSINESS_GROUP_ID, 1, "owner"), + ] + assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added Narasimha, sh")] + + +@pytest.mark.parametrize("status", ["rejected", "removed", "left", "deleted", "unknown"]) +async def test_does_not_add_someone_who_has_left_the_roster_group(ctx, api, status): + # The departure event and a queued business request arrive in whatever order + # the core dispatches them, so an active mark is not authority on its own: + # this is what stops a departed member reading a conversation started after + # they went. + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + join_roster_group(api) + api.members[ROSTER_GROUP_ID][1]["memberStatus"] = status + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent == [(["group", ROSTER_GROUP_ID], "Connected: Alex → added sh")] + + +async def test_reconcile_does_not_add_someone_who_has_left_the_roster_group(ctx, api): + api.contacts.append( + make_contact( + 1, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.members[ROSTER_GROUP_ID][0]["memberStatus"] = "removed" + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_skips_members_already_in_the_group(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="invited")] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex") + + +async def test_does_not_skip_members_who_left(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")] + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + + +async def test_empty_roster_logs_and_adds_nobody(ctx, api): + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex") + + +async def test_pending_only_roster_counts_as_empty(ctx, api): + api.contacts.append( + make_contact(1, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.EMPTY_ROSTER_LOG.format(customer="Alex") + + +async def test_one_failure_does_not_block_the_rest(ctx, api): + api.contacts += [ + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 2, "Narasimha", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + calls: list[int] = [] + original = api.api_add_member + + async def flaky(group_id, contact_id, member_role): + calls.append(contact_id) + if contact_id == 2: + raise ChatCommandError("nope", {"type": "chatCmdError"}) + return await original(group_id, contact_id, member_role) + + api.api_add_member = flaky + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert sorted(calls) == [1, 2] # both attempted + assert api.sent[-1][1] == "Connected: Alex → added sh (failed: Narasimha)" + + +async def test_roster_read_failure_logs_and_adds_nobody(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_contacts") + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex") + + +async def test_member_list_failure_logs_and_adds_nobody(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_members") + await business.on_business_request(ctx, event()) + assert api.added == [] + assert api.sent[-1][1] == messages.BUSINESS_FAILED_LOG.format(customer="Alex") + + +async def test_uses_configured_member_role(api): + ctx = BotContext( + api=api, + user_id=USER_ID, + roster_group_id=ROSTER_GROUP_ID, + config=Config("S", "./x", "hi", "R", "admin"), + ) + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "admin")] + + +async def test_reconcile_repairs_a_chat_left_half_added_by_a_crash(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_reconcile_is_idempotent_when_everyone_is_present(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="complete")] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + # The chat was left unmarked, so a crash took the roster group's record of + # this customer with it; the repair puts it back even with nothing to add. + assert api.sent[-1][1] == messages.NOBODY_NEW_LOG.format(customer="Alex") + + +async def test_reconcile_skips_non_business_groups(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": ROSTER_GROUP_ID, + "groupProfile": {"displayName": "roster", "fullName": ""}, + "localDisplayName": "roster", + "membership": make_member(99, name="bot", status="complete"), + } + ) + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_reconcile_skips_a_chat_the_bot_has_left(ctx, api): + # The core keeps the group row after removal; adding into it would fail on + # every start, and the customer is no longer the bot's to serve. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="removed"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + assert api.group_custom_data == [] + + +async def test_reconcile_failure_does_not_stop_startup(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_list_groups") + join_roster_group(api) + await business.reconcile_chats(ctx) # must not raise + + +async def test_reconcile_skips_a_chat_whose_roster_pass_already_ran(ctx, api): + # Someone who joins the roster later must not be back-filled into every + # conversation the bot has ever handled. + api.contacts.append( + make_contact( + 1, "newbie", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": {"supportBotLight": {"rostered": True}}, + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + assert api.sent == [] + + +async def test_reconcile_does_not_re_invite_someone_who_left_a_chat(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": {"supportBotLight": {"rostered": True}}, + } + ) + api.members[BUSINESS_GROUP_ID] = [make_member(5, contact_id=1, status="left")] + join_roster_group(api) + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_on_business_request_marks_the_chat_as_rostered(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.group_custom_data[-1] == ( + BUSINESS_GROUP_ID, + {"supportBotLight": {"rostered": True}}, + ) + + +async def test_reconcile_marks_chats_even_with_an_empty_roster(ctx, api): + # Otherwise the chat stays unmarked and a later restart back-fills whoever + # joined the roster in the meantime. + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + await business.reconcile_chats(ctx) + assert api.group_custom_data[-1] == ( + BUSINESS_GROUP_ID, + {"supportBotLight": {"rostered": True}}, + ) + assert api.added == [] + + +async def test_a_failed_mark_does_not_report_nobody_added(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_set_group_custom_data") + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_a_chat_where_every_add_failed_is_retried_next_start(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_add_member") + join_roster_group(api) + await business.on_business_request(ctx, event()) + assert api.group_custom_data == [] # not marked, so repair will revisit it + + +async def test_a_chat_left_unmarked_is_not_back_filled_with_a_later_roster(ctx, api): + # The one bit that keeps a new roster member out of old conversations. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.fail_on.add("api_send_text_message") + await business.on_business_request(ctx, event()) + api.fail_on.clear() + + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + "customData": api.group_custom_data[-1][1], + } + ) + api.contacts.append( + make_contact( + 2, "newbie", {"supportBotLight": {"roster": "active", "since": "y"}}, connected=True + ) + ) + join_roster_group(api) + api.added.clear() + await business.reconcile_chats(ctx) + assert api.added == [] + + +async def test_a_chat_is_marked_even_when_its_line_never_went_out(ctx, api): + # An unmarked chat is repaired by every later start with the roster of the + # day, so withholding the marker to preserve a log line would hand this + # customer's conversation to whoever joins the roster next. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + join_roster_group(api) + api.fail_on.add("api_send_text_message") + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.group_custom_data[-1][1] == {"supportBotLight": {"rostered": True}} + + +async def test_a_failed_mark_still_reports_the_repaired_chat(ctx, api): + # The marker is re-derived on the next start; the report is not, because + # the event that would have produced it was consumed before the crash. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + api.fail_on.add("api_set_group_custom_data") + await business.reconcile_chats(ctx) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + assert api.sent[-1][1] == "Connected: Alex → added sh" + + +async def test_an_unfinished_repair_is_retried_by_the_queued_event(ctx, api): + # Nothing was added and the chat was left unmarked, so the event that the + # startup pass raced is the only remaining chance to finish it. + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + api.fail_on.add("api_add_member") + await business.reconcile_chats(ctx) + assert api.group_custom_data == [] # not marked: the repair failed + + api.fail_on.clear() + await business.on_business_request(ctx, event()) + assert api.added == [(BUSINESS_GROUP_ID, 1, "owner")] + + +async def test_repair_does_not_re_report_a_chat_to_the_event_handler(ctx, api): + api.contacts.append( + make_contact( + 1, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + api.groups.append( + { + "groupId": BUSINESS_GROUP_ID, + "groupProfile": {"displayName": "Alex", "fullName": ""}, + "localDisplayName": "Alex", + "businessChat": {"chatType": "business", "businessId": "b", "customerId": "c"}, + "membership": make_member(99, name="bot", status="complete"), + } + ) + api.members[BUSINESS_GROUP_ID] = [] + join_roster_group(api) + await business.reconcile_chats(ctx) + posts_after_repair = len(api.sent) + await business.on_business_request(ctx, event()) + assert len(api.sent) == posts_after_repair # the queued event adds no line + + # Only that one event is swallowed: the same customer coming back later + # must be handled like anyone else. + api.members[BUSINESS_GROUP_ID] = [] + await business.on_business_request(ctx, event()) + assert len(api.sent) == posts_after_repair + 1 diff --git a/apps/simplex-support-bot-light/tests/test_commands.py b/apps/simplex-support-bot-light/tests/test_commands.py new file mode 100644 index 0000000000..83c435aacb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_commands.py @@ -0,0 +1,32 @@ +from simplex_chat import BotCommand + +from support_bot_light.commands import COMMANDS, to_wire + + +def test_declares_four_commands(): + assert tuple(c.keyword for c in COMMANDS) == ("dm", "list", "leave", "help") + + +def test_no_command_takes_params(): + # Zero-argument commands send on tap instead of pasting a placeholder. + assert all(c.params is None for c in COMMANDS) + + +def test_to_wire_omits_params_when_none(): + wire = to_wire([BotCommand(keyword="list", label="Who gets invited")]) + assert wire == [{"type": "command", "keyword": "list", "label": "Who gets invited"}] + assert "params" not in wire[0] + + +def test_to_wire_includes_params_when_set(): + wire = to_wire([BotCommand(keyword="x", label="X", params="")]) + assert wire == [{"type": "command", "keyword": "x", "label": "X", "params": ""}] + + +def test_to_wire_distinguishes_none_from_empty_string(): + assert "params" not in to_wire([BotCommand("a", "A")])[0] + assert to_wire([BotCommand("b", "B", params="")])[0]["params"] == "" + + +def test_to_wire_preserves_declaration_order(): + assert [c["keyword"] for c in to_wire(COMMANDS)] == [c.keyword for c in COMMANDS] diff --git a/apps/simplex-support-bot-light/tests/test_config.py b/apps/simplex-support-bot-light/tests/test_config.py new file mode 100644 index 0000000000..e3c3a7fed5 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_config.py @@ -0,0 +1,286 @@ +import base64 + +import pytest + +from support_bot_light.config import Config, ConfigError, Health, load_config + +VALID = """ +[bot] +display_name = "Support" +db_prefix = "./support_bot_light" +welcome = "Hi! Someone will join shortly." + +[roster] +group_name = "Invite roster" +member_role = "admin" +""" + +# Minimal 1x1 PNG. The loader never parses it, only encodes the bytes. +PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde" + b"\x00\x00\x00\x0cIDATx\x9cc\xf8\xcf\xc0\x00\x00\x03\x01\x01\x00\xc9\xfe\x92\xef" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def write(tmp_path, text): + p = tmp_path / "config.toml" + p.write_text(text, encoding="utf-8") + return p + + +def test_loads_all_fields(tmp_path): + cfg = load_config(write(tmp_path, VALID)) + assert cfg == Config( + display_name="Support", + db_prefix="./support_bot_light", + welcome="Hi! Someone will join shortly.", + group_name="Invite roster", + member_role="admin", + health=Health(host="127.0.0.1", port=8080), + ) + + +def test_member_role_defaults_to_owner(tmp_path): + text = VALID.replace('member_role = "admin"\n', "") + assert load_config(write(tmp_path, text)).member_role == "owner" + + +def test_rejects_unknown_member_role(tmp_path): + text = VALID.replace('"admin"', '"chief"') + with pytest.raises(ConfigError, match="member_role"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_key(tmp_path): + text = VALID.replace('welcome = "Hi! Someone will join shortly."\n', "") + with pytest.raises(ConfigError, match="bot.welcome"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_bot_section(tmp_path): + with pytest.raises(ConfigError, match=r"missing \[bot\] section"): + load_config(write(tmp_path, '[roster]\ngroup_name = "R"\n')) + + +def test_rejects_missing_roster_section(tmp_path): + with pytest.raises(ConfigError, match=r"missing \[roster\] section"): + load_config(write(tmp_path, "[bot]\n")) + + +def test_rejects_empty_string(tmp_path): + text = VALID.replace('"Invite roster"', '" "') + with pytest.raises(ConfigError, match="roster.group_name"): + load_config(write(tmp_path, text)) + + +def test_missing_file(tmp_path): + with pytest.raises(ConfigError, match="not found"): + load_config(tmp_path / "nope.toml") + + +def test_invalid_toml(tmp_path): + with pytest.raises(ConfigError, match="invalid TOML"): + load_config(write(tmp_path, "[bot")) + + +def test_image_defaults_to_none(tmp_path): + assert load_config(write(tmp_path, VALID)).image is None + + +def test_png_image_encodes_with_prefix_and_roundtrips(tmp_path): + (tmp_path / "avatar.png").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + prefix = "data:image/png;base64," + assert image.startswith(prefix) + assert base64.b64decode(image[len(prefix) :]) == PNG_BYTES + + +@pytest.mark.parametrize("ext", ["jpg", "jpeg"]) +def test_jpg_and_jpeg_extensions_encode_as_jpg(tmp_path, ext): + (tmp_path / f"avatar.{ext}").write_bytes(b"not really a jpeg, just bytes") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + f'db_prefix = "./support_bot_light"\nimage = "avatar.{ext}"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert image.startswith("data:image/jpg;base64,") + + +def test_uppercase_extension_accepted(tmp_path): + (tmp_path / "avatar.PNG").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.PNG"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert image.startswith("data:image/png;base64,") + + +def test_rejects_unsupported_extension(tmp_path): + (tmp_path / "avatar.gif").write_bytes(b"gif bytes") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.gif"', + ) + with pytest.raises(ConfigError, match=r"\.gif"): + load_config(write(tmp_path, text)) + + +def test_rejects_missing_image_file(tmp_path): + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "missing.png"', + ) + resolved = tmp_path / "missing.png" + with pytest.raises(ConfigError, match=r"not found.*missing\.png|missing\.png.*not found"): + load_config(write(tmp_path, text)) + assert not resolved.exists() + + +def test_rejects_oversized_image(tmp_path): + # 12500 caps the whole data URI, prefix included. + (tmp_path / "avatar.png").write_bytes(b"\x00" * 20000) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="12500"): + load_config(write(tmp_path, text)) + + +def test_rejects_empty_image_string(tmp_path): + text = VALID.replace( + 'db_prefix = "./support_bot_light"', 'db_prefix = "./support_bot_light"\nimage = " "' + ) + with pytest.raises(ConfigError, match="bot.image"): + load_config(write(tmp_path, text)) + + +def test_relative_image_path_resolves_against_config_dir(tmp_path, monkeypatch): + other_dir = tmp_path / "elsewhere" + other_dir.mkdir() + monkeypatch.chdir(other_dir) + + (tmp_path / "avatar.png").write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES + + +def test_absolute_image_path_works(tmp_path): + image_path = tmp_path / "avatar.png" + image_path.write_bytes(PNG_BYTES) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + f'db_prefix = "./support_bot_light"\nimage = "{image_path}"', + ) + image = load_config(write(tmp_path, text)).image + assert image is not None + assert base64.b64decode(image[len("data:image/png;base64,") :]) == PNG_BYTES + + +def test_rejects_empty_image_file(tmp_path): + (tmp_path / "avatar.png").write_bytes(b"") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="empty"): + load_config(write(tmp_path, text)) + + +def test_rejects_a_non_regular_image_file(tmp_path): + import os + + os.mkfifo(tmp_path / "avatar.png") + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="not a regular file"): + load_config(write(tmp_path, text)) + + +def test_rejects_an_oversized_image_before_reading_it(tmp_path): + (tmp_path / "avatar.png").write_bytes(b"A" * 20000) + text = VALID.replace( + 'db_prefix = "./support_bot_light"', + 'db_prefix = "./support_bot_light"\nimage = "avatar.png"', + ) + with pytest.raises(ConfigError, match="bytes exceeds"): + load_config(write(tmp_path, text)) + + +def test_rejects_an_over_long_welcome(tmp_path): + text = VALID.replace( + 'welcome = "Hi! Someone will join shortly."', 'welcome = "' + "x" * 20000 + '"' + ) + with pytest.raises(ConfigError, match="too long"): + load_config(write(tmp_path, text)) + + +def test_health_is_on_without_configuration(tmp_path): + assert load_config(write(tmp_path, VALID)).health == Health(host="127.0.0.1", port=8080) + + +def test_health_can_be_switched_off(tmp_path): + assert load_config(write(tmp_path, VALID + "\n[health]\nenabled = false\n")).health is None + + +def test_health_port_can_be_set(tmp_path): + config = load_config(write(tmp_path, VALID + "\n[health]\nport = 9999\n")) + assert config.health == Health(host="127.0.0.1", port=9999, configured=True) + + +def test_the_default_port_is_not_treated_as_chosen(tmp_path): + # A port nobody asked for must not be able to stop the bot from starting. + assert load_config(write(tmp_path, VALID)).health == Health("127.0.0.1", 8080) + assert load_config(write(tmp_path, VALID)).health.configured is False + + +def test_health_host_can_be_set(tmp_path): + config = load_config(write(tmp_path, VALID + '\n[health]\nhost = "0.0.0.0"\nport = 9000\n')) + assert config.health == Health(host="0.0.0.0", port=9000, configured=True) + + +@pytest.mark.parametrize( + "section", + [ + '[health]\nenabled = "yes"\n', + "[health]\nport = 0\n", + "[health]\nport = 65536\n", + "[health]\nport = true\n", # TOML booleans are ints in Python + '[health]\nport = "8080"\n', + '[health]\nport = 8080\nhost = " "\n', + ], +) +def test_invalid_health_settings_are_rejected(tmp_path, section): + with pytest.raises(ConfigError): + load_config(write(tmp_path, VALID + "\n" + section)) + + +def test_a_missing_config_points_at_the_template(tmp_path): + # Under Docker this is a restart loop until the operator acts, so the error + # has to say what the action is. + (tmp_path / "config.toml.example").write_text(VALID, encoding="utf-8") + with pytest.raises(ConfigError, match="copy config.toml.example to config.toml"): + load_config(tmp_path / "config.toml") + + +def test_a_missing_config_without_a_template_says_only_that(tmp_path): + with pytest.raises(ConfigError, match="not found") as raised: + load_config(tmp_path / "config.toml") + assert "copy" not in str(raised.value) diff --git a/apps/simplex-support-bot-light/tests/test_handlers.py b/apps/simplex-support-bot-light/tests/test_handlers.py new file mode 100644 index 0000000000..dca98dc2d7 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_handlers.py @@ -0,0 +1,720 @@ +import pytest +from simplex_chat import ChatCommandError + +from support_bot_light import handlers, messages, roster +from support_bot_light.config import Config +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + make_contact, + make_group, + make_group_message, + make_member, +) + +CONFIG = Config( + display_name="Support", + db_prefix="./x", + welcome="hi", + group_name="Invite roster", + member_role="owner", +) + + +@pytest.fixture +def ctx(api): + # The bot always has its own marked roster group; reconcile checks for it. + api.groups.append( + make_group( + ROSTER_GROUP_ID, + {"displayName": "Invite roster", "fullName": ""}, + custom_data={"supportBotLight": {"group": "roster"}}, + ) + ) + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +async def test_dm_with_existing_contact_marks_active(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active" + assert api.replies == [messages.ADDED] + assert api.created_member_contacts == [] + + +async def test_dm_promotes_pending_contact_keeps_original_since(ctx, api): + # Self-heal after a missed contactConnected; the ask date must survive. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "2026-08-01T00:00:00+00:00"}}, + connected=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"] == { + "roster": "active", + "since": "2026-08-01T00:00:00+00:00", + } + assert api.replies == [messages.ADDED] + + +async def test_dm_promotes_usable_contact_even_if_invitation_was_sent(ctx, api): + # The invitation is what made the contact usable, so both flags are set. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + connected=True, + grp_inv_sent=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "active" + assert api.replies == [messages.ADDED] + + +async def test_dm_without_contact_creates_and_invites(ctx, api): + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + assert api.invitations == [(100, messages.INVITATION_TEXT)] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_replies_invitation_failed_when_send_fails(ctx, api): + api.fail_on.add("api_send_member_contact_invitation") + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.INVITATION_FAILED] + + +async def test_dm_while_pending_and_invitation_sent_is_a_noop(ctx, api): + # The core rejects a second invitation. + api.contacts.append( + make_contact( + 7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [] + assert api.custom_data == [] # already pending, mark untouched + assert api.replies == [messages.STILL_PENDING] + + +async def test_dm_while_pending_and_invitation_never_sent_resends_it(ctx, api): + api.contacts.append( + make_contact(7, "Alex", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [(7, messages.INVITATION_TEXT)] + assert api.custom_data == [] # already pending, mark untouched + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_when_already_active_is_a_noop(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.ALREADY_ACTIVE] + + +async def test_dm_ignores_non_group_message(ctx, api): + msg = make_group_message(api, make_member(1), "/dm") + msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"} + await handlers.dm(ctx, msg) + assert api.replies == [] and api.custom_data == [] + + +async def test_dm_recovers_when_contact_vanished(ctx, api): + # memberContactId points at a contact that no longer exists. + msg = make_group_message(api, make_member(1, contact_id=404, name="ghost"), "/dm") + await handlers.dm(ctx, msg) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + + +async def test_dm_replies_command_failed_when_api_fails(ctx, api): + # api_create_member_contact has no try/except of its own. + api.fail_on.add("api_create_member_contact") + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_dm_lets_unexpected_errors_propagate(ctx, api): + async def boom(contact_id, message=None): + raise RuntimeError("network on fire") + + api.api_send_member_contact_invitation = boom + msg = make_group_message(api, make_member(1, name="Alex"), "/dm") + with pytest.raises(RuntimeError): + await handlers.dm(ctx, msg) + + +async def test_dm_after_leave_does_not_promote_unconnected_contact(ctx, api): + member = make_member(1, name="Alex") + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + assert api.created_member_contacts == [(ROSTER_GROUP_ID, 1)] + created_contact_id = api.contacts[-1]["contactId"] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + + # The core sets memberContactId as soon as the contact exists. + member["memberContactId"] = created_contact_id + await handlers.leave(ctx, make_group_message(api, member, "/leave")) + api.custom_data.clear() + + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + written = api.custom_data[-1][1]["supportBotLight"]["roster"] if api.custom_data else None + assert written != "active", "unconnected contact must never be marked active" + + +async def test_dm_after_leave_still_promotes_on_accept(ctx, api): + """/dm -> /leave -> /dm -> accept must end up active. + + /leave clears our roster mark, but the core's contactGrpInvSent survives and + cannot be unset, so the second /dm must re-establish the pending mark + or the eventual acceptance has nothing to promote. + """ + member = make_member(1, name="Alex") + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + contact_id = api.contacts[-1]["contactId"] + # The fake names the contact after the group member id, not the member. + contact_name = api.contacts[-1]["profile"]["displayName"] + member["memberContactId"] = contact_id + + await handlers.leave(ctx, make_group_message(api, member, "/leave")) + await handlers.dm(ctx, make_group_message(api, member, "/dm")) + assert api.replies[-1] == messages.STILL_PENDING + + for c in api.contacts: + if c["contactId"] == contact_id: + c["activeConn"] = {"connStatus": {"type": "ready"}} + await handlers.contact_ready(ctx, contact_id) + assert [e.name for e in await roster.active(api, USER_ID)] == [contact_name] + + +async def test_contact_connected_promotes_pending(ctx, api): + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}, + connected=True, + ) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data[-1][1]["supportBotLight"] == { + "roster": "active", + "since": "2026-08-13", # original ask time preserved + } + assert api.sent == [(["group", ROSTER_GROUP_ID], "Now on the roster: Alex")] + + +async def test_contact_connected_ignores_unmarked_contact(ctx, api): + api.contacts.append(make_contact(7, "stranger")) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_ignores_already_active(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_does_not_promote_an_unusable_connection(ctx, api): + # The event says the connection is up; the contact record says otherwise. + # active() consults the record, so promoting here would list somebody the + # bot cannot reach. + api.contacts.append( + make_contact( + 7, + "Alex", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + conn_status="deleted", + ) + ) + await handlers.contact_ready(ctx, 7) + assert api.custom_data == [] and api.sent == [] + + +async def test_contact_connected_for_unknown_contact_is_a_noop(ctx, api): + await handlers.contact_ready(ctx, 999) + assert api.custom_data == [] and api.sent == [] + + +async def test_a_failed_revocation_is_reported_to_the_roster_group(ctx, api): + # Revocation is the access-control path; a silent failure would leave the + # operator reading /list as the truth. + api.contacts.append( + make_contact(7, "Alex", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + api.fail_on.add("api_set_contact_custom_data") + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="Alex")) + assert api.sent[-1][1] == messages.REVOKE_FAILED.format(name="Alex") + + +async def test_list_renders_both_states(ctx, api): + api.contacts += [ + make_contact( + 1, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-08-13"}}, + connected=True, + ), + make_contact(2, "Alex", {"supportBotLight": {"roster": "pending", "since": "2026-08-13"}}), + ] + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert "On the roster (1):" in api.replies[0] + assert "Contact request not accepted (1):" in api.replies[0] + + +async def test_list_when_empty(ctx, api): + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert api.replies == [messages.ROSTER_EMPTY] + + +async def test_list_replies_command_failed_when_api_fails(ctx, api): + api.fail_on.add("api_list_contacts") + await handlers.list_roster(ctx, make_group_message(api, make_member(1), "/list")) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_leave_clears_the_mark(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.custom_data[-1] == (7, None) + assert api.replies == [messages.LEFT] + + +async def test_leave_when_not_on_roster(ctx, api): + api.contacts.append(make_contact(7, "sh")) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.NOT_ON_ROSTER] + + +async def test_leave_without_any_contact(ctx, api): + msg = make_group_message(api, make_member(1, name="stranger"), "/leave") + await handlers.leave(ctx, msg) + assert api.replies == [messages.NOT_ON_ROSTER] + assert api.created_member_contacts == [] # /leave never creates a contact + + +async def test_leave_ignores_non_group_message(ctx, api): + msg = make_group_message(api, make_member(1, contact_id=7), "/leave") + msg.chat_item["chatItem"]["chatDir"] = {"type": "directRcv"} + await handlers.leave(ctx, msg) + assert api.replies == [] and api.custom_data == [] + + +async def test_leave_replies_command_failed_when_api_fails(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}) + ) + api.fail_on.add("api_list_contacts") + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/leave") + await handlers.leave(ctx, msg) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_help_replies_with_help_text(ctx, api): + await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help")) + assert api.replies == [messages.HELP] + + +async def test_help_replies_command_failed_when_send_fails(ctx, api): + # help_cmd's only action is the reply, so the first send must fail alone. + calls = 0 + original = api.api_send_text_reply + + async def flaky_once(chat_item, text): + nonlocal calls + calls += 1 + if calls == 1: + raise ChatCommandError("boom", {"type": "chatCmdError"}) + return await original(chat_item, text) + + api.api_send_text_reply = flaky_once + await handlers.help_cmd(ctx, make_group_message(api, make_member(1), "/help")) + assert api.replies == [messages.COMMAND_FAILED] + + +async def test_dm_redrives_a_contact_that_never_connected(ctx, api): + # Marked active but never usable: the member contact still exists, so the + # invitation can be re-sent. + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.invitations == [(7, messages.INVITATION_TEXT)] + assert api.replies == [messages.INVITATION_SENT] + + +async def test_dm_reports_a_connection_that_is_gone_for_good(ctx, api): + # The person deleted the bot after connecting. The core cleared + # contactGroupMemberId, so no invitation can be sent and telling them to + # retry would be false. + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_member_id=None, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.invitations == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_on_a_dead_contact_with_an_invitation_outstanding_waits(ctx, api): + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_inv_sent=True, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.invitations == [] + assert api.replies == [messages.STILL_PENDING] + + +async def test_dm_finds_a_contact_created_since_the_message_was_built(ctx, api): + # Two commands sent in quick succession both carry the pre-/dm snapshot, in + # which memberContactId is still None. + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, grp_inv_sent=True + ) + ) + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")] + stale = make_member(1, name="sh") # no memberContactId + await handlers.dm(ctx, make_group_message(api, stale, "/dm")) + assert api.created_member_contacts == [] + assert api.replies == [messages.STILL_PENDING] + + +async def test_leave_finds_a_contact_created_since_the_message_was_built(ctx, api): + api.contacts.append( + make_contact(7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}) + ) + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="sh")] + stale = make_member(1, name="sh") + await handlers.leave(ctx, make_group_message(api, stale, "/leave")) + assert api.custom_data[-1] == (7, None) + assert api.replies == [messages.LEFT] + + +async def test_member_gone_takes_them_off_the_roster(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh")) + assert api.custom_data[-1] == (7, None) + assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="sh") + + +async def test_member_gone_ignores_other_groups(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.member_gone(ctx, 999, make_member(1, contact_id=7, name="sh")) + assert api.custom_data == [] and api.sent == [] + + +async def test_member_gone_ignores_someone_not_on_the_roster(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + await handlers.member_gone(ctx, ROSTER_GROUP_ID, make_member(1, contact_id=7, name="sh")) + assert api.custom_data == [] and api.sent == [] + + +async def test_reconcile_promotes_an_acceptance_missed_while_stopped(ctx, api): + api.members[ROSTER_GROUP_ID] = [ + make_member(1, contact_id=1), + make_member(2, contact_id=2), + make_member(3, contact_id=3), + ] + api.contacts += [ + make_contact( + 1, + "accepted", + {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}}, + connected=True, + ), + make_contact( + 2, "waiting", {"supportBotLight": {"roster": "pending", "since": "2026-01-01"}} + ), + make_contact( + 3, + "already", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + connected=True, + ), + ] + await handlers.reconcile_roster(ctx) + assert api.custom_data == [ + (1, {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}) + ] + assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="accepted") + + +async def test_contact_ready_failure_does_not_escape(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "pending", "since": "x"}}, connected=True + ) + ) + api.fail_on.add("api_set_contact_custom_data") + await handlers.contact_ready(ctx, 7) # must not raise + assert api.sent == [] + + +async def test_reconcile_removes_someone_who_left_while_stopped(ctx, api): + # api_list_members keeps the row and only changes its status. + api.members[ROSTER_GROUP_ID] = [ + make_member(1, contact_id=5, name="stays"), + make_member(2, contact_id=7, name="gone", status="left"), + ] + api.contacts += [ + make_contact( + 5, "stays", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + await handlers.reconcile_roster(ctx) + assert api.custom_data[-1] == (7, None) + assert api.sent[-1][1] == messages.REMOVED_FROM_GROUP.format(name="gone") + + +async def test_reconcile_failure_does_not_stop_startup(ctx, api): + api.fail_on.add("api_list_members") + await handlers.reconcile_roster(ctx) # must not raise + + +async def test_reconcile_continues_past_a_failing_contact(ctx, api): + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=5, name="stays")] + api.contacts += [ + make_contact( + 7, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact( + 8, "b", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + ] + attempts: list[int] = [] + + async def flaky(contact_id, custom_data=None): + attempts.append(contact_id) + raise ChatCommandError("nope", {"type": "chatCmdError"}) + + api.api_set_contact_custom_data = flaky + await handlers.reconcile_roster(ctx) + assert attempts == [7, 8], "a failure on one contact must not abandon the rest" + + +async def test_dm_on_a_dead_contact_leaves_the_mark_alone(ctx, api): + api.contacts.append( + make_contact( + 7, + "sh", + {"supportBotLight": {"roster": "active", "since": "2026-01-01"}}, + grp_member_id=None, + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.custom_data == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_reconcile_skips_revocation_when_the_marker_is_ambiguous(ctx, api): + # A second marked group means ensure_roster_group may have picked the wrong + # one; deleting every mark on that basis is not recoverable. + api.groups.append( + make_group( + 99, + {"displayName": "Invite roster", "fullName": ""}, + custom_data={"supportBotLight": {"group": "roster"}}, + ) + ) + api.members[ROSTER_GROUP_ID] = [] + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.reconcile_roster(ctx) + assert api.custom_data == [] + + +async def test_reconcile_revokes_even_when_the_last_member_leaves(ctx, api): + api.members[ROSTER_GROUP_ID] = [make_member(1, contact_id=7, name="gone", status="left")] + api.contacts.append( + make_contact( + 7, "gone", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + await handlers.reconcile_roster(ctx) + assert api.custom_data[-1] == (7, None) + + +async def test_dm_accepts_a_connection_the_member_started(ctx, api): + # Tapping "connect directly" on the bot's profile leaves a prepared contact + # with no contactGroupMemberId, which looks identical to a dead one. + api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="prepared")) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [7] + assert api.custom_data[-1][1]["supportBotLight"]["roster"] == "pending" + assert api.replies == [messages.ACCEPTING] + + +async def test_dm_still_reports_a_genuinely_dead_contact(ctx, api): + # No prepared connection and no groupDirectInv: nothing to accept. + api.contacts.append(make_contact(7, "sh", grp_member_id=None, conn_status="deleted")) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_does_not_re_accept_a_connection_already_started(ctx, api): + # The core keeps groupDirectInv after acceptance and rejects a second + # accept with "connection already started". + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.ACCEPTING] + + +async def test_dm_accepts_an_invitation_not_yet_started(ctx, api): + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="prepared") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": False, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [7] + + +async def test_dm_reports_a_handshake_in_progress_as_connecting(ctx, api): + # The core clears contactGroupMemberId when the peer accepts and only later + # reports ready. Calling that gone would send the member to advice that + # tears the completing connection down. + api.contacts.append( + make_contact( + 7, + "Kit", + {"supportBotLight": {"roster": "pending", "since": "x"}}, + grp_member_id=None, + conn_status="accepted", + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.CONNECTING] + assert api.accepted_member_contacts == [] + + +async def test_dm_marks_an_unmarked_member_whose_connection_is_completing(ctx, api): + # ACCEPTING and CONNECTING both promise a roster place, and contact_ready + # delivers it only for a pending mark. + api.contacts.append(make_contact(7, "Kit", grp_member_id=None, conn_status="accepted")) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert roster.entry_of(api.contacts[0]) is not None + + for c in api.contacts: + c["activeConn"] = {"connStatus": {"type": "ready"}} + await handlers.contact_ready(ctx, 7) + assert [e.name for e in await roster.active(api, USER_ID)] == ["Kit"] + + +async def test_dm_marks_an_unmarked_member_whose_accept_already_started(ctx, api): + contact = make_contact(7, "Kit", grp_member_id=None, conn_status="joined") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Kit"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.ACCEPTING] + assert roster.entry_of(api.contacts[0]) is not None + + +async def test_dm_reports_a_dead_connection_even_after_we_accepted(ctx, api): + # The core never clears groupDirectInv, so the started flag alone would + # promise progress on a connection the peer has since deleted. + contact = make_contact(7, "Alice", grp_member_id=None, conn_status="deleted") + contact["groupDirectInv"] = { + "groupDirectInvLink": "x", + "groupDirectInvStartedConnection": True, + } + api.contacts.append(contact) + msg = make_group_message(api, make_member(1, contact_id=7, name="Alice"), "/dm") + await handlers.dm(ctx, msg) + assert api.accepted_member_contacts == [] + assert api.replies == [messages.CONNECTION_LOST] + + +async def test_dm_fast_path_announces_the_arrival(ctx, api): + api.contacts.append(make_contact(7, "sh", connected=True)) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.replies == [messages.ADDED] + assert api.sent[-1][1] == messages.NOW_ACTIVE.format(name="sh") + + +async def test_dm_on_an_already_active_member_announces_nothing(ctx, api): + api.contacts.append( + make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ) + ) + msg = make_group_message(api, make_member(1, contact_id=7, name="sh"), "/dm") + await handlers.dm(ctx, msg) + assert api.sent == [] diff --git a/apps/simplex-support-bot-light/tests/test_health.py b/apps/simplex-support-bot-light/tests/test_health.py new file mode 100644 index 0000000000..d818465175 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_health.py @@ -0,0 +1,236 @@ +"""The monitoring endpoint: real sockets, no libsimplex.""" + +import asyncio + +import pytest +from simplex_chat.core import ChatAPIError + +from support_bot_light import health +from support_bot_light.config import Config, ConfigError, Health +from support_bot_light.context import BotContext +from tests.conftest import ROSTER_GROUP_ID, USER_ID + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") + + +class ProbeApi: + """The one call the probe makes, with the outcomes it has to distinguish.""" + + def __init__(self, error: bool = False, delay: float = 0.0): + self.error = error + self.delay = delay + self.calls = 0 + + async def api_list_members(self, group_id: int) -> list[dict]: + self.calls += 1 + assert group_id == ROSTER_GROUP_ID + if self.delay: + await asyncio.sleep(self.delay) + if self.error: + raise ChatAPIError("core is unhappy", {"type": "chatCmdError"}) + return [] + + +def context(api) -> BotContext: + return BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + + +async def request(server: asyncio.Server, line: str) -> str: + """Send one request line to a running endpoint and read the whole reply.""" + port = server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + try: + writer.write(f"{line}\r\nHost: localhost\r\n\r\n".encode()) + await writer.drain() + return (await reader.read()).decode("latin-1") + finally: + writer.close() + await writer.wait_closed() + + +async def endpoint(api) -> asyncio.Server: + # Port 0: the OS picks a free one, so tests never collide. + return await health.serve(context(api), Health(host="127.0.0.1", port=0)) + + +async def test_reports_ok_while_the_core_answers(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + assert reply.endswith('{"status":"ok"}\n') + assert api.calls == 1 + + +async def test_reports_unavailable_when_the_core_errors(): + server = await endpoint(ProbeApi(error=True)) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503 Service Unavailable") + + +async def test_a_slow_core_times_out_rather_than_hanging(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=5) + server = await endpoint(api) + try: + reply = await asyncio.wait_for(request(server, "GET /health HTTP/1.1"), 2) + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503") + + +async def test_a_concurrent_request_does_not_start_a_second_probe(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.5) + api = ProbeApi(delay=0.3) + server = await endpoint(api) + try: + first = asyncio.create_task(request(server, "GET /health HTTP/1.1")) + await asyncio.sleep(0.05) + second = await request(server, "GET /health HTTP/1.1") + assert (await first).startswith("HTTP/1.1 200 OK") + finally: + server.close() + await server.wait_closed() + assert second.startswith("HTTP/1.1 200 OK") # it waits on the same probe + assert api.calls == 1 + + +async def test_polling_a_stalled_core_never_starts_a_second_query(monkeypatch): + # Each abandoned query keeps a worker in the loop's default executor, which + # the receive loop also uses: a query per poll would take the bot's own + # traffic down with the core. + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=3) + server = await endpoint(api) + try: + for _ in range(5): + reply = await request(server, "GET /health HTTP/1.1") + assert reply.startswith("HTTP/1.1 503") + assert api.calls == 1 # one query outstanding, not five + finally: + server.close() + await server.wait_closed() + + +async def test_the_next_poll_after_recovery_starts_a_fresh_query(monkeypatch): + monkeypatch.setattr(health, "PROBE_TIMEOUT", 0.05) + api = ProbeApi(delay=0.2) + server = await endpoint(api) + try: + assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 503") + await asyncio.sleep(0.3) # the abandoned query completes + api.delay = 0 # the core recovers + assert (await request(server, "GET /health HTTP/1.1")).startswith("HTTP/1.1 200") + finally: + server.close() + await server.wait_closed() + assert api.calls == 2 + + +async def test_a_core_that_raises_anything_reports_unavailable(): + # A malformed reply or a missing controller is what this exists to report, + # and neither arrives as a chat error. + class Broken(ProbeApi): + async def api_list_members(self, group_id: int) -> list[dict]: + self.calls += 1 + raise RuntimeError("controller not initialized") + + api = Broken() + server = await endpoint(api) + try: + reply = await request(server, "GET /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 503") + + +async def test_an_oversized_request_line_is_answered(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /" + "x" * (health.MAX_REQUEST_BYTES + 10)) + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 400") + assert api.calls == 0 + + +async def test_head_is_answered_without_a_body(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "HEAD /health HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + assert "{" not in reply + assert api.calls == 1 + + +@pytest.mark.parametrize( + ("line", "status"), + [ + ("GET / HTTP/1.1", "404"), + ("GET /healthz HTTP/1.1", "404"), + ("POST /health HTTP/1.1", "405"), + ("nonsense", "404"), + ], +) +async def test_only_get_on_the_health_path_is_answered(line, status): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, line) + finally: + server.close() + await server.wait_closed() + assert reply.startswith(f"HTTP/1.1 {status}") + assert api.calls == 0 + + +async def test_a_query_string_still_matches_the_path(): + api = ProbeApi() + server = await endpoint(api) + try: + reply = await request(server, "GET /health?from=monitor HTTP/1.1") + finally: + server.close() + await server.wait_closed() + assert reply.startswith("HTTP/1.1 200 OK") + + +async def test_a_configured_port_already_in_use_stops_the_bot(): + api = ProbeApi() + taken = await endpoint(api) + port = taken.sockets[0].getsockname()[1] + try: + with pytest.raises(ConfigError, match="cannot listen"): + await health.serve(context(api), Health("127.0.0.1", port, configured=True)) + finally: + taken.close() + await taken.wait_closed() + + +async def test_the_default_port_being_in_use_does_not_stop_the_bot(): + # Nothing asked for port 8080; an unrelated service on it is not a reason to + # refuse to answer chats. + api = ProbeApi() + taken = await endpoint(api) + port = taken.sockets[0].getsockname()[1] + try: + assert await health.serve(context(api), Health("127.0.0.1", port)) is None + finally: + taken.close() + await taken.wait_closed() diff --git a/apps/simplex-support-bot-light/tests/test_main.py b/apps/simplex-support-bot-light/tests/test_main.py new file mode 100644 index 0000000000..c49b01dbcb --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_main.py @@ -0,0 +1,312 @@ +import pytest +from simplex_chat import Bot, BotProfile, SqliteDb +from simplex_chat.core import ChatAPIError + +from support_bot_light import handlers, health +from support_bot_light.__main__ import ( + _register, + _run, + _serve, + bot_profile, + build_bot, + startup_error, +) +from support_bot_light.config import Config, Health +from support_bot_light.context import BotContext +from tests.conftest import ( + ROSTER_GROUP_ID, + USER_ID, + make_group, + make_group_message, + make_member, +) + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") +OTHER_GROUP_ID = 99 + + +def plain_bot() -> Bot: + return Bot( + profile=BotProfile(display_name="Support"), + db=SqliteDb(file_prefix="./unused"), + welcome="hi", + ) + + +def registered(api) -> Bot: + bot = plain_bot() + ctx = BotContext(api=api, user_id=USER_ID, roster_group_id=ROSTER_GROUP_ID, config=CONFIG) + _register(bot, ctx) + return bot + + +def test_registers_all_four_commands(api): + bot = registered(api) + keywords = [names for names, _predicate, _handler in bot._command_handlers] + assert keywords == [("dm",), ("list",), ("leave",), ("help",)] + + +def test_registers_connection_and_business_events(api): + bot = registered(api) + assert set(bot._event_handlers) == { + "acceptingBusinessRequest", + "contactConnected", + "contactSndReady", + "deletedMember", + "leftMember", + } + + +def test_commands_match_in_the_roster_group(api): + bot = registered(api) + msg = make_group_message(api, make_member(1), "/dm", group_id=ROSTER_GROUP_ID) + _names, predicate, _handler = bot._command_handlers[0] + assert predicate(msg) is True + + +def test_commands_do_not_match_in_other_groups(api): + # A /dm typed inside a business chat must not be acted on. + bot = registered(api) + msg = make_group_message(api, make_member(1), "/dm", group_id=OTHER_GROUP_ID) + _names, predicate, _handler = bot._command_handlers[0] + assert predicate(msg) is False + + +def test_bot_profile_carries_display_name_and_image(): + profile = bot_profile( + Config("Support", "./x", "hi", "R", "owner", image="data:image/png;base64,AAA") + ) + assert profile.display_name == "Support" + assert profile.image == "data:image/png;base64,AAA" + + +def test_bot_profile_without_image(): + assert bot_profile(CONFIG).image is None + + +@pytest.mark.parametrize("index,keyword", [(0, "dm"), (1, "list"), (2, "leave"), (3, "help")]) +def test_every_command_is_scoped_to_the_roster_group(api, index, keyword): + # A /list answered in a business chat would show the roster to a customer. + bot = registered(api) + names, predicate, _handler = bot._command_handlers[index] + assert names == (keyword,) + inside = make_group_message(api, make_member(1), f"/{keyword}", group_id=ROSTER_GROUP_ID) + outside = make_group_message(api, make_member(1), f"/{keyword}", group_id=OTHER_GROUP_ID) + assert predicate(inside) is True + assert predicate(outside) is False + + +async def test_registered_handlers_call_the_matching_handler(api, monkeypatch): + # Registration bookkeeping alone would not catch /dm being wired to leave(). + bot = registered(api) + called: list[str] = [] + + def spy(name): + async def handler(_ctx, _msg): + called.append(name) + + return handler + + for name in ("dm", "list_roster", "leave", "help_cmd"): + monkeypatch.setattr(handlers, name, spy(name)) + for keywords, _predicate, handler in bot._command_handlers: + await handler(make_group_message(api, make_member(1), f"/{keywords[0]}"), None) + assert called == ["dm", "list_roster", "leave", "help_cmd"] + + +def test_a_taken_display_name_is_explained(): + # The core reports it as a bare errorStore; the cause is in the store error. + e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "duplicateName"}}) + assert "bot.display_name" in startup_error(e) + + +def test_any_other_chat_error_keeps_its_detail(): + e = ChatAPIError("chat command error: errorStore", {"storeError": {"type": "userNotFound"}}) + assert "userNotFound" in startup_error(e) + + +def test_a_rejected_command_is_quoted_as_the_core_wrote_it(): + # The core puts what the caller did wrong in the message, and the tag says + # nothing; printing the raw dict instead would bury it. + e = ChatAPIError( + "chat command error: error", + {"type": "error", "errorType": {"type": "commandError", "message": "Profile image"}}, + ) + assert startup_error(e) == "Profile image" + + +def test_an_error_without_detail_is_rendered_plainly(): + assert startup_error(ValueError("no active user after start")) == "no active user after start" + + +def test_the_bot_opens_a_business_address(): + # Without these two the address yields direct chats that nothing handles: + # acceptingBusinessRequest never fires and no roster is ever added. + bot = build_bot(CONFIG) + assert bot._business_address is True + assert bot._auto_accept is True + assert bot._welcome == "hi" + + +def test_the_bot_does_not_apply_its_profile_while_starting(): + # The name the core will accept is only knowable from the database, which + # nothing can read until the client has started. _apply_profile does it. + assert build_bot(CONFIG)._update_profile is False + + +class FakeBot: + """A Bot stand-in for _serve: an async context manager with an api.""" + + def __init__(self, api, sync_error: Exception | None = None): + self.api = api + self.profile = BotProfile(display_name="Support") + self.served = 0 + self.syncs = 0 + self.sync_error = sync_error + self.signal_handlers = 0 + self._command_handlers = [] + self._event_handlers = {} + self.stop_requested = False + self.stopped = False + + async def __aenter__(self): + return self + + async def __aexit__(self, *_exc): + return False + + def install_signal_handlers(self): + self.signal_handlers += 1 + + async def sync_profile(self) -> bool: + self.syncs += 1 + if self.sync_error is not None: + raise self.sync_error + return True + + def on_command(self, *_names, **_kw): + def register(handler): + self._command_handlers.append(handler) + return handler + + return register + + def on_event(self, tag): + def register(handler): + self._event_handlers.setdefault(tag, []).append(handler) + return handler + + return register + + async def serve_forever(self): + self.served += 1 + + def stop(self): + self.stopped = True + + +def serve_api(api): + """The fake api with the calls _serve makes before serving.""" + + async def api_get_active_user(): + return {"userId": USER_ID, "localDisplayName": "Support"} + + api.api_get_active_user = api_get_active_user + api.group_links[ROSTER_GROUP_ID] = "https://example.invalid/g#x" + api.groups.append( + make_group( + ROSTER_GROUP_ID, + {"displayName": "Invite roster", "fullName": ""}, + {"supportBotLight": {"group": "roster"}}, + ) + ) + return api + + +async def test_serve_wires_the_handlers_and_serves(api): + bot = FakeBot(serve_api(api)) + await _serve(CONFIG, bot) + assert bot.served == 1 + assert len(bot._command_handlers) == 4 # nothing is delivered without these + assert set(bot._event_handlers) == { + "acceptingBusinessRequest", + "contactConnected", + "contactSndReady", + "deletedMember", + "leftMember", + } + + +async def test_serve_reads_the_group_listing_once(api): + # It is the largest thing startup marshals and grows with every customer. + bot = FakeBot(serve_api(api)) + calls = {"n": 0} + original = api.api_list_groups + + async def counted(user_id, **kw): + calls["n"] += 1 + return await original(user_id, **kw) + + api.api_list_groups = counted + await _serve(CONFIG, bot) + assert calls["n"] == 2 # one for discovery, one shared by both passes + + +async def test_serve_does_not_begin_serving_after_a_signal(api): + bot = FakeBot(serve_api(api)) + bot.stop_requested = True + await _serve(CONFIG, bot) + assert bot.served == 0 + + +async def test_serve_closes_the_health_endpoint_afterwards(api): + config = Config("Support", "./x", "hi", "Invite roster", "owner", health=Health("127.0.0.1", 0)) + bot = FakeBot(serve_api(api)) + servers: list = [] + original = health.serve + + async def spy(ctx, cfg): + server = await original(ctx, cfg) + servers.append(server) + return server + + health.serve = spy + try: + await _serve(config, bot) + finally: + health.serve = original + assert servers and not servers[0].is_serving() + + +async def test_the_bot_serves_after_a_refused_rename(api, caplog): + # The core keeps display names unique; a refused one is not a reason to + # leave customers unanswered. + refused = ChatAPIError("x", {"storeError": {"type": "duplicateName"}}) + bot = FakeBot(serve_api(api), sync_error=refused) + await _serve(CONFIG, bot) + assert bot.served == 1 + assert "bot.display_name" in caplog.text + + +async def test_the_profile_is_applied_after_start(api, monkeypatch): + bot = FakeBot(serve_api(api)) + await _serve(CONFIG, bot) + assert bot.syncs == 1 + + +async def test_run_installs_signal_handlers_before_starting(monkeypatch): + # Startup runs migrations and address creation; a signal there would + # otherwise kill the process mid-write. + order: list[str] = [] + bot = FakeBot(None) + + def build(_config): + return bot + + async def serve(_config, b): + order.append(f"serve:{b.signal_handlers}") + + monkeypatch.setattr("support_bot_light.__main__.build_bot", build) + monkeypatch.setattr("support_bot_light.__main__._serve", serve) + await _run(CONFIG) + assert order == ["serve:1"] diff --git a/apps/simplex-support-bot-light/tests/test_messages.py b/apps/simplex-support-bot-light/tests/test_messages.py new file mode 100644 index 0000000000..e9b73ab8af --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_messages.py @@ -0,0 +1,85 @@ +from support_bot_light import messages +from support_bot_light.roster import RosterEntry + + +def entry(name, state, since="2026-08-13T09:00:00+00:00", reachable=True): + return RosterEntry(contact_id=1, name=name, state=state, since=since, reachable=reachable) + + +def test_render_roster_lists_active_and_pending(): + out = messages.render_roster([entry("sh", "active"), entry("Alex", "pending")]) + assert "On the roster (1):" in out + assert "• sh — since 2026-08-13" in out + assert "Contact request not accepted (1):" in out + assert "• Alex — asked 2026-08-13" in out + + +def test_render_roster_empty(): + assert messages.render_roster([]) == messages.ROSTER_EMPTY + + +def test_render_roster_omits_pending_section_when_none(): + out = messages.render_roster([entry("sh", "active")]) + assert "Waiting" not in out + + +def test_render_roster_omits_date_suffix_when_since_is_empty(): + out = messages.render_roster([entry("sh", "active", since="")]) + assert out == "On the roster (1):\n • sh" + assert "since" not in out + + +def test_render_roster_formats_date_suffix(): + out = messages.render_roster([entry("sh", "active")]) + assert out == "On the roster (1):\n • sh — since 2026-08-13" + + +def test_invite_log_lists_added_names(): + assert messages.invite_log("Alex", ["sh", "Narasimha"], []) == ( + "Connected: Alex → added sh, Narasimha" + ) + + +def test_invite_log_reports_failures(): + line = messages.invite_log("Alex", ["sh"], ["Narasimha"]) + assert line == "Connected: Alex → added sh (failed: Narasimha)" + + +def test_help_mentions_every_command(): + for keyword in ("dm", "list", "leave"): + assert f"/{keyword}" in messages.HELP + + +def test_render_roster_separates_unreachable_members(): + out = messages.render_roster( + [entry("live", "active"), entry("dead", "active", reachable=False)] + ) + assert "On the roster (1):" in out + assert "Not reachable, not being added (1):" in out + assert "• dead" in out + + +def test_render_roster_caps_long_sections(): + entries = [entry(f"n{i}", "active") for i in range(messages.MAX_LISTED + 12)] + out = messages.render_roster(entries) + assert f"On the roster ({messages.MAX_LISTED + 12}):" in out + assert "… and 12 more" in out + assert out.count("•") == messages.MAX_LISTED + assert len(out.encode()) < 15000 + + +def test_render_roster_bounds_the_whole_reply_in_bytes(): + # Names are capped in characters, so CJK can overrun a byte limit even with + # every section capped. + entries = [entry("漢" * 50, "active") for _ in range(messages.MAX_LISTED)] + entries += [entry("漢" * 50, "pending") for _ in range(messages.MAX_LISTED)] + out = messages.render_roster(entries) + assert len(out.encode()) <= messages.MAX_REPLY_BYTES + assert out.endswith(messages.TRUNCATED) + + +def test_invite_log_is_bounded_in_bytes(): + names = ["漢" * 50 for _ in range(100)] + out = messages.invite_log("Alex", names, []) + assert len(out.encode()) <= messages.MAX_REPLY_BYTES + assert out.endswith(messages.TRUNCATED) diff --git a/apps/simplex-support-bot-light/tests/test_roster.py b/apps/simplex-support-bot-light/tests/test_roster.py new file mode 100644 index 0000000000..a4dd17f2b3 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_roster.py @@ -0,0 +1,154 @@ +from support_bot_light import roster +from tests.conftest import USER_ID, make_contact + + +def test_entry_of_reads_active_mark(): + contact = make_contact( + 7, "sh", {"supportBotLight": {"roster": "active", "since": "2026-08-13"}} + ) + entry = roster.entry_of(contact) + assert entry is not None + assert (entry.contact_id, entry.name, entry.state, entry.since) == ( + 7, + "sh", + "active", + "2026-08-13", + ) + + +def test_entry_of_returns_none_without_custom_data(): + assert roster.entry_of(make_contact(7, "sh")) is None + + +def test_entry_of_ignores_other_namespaces(): + assert roster.entry_of(make_contact(7, "sh", {"otherBot": {"roster": "active"}})) is None + + +def test_entry_of_ignores_unknown_state(): + contact = make_contact(7, "sh", {"supportBotLight": {"roster": "banned"}}) + assert roster.entry_of(contact) is None + + +def test_entry_of_ignores_non_dict_mark(): + assert roster.entry_of(make_contact(7, "sh", {"supportBotLight": "oops"})) is None + + +async def test_mark_preserves_other_keys(api): + contact = make_contact(7, "sh", {"otherBot": {"keep": 1}}) + api.contacts.append(contact) + await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00") + contact_id, data = api.custom_data[-1] + assert contact_id == 7 + assert data["otherBot"] == {"keep": 1} + assert data["supportBotLight"] == {"roster": "active", "since": "2026-08-13T09:00:00+00:00"} + + +async def test_mark_does_not_mutate_the_callers_contact(api): + original = {"otherBot": {"keep": 1}} + contact = make_contact(7, "sh", original) + api.contacts.append(contact) + await roster.mark(api, contact, "active", "2026-08-13T09:00:00+00:00") + # mark() builds a new blob; the caller's dict must be untouched. + assert original == {"otherBot": {"keep": 1}} + assert "supportBotLight" not in original + + +async def test_unmark_removes_only_our_key(api): + contact = make_contact( + 7, "sh", {"supportBotLight": {"roster": "active"}, "otherBot": {"keep": 1}} + ) + api.contacts.append(contact) + await roster.unmark(api, contact) + assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}}) + + +async def test_unmark_clears_blob_when_nothing_left(api): + contact = make_contact(7, "sh", {"supportBotLight": {"roster": "active"}}) + api.contacts.append(contact) + await roster.unmark(api, contact) + # None clears the column rather than writing an empty object. + assert api.custom_data[-1] == (7, None) + + +async def test_unmark_of_an_unmarked_contact_takes_nothing_away(api): + contact = make_contact(7, "sh", {"otherBot": {"keep": 1}}) + api.contacts.append(contact) + await roster.unmark(api, contact) + assert api.custom_data[-1] == (7, {"otherBot": {"keep": 1}}) + + +async def test_load_returns_marked_contacts_sorted_case_insensitively(api): + api.contacts += [ + make_contact(1, "Zoe", {"supportBotLight": {"roster": "active", "since": "x"}}), + make_contact(2, "bob", {"supportBotLight": {"roster": "pending", "since": "x"}}), + make_contact(3, "unmarked"), + ] + entries = await roster.load(api, USER_ID) + assert [e.name for e in entries] == ["bob", "Zoe"] + + +async def test_active_filters_pending(api): + api.contacts += [ + make_contact( + 1, "a", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(2, "b", {"supportBotLight": {"roster": "pending", "since": "x"}}), + ] + assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1] + + +async def test_find_contact_returns_none_when_absent(api): + assert await roster.find_contact(api, USER_ID, 99) is None + + +async def test_find_contact_returns_match(api): + api.contacts.append(make_contact(7, "sh")) + found = await roster.find_contact(api, USER_ID, 7) + assert found is not None and found["contactId"] == 7 + + +def test_utc_now_is_iso_with_offset(): + now = roster.utc_now() + assert now.endswith("+00:00") and "T" in now + + +async def test_active_excludes_a_marked_contact_that_is_no_longer_usable(api): + # The person deleted the bot: the mark survives but api_add_member would + # fail for them on every business chat. + api.contacts += [ + make_contact( + 1, "live", {"supportBotLight": {"roster": "active", "since": "x"}}, connected=True + ), + make_contact(2, "dead", {"supportBotLight": {"roster": "active", "since": "x"}}), + ] + assert [e.contact_id for e in await roster.active(api, USER_ID)] == [1] + + +def test_entry_name_is_sanitised(): + contact = make_contact(7, "a\nb", {"supportBotLight": {"roster": "active", "since": "x"}}) + entry = roster.entry_of(contact) + assert entry is not None + assert entry.name == "a b" + + +def test_entry_of_survives_a_contact_with_no_profile(): + entry = roster.entry_of( + {"contactId": 7, "customData": {"supportBotLight": {"roster": "active", "since": "x"}}} + ) + assert entry is not None + assert entry.name == "(unnamed)" + + +def test_contact_name_prefers_the_local_display_name(): + # The core makes localDisplayName unique per user; two peers calling + # themselves "sh" render as "sh" and "sh_1", which is what the roster and + # the log must show. + contact = make_contact(1, "sh_1") + contact["profile"]["displayName"] = "sh" + assert roster.contact_name(contact) == "sh_1" + + +def test_contact_name_falls_back_to_the_profile(): + contact = make_contact(1, "sh") + del contact["localDisplayName"] + assert roster.contact_name(contact) == "sh" diff --git a/apps/simplex-support-bot-light/tests/test_setup.py b/apps/simplex-support-bot-light/tests/test_setup.py new file mode 100644 index 0000000000..41c383478f --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_setup.py @@ -0,0 +1,224 @@ +import asyncio +import logging + +from support_bot_light import commands, setup +from support_bot_light.config import Config +from tests.conftest import ROSTER_GROUP_ID, USER_ID, make_group + +CONFIG = Config("Support", "./x", "hi", "Invite roster", "owner") +MARKER = {"supportBotLight": {"group": "roster"}} + + +async def test_creates_group_when_none_marked(api, caplog): + caplog.set_level(logging.INFO) + group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert group_id == ROSTER_GROUP_ID + profile = api.new_groups[0] + assert profile["displayName"] == "Invite roster" + assert profile["groupPreferences"]["directMessages"] == {"enable": "on"} + assert profile["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS) + assert api.group_custom_data == [(ROSTER_GROUP_ID, MARKER)] + assert api.links == [ROSTER_GROUP_ID] # created exactly once + assert api.group_links[ROSTER_GROUP_ID] in caplog.text + + +async def test_finds_existing_group_by_marker(api): + profile = { + "displayName": "renamed by a human", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.new_groups == [] # not recreated + assert api.profile_updates == [] # commands already match — no broadcast + + +async def test_found_group_with_link_logs_it_without_recreating(api, caplog): + caplog.set_level(logging.INFO) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.links == [] # fetched, not recreated + assert "https://simplex.chat/contact#/?v=2&group=77" in caplog.text + + +async def test_found_group_without_link_recreates_and_logs_it(api, caplog): + # Crash window between marking the group and creating its link. + caplog.set_level(logging.INFO) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + assert api.links == [77] # recovered by creating a new link + assert api.group_links[77] in caplog.text + + +async def test_group_link_get_failure_with_link_present_does_not_block_startup(api, caplog): + # The create fallback hits the unique link index. A missing link must not + # stop the bot starting. + caplog.set_level(logging.WARNING) + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + api.group_links[77] = "https://simplex.chat/contact#/?v=2&group=77" + api.fail_on.add("api_get_group_link_str") + api.fail_on.add("api_create_group_link") + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 77 + + +async def test_pushes_commands_when_they_differ(api): + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": {"directMessages": {"enable": "on"}, "commands": []}, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert len(api.profile_updates) == 1 + group_id, sent = api.profile_updates[0] + assert group_id == 77 + assert sent["groupPreferences"]["commands"] == commands.to_wire(commands.COMMANDS) + + +async def test_pushed_profile_keeps_existing_display_name(api): + profile = { + "displayName": "renamed by a human", + "fullName": "", + "groupPreferences": {"directMessages": {"enable": "on"}, "commands": []}, + } + api.groups.append(make_group(77, profile, custom_data=MARKER)) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + _, sent = api.profile_updates[0] + # Syncing commands must not silently rename a group the operator renamed. + assert sent["displayName"] == "renamed by a human" + + +async def test_ignores_groups_without_the_marker(api): + api.groups.append(make_group(88, {"displayName": "Invite roster", "fullName": ""})) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] # name match alone must not be trusted + + +async def test_ignores_groups_with_a_foreign_marker(api): + api.groups.append( + make_group( + 88, {"displayName": "x", "fullName": ""}, custom_data={"otherBot": {"group": "roster"}} + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] + + +async def test_ignores_groups_with_the_wrong_marker_value(api): + # Right namespace, wrong marker. + api.groups.append( + make_group( + 88, + {"displayName": "x", "fullName": ""}, + custom_data={"supportBotLight": {"group": "archive"}}, + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + assert api.new_groups != [] + + +async def test_ignores_groups_with_a_non_dict_marker(api): + api.groups.append( + make_group( + 88, {"displayName": "x", "fullName": ""}, custom_data={"supportBotLight": "roster"} + ) + ) + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == ROSTER_GROUP_ID + + +async def test_warns_and_picks_one_when_two_groups_are_marked(api, caplog): + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "on"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups += [ + make_group(20, profile, custom_data=MARKER), + make_group(21, profile, custom_data=MARKER), + ] + api.group_links[20] = "https://simplex.chat/#g20" + with caplog.at_level("WARNING"): + assert await setup.ensure_roster_group(api, USER_ID, CONFIG) == 20 + assert "2 groups carry the roster marker" in caplog.text + assert api.new_groups == [] + + +async def test_a_group_the_bot_has_left_is_not_reused(api): + # Nothing would ever be delivered there, and the marker would keep a + # replacement from being created. + api.groups.append( + make_group( + 77, + {"displayName": "old roster", "fullName": ""}, + {"supportBotLight": {"group": "roster"}}, + membership_status="removed", + ) + ) + group_id = await setup.ensure_roster_group(api, USER_ID, CONFIG) + assert group_id != 77 + assert api.new_groups # a live roster group was created instead + + +async def test_direct_messages_is_restored_when_an_owner_switches_it_off(api): + # api_create_member_contact fails without it, so /dm would fail forever with + # nothing to explain it. + profile = { + "displayName": "Invite roster", + "fullName": "", + "groupPreferences": { + "directMessages": {"enable": "off"}, + "commands": commands.to_wire(commands.COMMANDS), + }, + } + api.groups.append(make_group(77, profile, {"supportBotLight": {"group": "roster"}})) + await setup.ensure_roster_group(api, USER_ID, CONFIG) + pushed = api.profile_updates[-1][1]["groupPreferences"] + assert pushed["directMessages"] == {"enable": "on"} + assert pushed["commands"] == commands.to_wire(commands.COMMANDS) + + +async def test_a_profile_push_that_cannot_return_does_not_hang_startup(api, monkeypatch): + # The core's view queue is bounded and nothing drains it until the bot + # serves, so this write can block until it does. + monkeypatch.setattr(setup, "PROFILE_PUSH_TIMEOUT", 0.05) + + async def never_returns(group_id, profile): + await asyncio.sleep(10) + + monkeypatch.setattr(api, "api_update_group_profile", never_returns) + api.groups.append(make_group(77, {"displayName": "r", "fullName": ""}, MARKER)) + group_id = await asyncio.wait_for(setup.ensure_roster_group(api, USER_ID, CONFIG), 2) + assert group_id == 77 diff --git a/apps/simplex-support-bot-light/tests/test_text.py b/apps/simplex-support-bot-light/tests/test_text.py new file mode 100644 index 0000000000..b9a4602b58 --- /dev/null +++ b/apps/simplex-support-bot-light/tests/test_text.py @@ -0,0 +1,50 @@ +from support_bot_light.text import MAX_NAME, UNNAMED, safe_name + + +def test_collapses_newlines_so_a_name_cannot_forge_a_line(): + assert safe_name("AAA\n • ceo@example.com — since 2020-01-01") == ( + "AAA • ceo@example.com — since 2020-01-01" + ) + assert "\n" not in safe_name("a\r\nb\tc") + + +def test_truncates_a_long_name(): + out = safe_name("X" * 14000) + assert len(out) == MAX_NAME + assert out.endswith("…") + + +def test_strips_non_printable_characters(): + assert safe_name("bob\x00\x07") == "bob" + + +def test_blank_and_whitespace_only_names(): + assert safe_name("") == UNNAMED + assert safe_name(" \n ") == UNNAMED + + +def test_leaves_an_ordinary_name_alone(): + assert safe_name("Narasimha") == "Narasimha" + + +def test_strips_invisible_but_printable_characters(): + # Hangul fillers and Braille blanks are Lo/So, so isprintable() lets them + # through while they render as nothing. + assert safe_name("\u3164\u3164Alice") == "Alice" + assert safe_name("\u115f\u1160Alice") == "Alice" + assert safe_name("\u2800Alice") == "Alice" + assert safe_name("\u3164" * 10) == UNNAMED + + +def test_normalises_compatibility_forms(): + assert safe_name("\uff21lice") == "Alice" + + +def test_leaves_names_in_other_scripts_alone(): + for name in ( + "\uae40\ucca0\uc218", + "Nguy\u1ec5n", + "\u0645\u062d\u0645\u062f", + "Jos\u00e9 M\u00fcller", + ): + assert safe_name(name) == name diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg new file mode 100644 index 0000000000..715a6daf99 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_1@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg new file mode 100644 index 0000000000..f70ecb91db Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_1@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg new file mode 100644 index 0000000000..48bfe6caa7 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_2@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg new file mode 100644 index 0000000000..2bab828717 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_2@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg new file mode 100644 index 0000000000..704cae8710 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_3@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg new file mode 100644 index 0000000000..d7c1f4e088 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_3@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg new file mode 100644 index 0000000000..e39e0836cb Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_4@2x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg new file mode 100644 index 0000000000..ce9b4a86ef Binary files /dev/null and b/assets/multiplatform/resources/MR/images/crowdfunding_4@3x.jpg differ diff --git a/assets/multiplatform/resources/MR/images/own_stake@2x.png b/assets/multiplatform/resources/MR/images/own_stake@2x.png new file mode 100644 index 0000000000..fadf1b9599 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake@3x.png b/assets/multiplatform/resources/MR/images/own_stake@3x.png new file mode 100644 index 0000000000..106ee804ca Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake@3x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@2x.png b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png new file mode 100644 index 0000000000..8f02fa3eaa Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@2x.png differ diff --git a/assets/multiplatform/resources/MR/images/own_stake_light@3x.png b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png new file mode 100644 index 0000000000..0a994849d6 Binary files /dev/null and b/assets/multiplatform/resources/MR/images/own_stake_light@3x.png differ diff --git a/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md index 51f0db7475..70e8f4d1ff 100644 --- a/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md +++ b/blog/20260430-simplex-channels-v6-5-consortium-crowdfunding-freedom-of-speech.md @@ -52,13 +52,11 @@ We've seen open-source privacy-focussed projects die without funding, or worse & So we're building both: a governance structure and a real business. The governance protects the network neutrality. The commercial model funds the network and makes our and other businesses on the network profitable, ensuring their independence. Neither works without the other. -We recently published [a preliminary design of commercial model](https://simplex.chat/credits/) — private Community Credits that fund servers, development, and governance without surveillance or speculation. The full investment case will be published when crowdfunding launches. +We recently published [a preliminary design of commercial model](https://simplex.chat/credits/) — private Community Credits that fund servers, development, and governance without surveillance or speculation. -You can *register your interest* to participate in crowdfunding here: https://simplexchat.typeform.com/crowdfunding +Aug 20, 2026: We now launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat?utm_source=blog). Please read [the next blog post](./20260819-simplex-chat-crowdfunding.md) about it. -Join the channel for updates [here](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) — you must install v6.5 to join it — or you can join a [read-only group](https://smp12.simplex.im/g#gJzy7ETpuvltqARIB73TQUpJ11Lz4Xpl9xeH9qNoGCg) from the previous app versions. - -_Disclaimer: SimpleX Chat, Inc. is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ +Join the channel for updates [simplex.chat/crowdfunding-news](https://simplex.chat/crowdfunding-news/). [^release]: v6.5 release also improved how new users make the first connection, increased security of sending web links, and has many other improvements — see *What's new* in the app or full release notes. diff --git a/blog/20260722-simplex-public-names.md b/blog/20260722-simplex-public-names.md index 1b9220da4a..0f62c93e01 100644 --- a/blog/20260722-simplex-public-names.md +++ b/blog/20260722-simplex-public-names.md @@ -57,10 +57,9 @@ To ensure the long term success of SimpleX Network we established [SimpleX Netwo The commercial model for the network that we are building aims to make both our and other businesses on the network profitable. We recently [presented the technology design](https://www.youtube.com/watch?v=UhW8AuoRgxg) for this commercial model at Web3 Summit. -The planned crowdfunding will fund this development. You can [register your interest](https://simplexchat.typeform.com/crowdfunding), and join the [SimpleX Crowdfunding News channel](https://smp10.simplex.im/c#q09nMBmWFGz1m2TvgfZFaEOG5D2a7Ma9mSkl6pHXEsg) for updates. - -_Disclaimer: SimpleX Chat, Inc. is testing the waters for a possible Reg CF offering. We’re not asking for or accepting any money right now, and we won’t accept any if sent. We can’t accept any offers to buy securities or take any payments until the official filing is done and it’s live through a regulated platform. Our testing the waters and your possible indications of interest doesn’t create any obligation or commitment of any kind._ +Aug 20, 2026: We now launched [equity crowdfunding on Wefunder](https://wefunder.com/simplex.chat?utm_source=blog). Please read [the next blog post](./20260819-simplex-chat-crowdfunding.md) about it. +Join the channel for updates [simplex.chat/crowdfunding-news](https://simplex.chat/crowdfunding-news/). [^testing]: Test names are free to register; you only need to pay the blockchain fee. The `.testing` namespace is temporary — test names will stop working in the app one month after `.simplex` name sales launch. diff --git a/blog/20260819-simplex-chat-crowdfunding.md b/blog/20260819-simplex-chat-crowdfunding.md new file mode 100644 index 0000000000..b0114d73da --- /dev/null +++ b/blog/20260819-simplex-chat-crowdfunding.md @@ -0,0 +1,71 @@ +--- +layout: layouts/article.html +title: "Equity Crowdfunding Launched — You Can Get a Stake in SimpleX Chat" +date: 2026-08-20 +previewBody: blog_previews/20260820.html +image: images/20260819-wefunder.jpg +imageWide: true +permalink: "/blog/20260819-simplex-chat-crowdfunding.html" +--- + +# Equity Crowdfunding Launched — You Can Get a Stake in SimpleX Chat + +**Published:** Aug 20, 2026 + +SimpleX is the first and the only messaging network without user identifiers of any kind. Our equity crowdfunding round is now launched on Wefunder, so network users, and anybody else, can get a stake in SimpleX Chat. + +## A network that cannot know who you are, or who you talk to + + + +People found SimpleX without any paid marketing — the community spread it via hundreds of posts, videos, podcasts and talks.[^links] The app was downloaded more than 3 million times. About 480,000 people use it every month. Users, including Vitalik Buterin, donated $650,000. There are more than 1,000 community-run servers on the network. Trail of Bits audited it in 2022, 2024, and 2026 (to be published). + +SimpleX Network assigns no identifiers to users — no phone numbers, usernames, or even random numbers. Instead, random identifiers are assigned to conversations, and only users know who they talk to. This cannot be added to a network that already has identifiers; it has to be built this way from the start, as SimpleX Network was. + +All other messengers, even private ones, may hide your name but still tag you with some number or key. That identifier links your conversations together, and the pattern of who you talk to can reveal who you are, even without names, as a 2009 study showed.[^deanon] + +## Why it matters now + +There are three trends that increase the demand for identity-free messaging that no other existing network can provide. + +**Surveillance of private life**. Big centralized platforms are increasingly scanning private messages to train AI models. More people would move to use private messaging that can't know who they talk to. + +**Accelerating deplatforming**. Creators are removed from centralized platforms without due process, and an audience built over years can disappear with one policy change. Telegram alone blocked 44 million groups and channels in 2025. + +**AI agents**. Agents are beginning to act for people, handling logins and money. Whoever controls an agent's identity can shut it down, or turn it against the person it works for. The same AI can fake a real person too.[^arup] User-controlled IDs are a basic security for the agentic Internet. + +On SimpleX Network the users, not the network, control their identities, and it solves all three problems — users cannot be surveilled or deplatformed by the network, or attacked by somebody who they have no connection with.[^address] The only keys to user connections are held by the users themselves. + +## SimpleX is not just a messenger — it's a network to build on + + + +SimpleX is used for private messaging, but it also brings together communities, creators, businesses, developers and server operators. Each group makes the network more valuable to the others. + +Most people want more from a messenger than to connect and send texts — they want to share experiences, and every messenger builds its own. That user experience is what prevents messaging apps from converging to one protocol, the way other networks did.[^network] + +The web solved this by letting anyone build any experience on one open platform. SimpleX Network is evolving into that kind of platform, and developers already build on it: moderation and AI bots, bridges, and more, with over 19,000 stars on GitHub. Soon it will be possible to build custom interfaces inside a chat. Every service developers build on SimpleX makes the network more useful and brings new people in, without locking them into a single provider. + +## Now you can get a stake + + + +SimpleX Chat is the company that builds SimpleX Network, and now you can invest from $100 and get a stake in the company. + +If you invest $500 or more, you receive [a public SimpleX name](./20260722-simplex-public-names.md) on SimpleX Network: +- for 5 years for early bird investors, +- for 3 years after that. + +You may benefit from the company's growth, and you would help build a network that people own — where the communities and audiences you build stay yours, and no company can take them away. + +Read about how we plan to make SimpleX Chat and the network profitable, and about all the investment terms, on Wefunder: [https://wefunder.com/simplex.chat](https://wefunder.com/simplex.chat?utm_source=blog) + +[^links]: https://simplex.chat/links/ + +[^deanon]: Narayanan & Shmatikov, *De-anonymizing Social Networks* (2009): a third of the users with accounts on both Twitter and Flickr were re-identified in the anonymised Twitter graph, from its structure alone, at a 12% error rate. Sources: [arXiv 0903.3276](https://arxiv.org/pdf/0903.3276), [Narayanan, 33 Bits of Entropy](https://33bits.wordpress.com/2009/03/19/de-anonymizing-social-networks/), [Princeton listing](https://collaborate.princeton.edu/en/publications/de-anonymizing-social-networks/). + +[^arup]: In 2024, a video call of deepfakes cost Arup $25 million. + +[^address]: SimpleX Network supports public addresses, for users who want to accept connections from anybody, e.g. for a business, but these addresses are opt-in. When users use only one-time connection links, nobody can connect to them. + +[^network]: All networks converged to a single protocol, starting from railroads, and ending to a World Wide Web. A single unified network can create more value that several disconnected networks. diff --git a/blog/README.md b/blog/README.md index 6122eb3c39..3a1c545b4f 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,6 +1,15 @@ # Blog -Jul 22, 2026 [SimpleX Public Names — a Name Nobody Can Take From You](./20260722-simplex-public-names.md) +Aug 20, 2026 [Equity Crowdfunding Launched - You Can Get a Stake in SimpleX Chat](./20260819-simplex-chat-crowdfunding.md) + +SimpleX is the first and the only messaging network without user identifiers of any kind. Our equity crowdfunding round is now launched on Wefunder, so network users, and anybody else, can get a stake in SimpleX Chat - the company that builds it. + +Learn more and invest [on Wefunder](https://wefunder.com/simplex.chat?utm_source=blog). + + +--- + +Jul 22, 2026 [SimpleX Public Names - a Name Nobody Can Take From You](./20260722-simplex-public-names.md) You can now give your channel or business a test SimpleX name that people can remember and nobody can take from you. Test names are free in v7-beta. diff --git a/blog/images/20260819-developers.png b/blog/images/20260819-developers.png new file mode 100644 index 0000000000..774d867e93 Binary files /dev/null and b/blog/images/20260819-developers.png differ diff --git a/blog/images/20260819-growth-chart.png b/blog/images/20260819-growth-chart.png new file mode 100644 index 0000000000..2b170f1146 Binary files /dev/null and b/blog/images/20260819-growth-chart.png differ diff --git a/blog/images/20260819-wefunder.jpg b/blog/images/20260819-wefunder.jpg new file mode 100644 index 0000000000..6586d4dfe7 Binary files /dev/null and b/blog/images/20260819-wefunder.jpg differ diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 476ee4f94d..d88181b778 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -61,6 +61,7 @@ This file is generated automatically. - [APISetGroupCustomData](#apisetgroupcustomdata) - [APISetContactCustomData](#apisetcontactcustomdata) - [APISetUserAutoAcceptMemberContacts](#apisetuserautoacceptmembercontacts) +- [APISetUserAutoAcceptGroupInvitations](#apisetuserautoacceptgroupinvitations) [User profile commands](#user-profile-commands) - [ShowActiveUser](#showactiveuser) @@ -1972,6 +1973,43 @@ ChatCmdError: Command error (only used in WebSockets API). --- +### APISetUserAutoAcceptGroupInvitations + +Set auto-accept group invitations. + +*Network usage*: no. + +**Parameters**: +- userId: int64 +- onOff: bool + +**Syntax**: + +``` +/_set accept group invitations on|off +``` + +```javascript +'/_set accept group invitations ' + userId + ' ' + (onOff ? 'on' : 'off') // JavaScript +``` + +```python +'/_set accept group invitations ' + str(userId) + ' ' + ('on' if onOff else 'off') # Python +``` + +**Responses**: + +CmdOk: Ok. +- type: "cmdOk" +- user_: [User](./TYPES.md#user)? + +ChatCmdError: Command error (only used in WebSockets API). +- type: "chatCmdError" +- chatError: [ChatError](./TYPES.md#chaterror) + +--- + + ## User profile commands Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 4546a6aaa7..665dd26889 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -832,6 +832,19 @@ Group: - msgDir: [MsgDirection](#msgdirection) - groupId: int64? - chatItemId: int64? +- memberId: string? +- sharedMsgId_: string? +- groupType: [GroupType](#grouptype)? + +GroupLink: +- type: "groupLink" +- chatName: string +- msgDir: [MsgDirection](#msgdirection) +- groupLink: string +- publicGroupId: string +- memberId: string? +- sharedMsgId: string +- groupType: [GroupType](#grouptype)? --- @@ -3589,6 +3602,7 @@ ParseError: A_MESSAGE: - type: "A_MESSAGE" +- messageErr: string A_PROHIBITED: - type: "A_PROHIBITED" @@ -4366,6 +4380,7 @@ Handshake: - sendRcptsContacts: bool - sendRcptsSmallGroups: bool - autoAcceptMemberContacts: bool +- autoAcceptGroupInvitations: bool - userMemberProfileUpdatedAt: UTCTime? - userChatRelay: bool - clientService: bool diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 09ae1faa5b..d126ff1844 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -154,7 +154,8 @@ chatCommandsDocsData = ("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"), ("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"), ("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"), - ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff") + ("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff"), + ("APISetUserAutoAcceptGroupInvitations", [], "Set auto-accept group invitations.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept group invitations " <> Param "userId" <> " " <> OnOff "onOff") -- ("APIChatItemsRead", [], "Mark items as read.", ["CRItemsReadForChat"], [], Nothing, ""), -- ("APIChatRead", [], "Mark chat as read.", ["CRCmdOk"], [], Nothing, ""), -- ("APIChatUnread", [], "Mark chat as unread.", ["CRCmdOk"], [], Nothing, ""), @@ -297,6 +298,7 @@ cliCommands = "SetUserFeature", "SetUserGroupReceipts", "SetUserAutoAcceptMemberContacts", + "SetUserAutoAcceptGroupInvitations", "SetUserTimedMessages", "ShareMyAddress", "SharePublicGroup", diff --git a/cabal.project b/cabal.project index bdd04fe7d6..fa144d6780 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: e3d53428a0c5776f9682264a56436ce97bc3eff8 + tag: 0d3cf39c27aec1c51c88d7bf3d6762bc22278967 source-repository-package type: git diff --git a/docs/DIRECTORY.md b/docs/DIRECTORY.md index 50e7771a1a..9a1e26b9f7 100644 --- a/docs/DIRECTORY.md +++ b/docs/DIRECTORY.md @@ -22,32 +22,27 @@ Please note that your search queries can be kept by the bot as the conversation To add a group you must be its owner. Once you connect to the directory service and send `/help`, the service will guide you through the process. -1. Invite SimpleX Service Directory to the group as `admin` member. You can also set the role to `admin` after inviting the directory service. +1. Invite SimpleX Service Directory to the group as `admin` member. You can also set the role to `admin` after inviting the directory service. The member who invited the directory service will be the owner of the group record in the directory service. The directory service needs to be `admin` to provide a good user experience of joining the group, as it will create a new link to join the group, which is expected to be online 99% of the time. -2. Add the link sent to you by the directory service to the group welcome message. This has to be done by the same group member who invited the directory service to the group. This member will be the owner of the group record in the directory service. - -3. Once the link is added, the group will need to be approved by the directory service admins. This link is functional even before the group is approved, and you can continue using this link even if the group is not approved. +2. The group will need to be approved by the directory service admins. The directory service creates the link to join the group when the group is approved, and sends it to you. The group is usually approved within 24 hours. Please see below which groups can be added. -Once the group is approved, it will appear in search results. +Once the group is approved, it will appear in search results together with the link to join it. We recommend adding this link to the group welcome message - adding or removing it does not require a new approval. You can list all the groups you submitted by sending `/list` to the directory service. ### How to remove the group from the directory -Changing the group profile in any way (e.g., changing the group name, welcome message, or removing the link to join the group from the welcome message) will remove the group from the search results until the group is approved again by the directory service admins. +Changing the group profile (e.g., the group name, image, or the text of the welcome message) will remove the group from the search results until the group is approved again by the directory service admins. Adding or removing the directory link in the welcome message does not require a new approval. If it is undesirable that the service cannot be found in search during this time, please coordinate the time of this change with the directory service admins for quick approval. Changing the role of the directory service will temporarily remove the group from the search results, and unless you changed the role to the `owner`, it will also permanently disrupt the members that were in the process of connecting to other members via the directory service. -To remove the group from the directory: - -1. Remove the group link created by the directory service from the welcome message. This will not disrupt the members from joining the group, even via this link, but will remove the group from the search results. -2. After some time (we recommend 3-4 days) remove the directory service from the group - it will stop receiving the messages and the group will be permanently removed from the search results. +To remove the group from the directory, send `/delete :` to the directory service, with the ID and name shown by `/list`. You can also remove the directory service from the group - the group will be permanently removed from the search results. Removing the group does not prevent you from registering the group again in the future. diff --git a/docs/LINKS.md b/docs/LINKS.md index 46de314a75..c1eb1e6f2b 100644 --- a/docs/LINKS.md +++ b/docs/LINKS.md @@ -1,5 +1,51 @@ # Links to Community Publications +## SimpleX Chat Wants Its 400K+ Users to Become Investors Too + +It's FOSS + +Article + +Image: itsfoss-simplex-crowdfunding.webp + +Language: English + +Date: Aug 14, 2026 + +https://itsfoss.com/news/simplex-chat-investment-drive/ + +## SimpleX Chat: Private Monero Communities — and Now a Chance to Invest + +Monerica + +Article + +SimpleX Chat calls itself “the messaging network that can’t identify you,” and that is not marketing hyperbole — it is the actual design. For a privacy community built around Monero, that combination of unlinkable messaging and unlinkable money is a natural fit. This post looks at why SimpleX matters, the growing set of private Monero communities already running on it, and the news that users can now buy a stake in the project itself through an equity crowdfunding round on Wefunder. + +Image: simplex-monero-investing.jpg + +Language: English + +Date: Aug 10, 2026 + +https://blog.monerica.com/articles/simplex-chat-private-monero-communities + +## Web3 Summit Talk: "SimpleX Community Credits: Making Privacy Profitable" + +SimpleX Chat + +Conference talk, Video + +Alain Brenzikofer presents our design for Community Credits at Web3 Summit – a payment solution for private infrastructure payments on SimpleX network. See the whitepaper at: https://simplex.chat/credits/whitepaper.pdf + +Image: simplex-web3-summit-talk.jpg + +Language: English + +Date: Jul 2026 + +https://www.youtube.com/watch?v=UhW8AuoRgxg + ## SimpleX Chat: Product Showcase - Removing User Identifiers From Messaging Help Net Security @@ -200,19 +246,19 @@ https://opennet.ru/65337/ ## Vitalik Buterin Donates $765K in Ethereum to Privacy Messaging Apps -Yahoo Finance +Decrypt 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. +Decrypt 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 +Image: decrypt-buterin.jpg Language: English Date: Nov 2025 -https://finance.yahoo.com/news/vitalik-buterin-donates-765k-ethereum-190102367.html +https://decrypt.co/350253/vitalik-buterin-donates-765k-in-ethereum-to-privacy-messaging-apps ## Vitalik Buterin Supports Privacy-Focused Messaging Platforms With Significant Ethereum Donation @@ -2543,24 +2589,6 @@ 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) @@ -3780,6 +3808,8 @@ 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. +Monerica is established in 2022. + Image: monerica-simplex-communities.jpg Language: English @@ -3820,22 +3850,6 @@ 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 diff --git a/docs/contributing/PROJECT.md b/docs/contributing/PROJECT.md index 3f7e6e0e54..40417a6539 100644 --- a/docs/contributing/PROJECT.md +++ b/docs/contributing/PROJECT.md @@ -68,14 +68,15 @@ The project uses several custom forks managed via `cabal.project`: ```bash cd apps/multiplatform -# Build Android debug APK -./gradlew assembleDebug +# Build Android debug APK; `foss` ships to F-Droid/GitHub, `google` adds Play Billing. +# The aggregate tasks fail by design, see apps/multiplatform/README.md +./gradlew assembleFossDebug # Build desktop ./gradlew :desktop:packageDistributionForCurrentOS # Run Android tests -./gradlew connectedAndroidTest +./gradlew connectedFossDebugAndroidTest ``` ### iOS diff --git a/docs/links/images/yahoo-finance-buterin.jpg b/docs/links/images/decrypt-buterin.jpg similarity index 100% rename from docs/links/images/yahoo-finance-buterin.jpg rename to docs/links/images/decrypt-buterin.jpg diff --git a/docs/links/images/itsfoss-simplex-crowdfunding.webp b/docs/links/images/itsfoss-simplex-crowdfunding.webp new file mode 100644 index 0000000000..e4b52d65ec Binary files /dev/null and b/docs/links/images/itsfoss-simplex-crowdfunding.webp differ diff --git a/docs/links/images/simplex-monero-investing.jpg b/docs/links/images/simplex-monero-investing.jpg new file mode 100644 index 0000000000..2c04c8d292 Binary files /dev/null and b/docs/links/images/simplex-monero-investing.jpg differ diff --git a/docs/links/images/simplex-web3-summit-talk.jpg b/docs/links/images/simplex-web3-summit-talk.jpg new file mode 100644 index 0000000000..e4edf5e221 Binary files /dev/null and b/docs/links/images/simplex-web3-summit-talk.jpg differ diff --git a/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md new file mode 100644 index 0000000000..2fe40755e8 --- /dev/null +++ b/docs/rfcs/2026-08-05-markdown-hyperlink-connect.md @@ -0,0 +1,33 @@ +# Connecting via a SimpleX link written as a markdown hyperlink + +## Problem + +Pasting a short SimpleX link written as a markdown hyperlink — `[label](https://smp6.simplex.im/a#...)` — into the chat list search, the new chat sheet search, or "Tap to paste link" fails with "Invalid connection link" instead of connecting. + +## Cause + +`markdownP` parses such a link into a single fragment whose `format` is `SimplexLink` but whose `text` is the whole markdown source: + +``` +[{"format":{"type":"simplexLink","showText":"label","linkType":"contact", + "simplexUri":"simplex:/a#...?h=smp6.simplex.im","smpHosts":["smp6.simplex.im"]}, + "text":"[label](https://smp6.simplex.im/a#...)"}] +``` + +`strConnectTarget` returns that `text` as the string to connect with. For a bare link `text` is the link, so it works; for a hyperlink it is `[label](link)`, which the core rejects as `InvalidConnReq`. + +## Design + +Use `simplexUri` — the link the parser already resolved — when the fragment came from the hyperlink parser, and keep using `text` otherwise: + +``` +text = if showText != null then simplexUri else text +``` + +`showText` is an exact discriminator, not a heuristic: `simplexUriFormat` is called with `Just t` only from `sowLinkP` (the hyperlink parser) and with `Nothing` from `wordMD` (bare link). Gating on it leaves every bare-link path unchanged. + +This also matches how the chat item renderer already resolves the same format — `TextItemView.kt` takes `simplexUri`, never the fragment `text`, when `showText` is set. `strConnectTarget` was the outlier. + +## Scope + +Short links only. `sowLinkP` rejects a full link inside a hyperlink (`fail "full SimpleX link in hyperlink"`), so `[label](full-link)` yields no formatting at all and never reaches this code — it stays treated as search text, as before. Bare full links are unaffected. diff --git a/packages/simplex-chat-client/types/typescript/package.json b/packages/simplex-chat-client/types/typescript/package.json index 01d88ab770..4217b0e8fa 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.10.3", + "version": "0.11.1", "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 14b03f560f..0dfd63ba88 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -728,6 +728,21 @@ export namespace APISetUserAutoAcceptMemberContacts { } } +// Set auto-accept group invitations. +// Network usage: no. +export interface APISetUserAutoAcceptGroupInvitations { + userId: number // int64 + onOff: boolean +} + +export namespace APISetUserAutoAcceptGroupInvitations { + export type Response = CR.CmdOk | CR.ChatCmdError + + export function cmdString(self: APISetUserAutoAcceptGroupInvitations): string { + return '/_set accept group invitations ' + self.userId + ' ' + (self.onOff ? 'on' : 'off') + } +} + // User profile commands // Most bots don't need to use these commands, as bot profile can be configured manually via CLI or desktop client. These commands can be used by bots that need to manage multiple user profiles (e.g., the profiles of support agents). diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5faf084dce..cae52090e7 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -803,10 +803,14 @@ export namespace CIFileStatus { } } -export type CIForwardedFrom = CIForwardedFrom.Unknown | CIForwardedFrom.Contact | CIForwardedFrom.Group +export type CIForwardedFrom = + | CIForwardedFrom.Unknown + | CIForwardedFrom.Contact + | CIForwardedFrom.Group + | CIForwardedFrom.GroupLink export namespace CIForwardedFrom { - export type Tag = "unknown" | "contact" | "group" + export type Tag = "unknown" | "contact" | "group" | "groupLink" interface Interface { type: Tag @@ -830,6 +834,20 @@ export namespace CIForwardedFrom { msgDir: MsgDirection groupId?: number // int64 chatItemId?: number // int64 + memberId?: string + sharedMsgId_?: string + groupType?: GroupType + } + + export interface GroupLink extends Interface { + type: "groupLink" + chatName: string + msgDir: MsgDirection + groupLink: string + publicGroupId: string + memberId?: string + sharedMsgId: string + groupType?: GroupType } } @@ -3984,6 +4002,7 @@ export namespace SMPAgentError { export interface A_MESSAGE extends Interface { type: "A_MESSAGE" + messageErr: string } export interface A_PROHIBITED extends Interface { @@ -5042,6 +5061,7 @@ export interface User { sendRcptsContacts: boolean sendRcptsSmallGroups: boolean autoAcceptMemberContacts: boolean + autoAcceptGroupInvitations: boolean userMemberProfileUpdatedAt?: string // ISO-8601 timestamp userChatRelay: boolean clientService: boolean diff --git a/packages/simplex-chat-nodejs/package.json b/packages/simplex-chat-nodejs/package.json index 76833f70c8..51311cfb5e 100644 --- a/packages/simplex-chat-nodejs/package.json +++ b/packages/simplex-chat-nodejs/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "7.0.0", + "version": "7.1.0-beta.1", "main": "dist/index.js", "types": "dist/index.d.ts", "files": [ @@ -24,7 +24,7 @@ "docs": "typedoc" }, "dependencies": { - "@simplex-chat/types": "^0.10.3", + "@simplex-chat/types": "^0.11.1", "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 e0685e0123..727e5164d5 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 = 'v7.0.0'; +const RELEASE_TAG = 'v7.1.0-beta.1'; 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-python/pyproject.toml b/packages/simplex-chat-python/pyproject.toml index 76f22cdabe..ecc6a9d6b4 100644 --- a/packages/simplex-chat-python/pyproject.toml +++ b/packages/simplex-chat-python/pyproject.toml @@ -42,6 +42,10 @@ asyncio_mode = "auto" line-length = 100 target-version = "py311" +[tool.ruff.lint] +# Generated by the Haskell codegen; regenerating is the only way to change them. +exclude = ["src/simplex_chat/types/_*.py"] + [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 diff --git a/packages/simplex-chat-python/src/simplex_chat/__init__.py b/packages/simplex-chat-python/src/simplex_chat/__init__.py index c353b74935..a60e60c820 100644 --- a/packages/simplex-chat-python/src/simplex_chat/__init__.py +++ b/packages/simplex-chat-python/src/simplex_chat/__init__.py @@ -1,5 +1,6 @@ """SimpleX Chat — Python client library for chat bots.""" +from . import util as util # re-export the util namespace from ._version import __version__ from .api import ( ChatApi, @@ -32,17 +33,16 @@ from .bot import ( VideoMessage, VoiceMessage, ) -from .core import ChatAPIError, ChatInitError, CryptoArgs, MigrationConfirmation -from . import util as util # re-export the util namespace +from .core import ChatAPIError, ChatError, ChatInitError, CryptoArgs, MigrationConfirmation __all__ = [ - "__version__", "Bot", "BotCommand", "BotProfile", "ChatAPIError", "ChatApi", "ChatCommandError", + "ChatError", "ChatInitError", "ChatMessage", "Client", @@ -68,5 +68,6 @@ __all__ = [ "UnknownMessage", "VideoMessage", "VoiceMessage", + "__version__", "util", ] diff --git a/packages/simplex-chat-python/src/simplex_chat/__main__.py b/packages/simplex-chat-python/src/simplex_chat/__main__.py index 2fa4f3cd37..d14fa377b0 100644 --- a/packages/simplex-chat-python/src/simplex_chat/__main__.py +++ b/packages/simplex-chat-python/src/simplex_chat/__main__.py @@ -26,7 +26,7 @@ def main(argv: list[str] | None = None) -> int: path = _native._resolve_libs_dir(args.backend) print(f"libsimplex installed at: {path}") return 0 - except Exception as e: + except Exception as e: # noqa: BLE001 - a CLI: report any failure, don't traceback print(f"install failed: {e}", file=sys.stderr) return 1 diff --git a/packages/simplex-chat-python/src/simplex_chat/_native.py b/packages/simplex-chat-python/src/simplex_chat/_native.py index 313c606883..4c408479bb 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_native.py +++ b/packages/simplex-chat-python/src/simplex_chat/_native.py @@ -99,7 +99,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: `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/... + with urllib.request.urlopen(url, timeout=timeout) as resp: total = int(resp.headers.get("Content-Length") or 0) received = 0 with dest.open("wb") as out: @@ -112,7 +112,7 @@ def _stream_to_file(url: str, dest: Path, *, timeout: float = 60.0) -> None: 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 + print(file=sys.stderr, flush=True) # newline after final progress line def _download(target: Path, backend: Backend) -> None: diff --git a/packages/simplex-chat-python/src/simplex_chat/_version.py b/packages/simplex-chat-python/src/simplex_chat/_version.py index e1cbccc2da..091488e2af 100644 --- a/packages/simplex-chat-python/src/simplex_chat/_version.py +++ b/packages/simplex-chat-python/src/simplex_chat/_version.py @@ -5,5 +5,5 @@ 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__ = "7.0.0" # PEP 440 — read by hatchling for wheel metadata -LIBS_VERSION = "7.0.0" # simplex-chat-libs release tag (no 'v' prefix) +__version__ = "7.1.0b1" # PEP 440 — read by hatchling for wheel metadata +LIBS_VERSION = "7.1.0-beta.1" # 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 index 51de063329..e3d36c45df 100644 --- a/packages/simplex-chat-python/src/simplex_chat/api.py +++ b/packages/simplex-chat-python/src/simplex_chat/api.py @@ -8,7 +8,7 @@ from typing import Any, Literal from . import _native, core, util from .core import MigrationConfirmation -from .types import CC, CEvt, CR, T +from .types import CC, CR, CEvt, 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. @@ -39,7 +39,7 @@ def _db_to_migrate_args(db: Db) -> tuple[str, str, _native.Backend]: raise TypeError(f"Unknown db: {db!r}") -class ChatCommandError(Exception): +class ChatCommandError(core.ChatError): """A chat command returned an unexpected response type. `response` is the raw wire response; `response_type` exposes its `type` @@ -71,7 +71,7 @@ class ChatApi: cls, db: Db, confirm: MigrationConfirmation = MigrationConfirmation.YES_UP, - ) -> "ChatApi": + ) -> 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) @@ -96,8 +96,12 @@ class ChatApi: return self._started async def start_chat(self) -> None: + # serviceRequests is off: a bot answers its own address, it does not + # serve requests routed to it as a service. r = await self.send_chat_cmd( - CC.StartChat_cmd_string({"mainApp": True, "enableSndFiles": True}) + CC.StartChat_cmd_string( + {"mainApp": True, "enableSndFiles": True, "serviceRequests": False} + ) ) if r.get("type") not in ("chatStarted", "chatRunning"): raise ChatCommandError("error starting chat", r) @@ -142,12 +146,7 @@ class ChatApi: 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" - ): + if e.store_error_type == "userContactLinkNotFound": return None raise @@ -510,8 +509,10 @@ class ChatApi: raise ChatCommandError("error accepting contact request", r) async def api_reject_contact_request(self, contact_req_id: int) -> None: + # notify is not rendered into the command string, so the core reads its + # own default of off; this only keeps the argument type satisfied. r = await self.send_chat_cmd( - CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id}) + CC.APIRejectContact_cmd_string({"contactReqId": contact_req_id, "notify": False}) ) if r["type"] != "contactRequestRejected": raise ChatCommandError("error rejecting contact request", r) @@ -607,6 +608,28 @@ class ChatApi: if r["type"] != "cmdOk": raise ChatCommandError("error setting contact custom data", r) + async def api_merge_contact_custom_data( + self, contact: T.Contact, key: str, value: object | None + ) -> None: + """Set or drop one key of a contact's custom data, keeping the rest. + + The set command replaces the whole column. `value=None` removes `key`. + """ + await self.api_set_contact_custom_data( + contact["contactId"], util.merged_custom_data(contact.get("customData"), key, value) + ) + + async def api_merge_group_custom_data( + self, group: T.GroupInfo, key: str, value: object | None + ) -> None: + """Set or drop one key of a group's custom data, keeping the rest. + + See `api_merge_contact_custom_data`. + """ + await self.api_set_group_custom_data( + group["groupId"], util.merged_custom_data(group.get("customData"), key, value) + ) + 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}) @@ -632,12 +655,7 @@ class ChatApi: 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" - ): + if e.error_type == "noActiveUser": return None raise @@ -719,3 +737,13 @@ class ChatApi: if r["type"] == "newMemberContactSentInv": return r["contact"] raise ChatCommandError("error sending member contact invitation", r) + + async def api_accept_member_contact(self, contact_id: int) -> T.Contact: + """Accept a direct connection a group member opened with us. + + The core rejects a second accept with "connection already started". + """ + r = await self.send_chat_cmd(f"/_accept member contact @{contact_id}") + if r["type"] == "memberContactAccepted": + return r["contact"] + raise ChatCommandError("error accepting member contact", r) diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index 4e385493b2..b3e5b5ec03 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -121,8 +121,8 @@ class Bot(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) + self._contact_link = await self._sync_address(user) + await self._maybe_sync_profile(user) async def _sync_address(self, user: T.User) -> str | None: """Address sync. Returns the public link if any, for embedding in the profile.""" diff --git a/packages/simplex-chat-python/src/simplex_chat/client.py b/packages/simplex-chat-python/src/simplex_chat/client.py index b0d144b8b9..8ec955b54a 100644 --- a/packages/simplex-chat-python/src/simplex_chat/client.py +++ b/packages/simplex-chat-python/src/simplex_chat/client.py @@ -14,7 +14,7 @@ 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 typing import Any, Generic, Literal, Self, TypeVar, overload from . import util from .api import ChatApi, ChatCommandError, ContactAlreadyExistsError, Db @@ -58,7 +58,7 @@ class ParsedCommand: class Message(Generic[C]): chat_item: T.AChatItem content: C - client: "Client" + client: Client @property def chat_info(self) -> T.ChatInfo: @@ -71,7 +71,7 @@ class Message(Generic[C]): return c.get("text") # type: ignore[return-value] return None - async def reply(self, text: str) -> "Message[T.MsgContent]": + 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"] @@ -79,7 +79,7 @@ class Message(Generic[C]): 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]": + 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": {}}] ) @@ -162,6 +162,11 @@ class Client: self._api: ChatApi | None = None self._serving = False self._stop_event = asyncio.Event() + # Set by Bot once its address is known, so a later `sync_profile()` + # embeds the same link the startup sync would have. + self._contact_link: str | None = None + self._signal_handlers_installed = False + self._interrupts = 0 self._message_handlers: list[tuple[Callable[[Message[Any]], bool], MessageHandler]] = [] self._command_handlers: list[ tuple[tuple[str, ...], Callable[[Message[Any]], bool], CommandHandler] @@ -184,6 +189,26 @@ class Client: raise RuntimeError("Client not initialized — call run() or use `async with client:`") return self._api + @property + def profile(self) -> Profile: + """The profile this client identifies with. + + Mutable: change a field and call `sync_profile()` to apply it. + """ + return self._profile + + @profile.setter + def profile(self, profile: Profile) -> None: + self._profile = profile + + @property + def stop_requested(self) -> bool: + """Whether `stop()` has been called, including during startup. + + Sticky: a caller doing its own setup can unwind instead of serving. + """ + return self._stop_event.is_set() + # ------------------------------------------------------------------ # # Decorators # ------------------------------------------------------------------ # @@ -312,16 +337,12 @@ class Client: # Lifecycle # ------------------------------------------------------------------ # - async def __aenter__(self) -> "Client": + async def __aenter__(self) -> Self: # 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() + # `_stop_event` is never cleared: a stop requested during startup has + # to survive into the receive loop. A stopped client is spent. self._api = await ChatApi.init(self._db, self._confirm_migrations) try: user = await self._ensure_active_user() @@ -372,7 +393,7 @@ class Client: 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) + await self._maybe_sync_profile(user) def run(self) -> None: """Blocking entry: runs serve_forever() with SIGINT/SIGTERM handlers installed. @@ -390,33 +411,39 @@ class Client: ) async def _main() -> None: + # Before startup: a signal during migrations would otherwise hit + # the default disposition and kill the process mid-write. + self.install_signal_handlers() 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()) + def install_signal_handlers(self) -> None: + """Route SIGINT and SIGTERM to `stop()`. Idempotent. + + `run()` calls this itself; call it directly when driving the client + yourself. First Ctrl+C stops, a second force-exits. Needs a running loop. + """ + if self._signal_handlers_installed or not hasattr(_signal, "SIGINT"): + return + self._signal_handlers_installed = True + + def on_interrupt() -> None: + self._interrupts += 1 + if self._interrupts == 1: + log.info("stopping... (press Ctrl+C again to force exit)") + self.stop() + else: + os._exit(130) # 128 + SIGINT + + try: + loop = asyncio.get_running_loop() + 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()) + async def serve_forever(self) -> None: if self._serving: raise RuntimeError("already serving") @@ -450,10 +477,7 @@ class Client: 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 + event = await self.api.recv_chat_event(wait_us=500_000) if event is None: continue try: @@ -551,7 +575,7 @@ class Client: text: str, *, timeout: float = 30.0, - ) -> "Message[T.MsgContent]": + ) -> 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 @@ -815,20 +839,35 @@ class Client: log.info("user: %s", user["profile"]["displayName"]) return user - async def _maybe_sync_profile(self, user: T.User, *, contact_link: str | None) -> None: + async def sync_profile(self) -> bool: + """Apply the current `profile` to the active user. True if it changed. + + For what the startup sync cannot know yet, such as a display name that + depends on the database. Raises `ChatAPIError` if the core refuses it. + """ + user = await self.api.api_get_active_user() + if user is None: + raise RuntimeError("no active user") + return await self._sync_profile(user) + + async def _maybe_sync_profile(self, user: T.User) -> bool: + """The startup sync — `sync_profile()` unless the caller opted out.""" + if not self._update_profile: + return False + return await self._sync_profile(user) + + async def _sync_profile(self, user: T.User) -> bool: """Update the user profile on the wire if its fields changed. - `contact_link` is only set by Bot (to embed its address). Mirrors + `_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 + if self._contact_link is not None: + new_profile["contactLink"] = self._contact_link cur = user["profile"] changed = ( cur["displayName"] != new_profile["displayName"] @@ -842,6 +881,7 @@ class Client: if changed: log.info("profile changed, updating...") await self.api.api_update_profile(user["userId"], new_profile) + return changed def _profile_to_wire(self) -> T.Profile: """Convert the user-facing Profile dataclass to wire format. @@ -857,7 +897,7 @@ class Client: 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 + p["image"] = util.check_profile_image(self._profile.image) return p # ------------------------------------------------------------------ # diff --git a/packages/simplex-chat-python/src/simplex_chat/core.py b/packages/simplex-chat-python/src/simplex_chat/core.py index 075db34b52..4fc847f7de 100644 --- a/packages/simplex-chat-python/src/simplex_chat/core.py +++ b/packages/simplex-chat-python/src/simplex_chat/core.py @@ -13,16 +13,46 @@ from enum import StrEnum from typing import Any, TypedDict from . import _native -from .types import T, CR, CEvt +from .types import CR, CEvt, T -class ChatAPIError(Exception): +class ChatError(Exception): + """Base class for every failure of a chat command. + + Catch this for both `ChatAPIError` and `api.ChatCommandError`. + """ + + +class ChatAPIError(ChatError): """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 + @property + def error_type(self) -> str | None: + """Tag of the nested `errorType`, e.g. `noActiveUser`, or None.""" + return self._nested("errorType").get("type") + + @property + def store_error_type(self) -> str | None: + """Tag of the nested `storeError`, e.g. `duplicateName`, or None.""" + return self._nested("storeError").get("type") + + @property + def command_error(self) -> str | None: + """What the core says the caller did wrong, or None. + + The only part of a `commandError` worth reading: the tag says nothing. + """ + error = self._nested("errorType") + return error.get("message") if error.get("type") == "commandError" else None + + def _nested(self, key: str) -> dict[str, Any]: + nested = (self.chat_error or {}).get(key) # type: ignore[attr-defined] + return nested if isinstance(nested, dict) else {} + class ChatInitError(Exception): """Raised when chat_migrate_init returns a DBMigrationResult error.""" diff --git a/packages/simplex-chat-python/src/simplex_chat/filters.py b/packages/simplex-chat-python/src/simplex_chat/filters.py index 8af15c1c66..a119ede25a 100644 --- a/packages/simplex-chat-python/src/simplex_chat/filters.py +++ b/packages/simplex-chat-python/src/simplex_chat/filters.py @@ -3,7 +3,8 @@ from __future__ import annotations import re -from typing import Any, Callable +from collections.abc import Callable +from typing import Any def compile_message_filter(kw: dict[str, Any]) -> Callable[[Any], bool]: diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py index f73a4fa4f7..086b3cfd29 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_commands.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_commands.py @@ -637,6 +637,19 @@ def APISetUserAutoAcceptMemberContacts_cmd_string(self: APISetUserAutoAcceptMemb APISetUserAutoAcceptMemberContacts_Response = CR.CmdOk | CR.ChatCmdError +# Set auto-accept group invitations. +# Network usage: no. +class APISetUserAutoAcceptGroupInvitations(TypedDict): + userId: int # int64 + onOff: bool + + +def APISetUserAutoAcceptGroupInvitations_cmd_string(self: APISetUserAutoAcceptGroupInvitations) -> str: + return '/_set accept group invitations ' + str(self['userId']) + ' ' + ('on' if self['onOff'] else 'off') + +APISetUserAutoAcceptGroupInvitations_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). diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index b00a559f92..512a7464db 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -572,10 +572,28 @@ class CIForwardedFrom_group(TypedDict): msgDir: "MsgDirection" groupId: NotRequired[int] # int64 chatItemId: NotRequired[int] # int64 + memberId: NotRequired[str] + sharedMsgId_: NotRequired[str] + groupType: NotRequired["GroupType"] -CIForwardedFrom = CIForwardedFrom_unknown | CIForwardedFrom_contact | CIForwardedFrom_group +class CIForwardedFrom_groupLink(TypedDict): + type: Literal["groupLink"] + chatName: str + msgDir: "MsgDirection" + groupLink: str + publicGroupId: str + memberId: NotRequired[str] + sharedMsgId: str + groupType: NotRequired["GroupType"] -CIForwardedFrom_Tag = Literal["unknown", "contact", "group"] +CIForwardedFrom = ( + CIForwardedFrom_unknown + | CIForwardedFrom_contact + | CIForwardedFrom_group + | CIForwardedFrom_groupLink +) + +CIForwardedFrom_Tag = Literal["unknown", "contact", "group", "groupLink"] class CIGroupInvitation(TypedDict): groupId: int # int64 @@ -2764,6 +2782,7 @@ class RoleGroupPreference(TypedDict): class SMPAgentError_A_MESSAGE(TypedDict): type: Literal["A_MESSAGE"] + messageErr: str class SMPAgentError_A_PROHIBITED(TypedDict): type: Literal["A_PROHIBITED"] @@ -3526,6 +3545,7 @@ class User(TypedDict): sendRcptsContacts: bool sendRcptsSmallGroups: bool autoAcceptMemberContacts: bool + autoAcceptGroupInvitations: bool userMemberProfileUpdatedAt: NotRequired[str] # ISO-8601 timestamp userChatRelay: bool clientService: bool diff --git a/packages/simplex-chat-python/src/simplex_chat/util.py b/packages/simplex-chat-python/src/simplex_chat/util.py index 158bb72a79..e5fbbf3fab 100644 --- a/packages/simplex-chat-python/src/simplex_chat/util.py +++ b/packages/simplex-chat-python/src/simplex_chat/util.py @@ -120,6 +120,46 @@ def ci_bot_command(chat_item: T.ChatItem) -> tuple[str, str] | None: return m.group(1), m.group(2).strip() +def merged_custom_data( + custom_data: dict[str, object] | None, key: str, value: object | None +) -> dict[str, object] | None: + """`custom_data` with `key` set to `value`, or removed when `value` is None. + + Returns None, which the set commands read as "clear the column", if empty. + """ + data = dict(custom_data or {}) + if value is None: + data.pop(key, None) + else: + data[key] = value + return data or None + + +# The apps decode these two and nothing else (base64ToBitmap in mobile and +# desktop), while the core stores any string starting with "data:". +PROFILE_IMAGE_PREFIXES = ("data:image/png;base64,", "data:image/jpg;base64,") + + +def check_profile_image(image: str) -> str: + """`image` unchanged, or ValueError if no client could render it. + + An image the apps cannot decode is still stored and broadcast, and shows + as an empty avatar to everyone. + """ + if image.startswith(PROFILE_IMAGE_PREFIXES): + return image + raise ValueError(f"profile image must start with {' or '.join(PROFILE_IMAGE_PREFIXES)}") + + +def conn_status(contact: T.Contact) -> str | None: + """Tag of a contact's active connection status, or None if it has none. + + A contact exists before its connection does, so the two are not the same. + """ + status = (contact.get("activeConn") or {}).get("connStatus") or {} + return status.get("type") + + def reaction_text(reaction: T.ACIReaction) -> str: """Format an `ACIReaction` as the emoji character or tag string.""" r = reaction["chatReaction"]["reaction"] # type: ignore[index] diff --git a/packages/simplex-chat-python/tests/test_api.py b/packages/simplex-chat-python/tests/test_api.py new file mode 100644 index 0000000000..09b5ce4c03 --- /dev/null +++ b/packages/simplex-chat-python/tests/test_api.py @@ -0,0 +1,174 @@ +"""ChatApi commands and error classification, without the native controller. + +`ChatApi` only touches the FFI through `send_chat_cmd`, so replacing that one +method exercises every wrapper: the command string it builds and the response +shape it accepts. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from simplex_chat import ChatApi, ChatAPIError, ChatCommandError, ChatError + + +class FakeCtrl(ChatApi): + """ChatApi with the FFI call replaced by a scripted response.""" + + def __init__(self, response: Any = None, raises: Exception | None = None) -> None: + super().__init__(ctrl=1) + self.response = response + self.raises = raises + self.sent: list[str] = [] + + async def send_chat_cmd(self, cmd: str) -> Any: + self.sent.append(cmd) + if self.raises is not None: + raise self.raises + return self.response + + +# ---------------------------------------------------------------------- # +# Error hierarchy +# ---------------------------------------------------------------------- # + + +def test_both_command_failures_share_one_base(): + # The two are raised from different layers for the same kind of failure; + # callers should not have to name both. + assert issubclass(ChatAPIError, ChatError) + assert issubclass(ChatCommandError, ChatError) + + +def test_store_error_type_reads_the_nested_tag(): + e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}}) + assert e.store_error_type == "duplicateName" + assert e.error_type is None + + +def test_error_type_reads_the_nested_tag(): + e = ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}}) + assert e.error_type == "noActiveUser" + assert e.store_error_type is None + + +def test_command_error_carries_the_message(): + # The tag is always "commandError"; the message is the whole content. + e = ChatAPIError( + "x", {"type": "error", "errorType": {"type": "commandError", "message": "name too long"}} + ) + assert e.command_error == "name too long" + + +def test_command_error_of_another_failure(): + e = ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "duplicateName"}}) + assert e.command_error is None + + +def test_error_tags_of_an_unrelated_error(): + e = ChatAPIError("x", {"type": "errorAgent", "agentError": {"type": "CRITICAL"}}) + assert e.error_type is None + assert e.store_error_type is None + + +def test_error_tags_without_a_chat_error(): + # Raised when the controller returns something that is not valid JSON-RPC. + e = ChatAPIError("invalid chat command result") + assert e.error_type is None + assert e.store_error_type is None + + +# ---------------------------------------------------------------------- # +# Errors surfaced as absence +# ---------------------------------------------------------------------- # + + +async def test_missing_address_reads_as_none(): + api = FakeCtrl( + raises=ChatAPIError( + "x", {"type": "errorStore", "storeError": {"type": "userContactLinkNotFound"}} + ) + ) + assert await api.api_get_user_address(1) is None + + +async def test_another_store_error_still_raises(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "errorStore", "storeError": {"type": "dBBusyError"}}) + ) + with pytest.raises(ChatAPIError): + await api.api_get_user_address(1) + + +async def test_no_active_user_reads_as_none(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "noActiveUser"}}) + ) + assert await api.api_get_active_user() is None + + +async def test_another_error_from_the_user_query_still_raises(): + api = FakeCtrl( + raises=ChatAPIError("x", {"type": "error", "errorType": {"type": "invalidConnReq"}}) + ) + with pytest.raises(ChatAPIError): + await api.api_get_active_user() + + +# ---------------------------------------------------------------------- # +# Member contacts +# ---------------------------------------------------------------------- # + + +async def test_accept_member_contact(): + contact = {"contactId": 7} + api = FakeCtrl({"type": "memberContactAccepted", "contact": contact}) + assert await api.api_accept_member_contact(7) is contact + assert api.sent == ["/_accept member contact @7"] + + +async def test_accept_member_contact_rejected(): + # The core answers a second accept with a command error, not a contact. + api = FakeCtrl({"type": "chatCmdError"}) + with pytest.raises(ChatCommandError): + await api.api_accept_member_contact(7) + + +# ---------------------------------------------------------------------- # +# Custom data +# ---------------------------------------------------------------------- # + + +async def test_merge_contact_custom_data_keeps_other_keys(): + api = FakeCtrl({"type": "cmdOk"}) + contact = {"contactId": 4, "customData": {"other": 1}} + await api.api_merge_contact_custom_data(contact, "mine", {"roster": "active"}) + assert api.sent == ['/_set custom @4 {"other": 1, "mine": {"roster": "active"}}'] + + +async def test_merge_contact_custom_data_removing_the_last_key_clears_the_column(): + api = FakeCtrl({"type": "cmdOk"}) + contact = {"contactId": 4, "customData": {"mine": 1}} + await api.api_merge_contact_custom_data(contact, "mine", None) + assert api.sent == ["/_set custom @4"] + + +async def test_merge_group_custom_data_keeps_other_keys(): + api = FakeCtrl({"type": "cmdOk"}) + group = {"groupId": 9, "customData": {"other": 1}} + await api.api_merge_group_custom_data(group, "mine", {"rostered": True}) + assert api.sent == ['/_set custom #9 {"other": 1, "mine": {"rostered": true}}'] + + +async def test_merge_group_custom_data_on_a_group_with_no_custom_data(): + api = FakeCtrl({"type": "cmdOk"}) + await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) + assert api.sent == ['/_set custom #9 {"mine": 1}'] + + +async def test_a_failed_custom_data_write_raises(): + api = FakeCtrl({"type": "chatCmdError"}) + with pytest.raises(ChatCommandError): + await api.api_merge_group_custom_data({"groupId": 9}, "mine", 1) diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index 7c01ae576a..d40c74a0eb 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -614,3 +614,238 @@ def test_events_raises_if_already_serving(): pass asyncio.run(go()) + + +class _StubApi: + """The controller calls `__aenter__` makes, with nothing behind them.""" + + def __init__(self) -> None: + self.profiles: list[dict] = [] + self.user: dict = {"userId": 1, "profile": {"displayName": "x", "fullName": ""}} + self.address: dict | None = None + + @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 self.user + + async def api_update_profile(self, _user_id, profile): + self.profiles.append(profile) + + async def api_get_user_address(self, _user_id): + return self.address + + async def api_set_address_settings(self, _user_id, _settings): + pass + + async def send_chat_cmd(self, _cmd): + return {"type": "cmdOk"} + + +def _client_with_stub_api(monkeypatch, **kw) -> tuple[Client, _StubApi]: + import simplex_chat.client as client_mod + + api = _StubApi() + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + client = Client(profile=Profile(display_name="x"), db=SqliteDb(file_prefix="/tmp/test"), **kw) + return client, api + + +def _init_returning(api: _StubApi): + """A stand-in for the ChatApi class whose `init` hands back `api`.""" + return type("_Init", (), {"init": staticmethod(lambda *_a, **_kw: _done(api))}) + + +async def _done(value): + return value + + +def test_stop_before_start_is_not_lost(monkeypatch): + """A signal handler installed before startup — the only way to survive a + Ctrl+C during database migrations — sets the stop event before __aenter__ + runs. Clearing it there would begin serving a client the operator has + already stopped.""" + c, api = _client_with_stub_api(monkeypatch) + + async def go(): + c.stop() + assert c.stop_requested + async with c: + assert c.stop_requested, "stop intent was cleared by __aenter__" + await c.serve_forever() # must return immediately, never polling + + api.recv_chat_event = _never_called # type: ignore[attr-defined] + asyncio.run(go()) + + +async def _never_called(*_a, **_kw): + raise AssertionError("receive loop should have exited immediately") + + +def test_stop_requested_is_false_until_stopped(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + assert not c.stop_requested + c.stop() + assert c.stop_requested + + +def test_install_signal_handlers_routes_both_signals(monkeypatch): + import signal as signal_mod + + c, _ = _client_with_stub_api(monkeypatch) + registered: dict[int, object] = {} + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr( + loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb) + ) + c.install_signal_handlers() + + asyncio.run(go()) + assert set(registered) == {signal_mod.SIGINT, signal_mod.SIGTERM} + registered[signal_mod.SIGINT]() # type: ignore[operator] + assert c.stop_requested + + +def test_install_signal_handlers_is_idempotent(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + calls: list[int] = [] + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr(loop, "add_signal_handler", lambda sig, cb, *a: calls.append(sig)) + c.install_signal_handlers() + c.install_signal_handlers() + + asyncio.run(go()) + assert len(calls) == 2, "second call re-registered the handlers" + + +def test_second_interrupt_force_exits(monkeypatch): + """A stop that hangs in stop_chat/close must not trap the operator.""" + import signal as signal_mod + + import simplex_chat.client as client_mod + + c, _ = _client_with_stub_api(monkeypatch) + registered: dict[int, object] = {} + exits: list[int] = [] + monkeypatch.setattr(client_mod.os, "_exit", lambda code: exits.append(code)) + + async def go(): + loop = asyncio.get_running_loop() + monkeypatch.setattr( + loop, "add_signal_handler", lambda sig, cb, *a: registered.__setitem__(sig, cb) + ) + c.install_signal_handlers() + + asyncio.run(go()) + on_interrupt = registered[signal_mod.SIGINT] + on_interrupt() # type: ignore[operator] + assert exits == [] + on_interrupt() # type: ignore[operator] + assert exits == [130] + + +def test_sync_profile_applies_a_change_made_after_start(monkeypatch): + """The name a bot can use may only be knowable once the database is + readable, which is after start. Without this the profile could only be + set before the client was started.""" + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + assert api.profiles == [], "update_profile=False still synced on start" + c.profile.display_name = "Helpdesk" + assert await c.sync_profile() is True + + asyncio.run(go()) + assert api.profiles == [{"displayName": "Helpdesk", "fullName": ""}] + + +def test_sync_profile_is_a_no_op_when_nothing_differs(monkeypatch): + """api_update_profile broadcasts to every contact; an unchanged profile + must not become traffic for all of them.""" + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + assert await c.sync_profile() is False + + asyncio.run(go()) + assert api.profiles == [] + + +def test_sync_profile_without_an_active_user(monkeypatch): + c, api = _client_with_stub_api(monkeypatch, update_profile=False) + + async def go(): + async with c: + api.user = None # type: ignore[assignment] + with pytest.raises(RuntimeError, match="no active user"): + await c.sync_profile() + + asyncio.run(go()) + + +def test_sync_profile_keeps_the_bot_address_in_the_profile(monkeypatch): + """The address is embedded by the startup sync; a later sync must not + drop it, or the profile would stop advertising where to connect.""" + import simplex_chat.client as client_mod + + api = _StubApi() + api.address = { + "connLinkContact": {"connFullLink": "https://l"}, + "addressSettings": {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}, + } + api.user = { + "userId": 1, + "profile": {"displayName": "x", "fullName": "", "contactLink": "https://l"}, + } + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + update_profile=False, + ) + + async def go(): + async with bot: + bot.profile.display_name = "Helpdesk" + await bot.sync_profile() + + asyncio.run(go()) + assert api.profiles[0]["contactLink"] == "https://l" + + +def test_profile_can_be_replaced(monkeypatch): + c, _ = _client_with_stub_api(monkeypatch) + c.profile = Profile(display_name="other", full_name="Other") + assert c._profile_to_wire() == {"displayName": "other", "fullName": "Other"} + + +def test_the_profile_image_is_checked_before_it_is_sent(monkeypatch): + """An image the apps cannot decode is stored and broadcast by the core, + and then shows as an empty avatar to every contact.""" + c, _ = _client_with_stub_api(monkeypatch) + c.profile = Profile(display_name="x", image="data:image/jpeg;base64,AAA") + with pytest.raises(ValueError, match="must start with"): + c._profile_to_wire() + c.profile = Profile(display_name="x", image="data:image/png;base64,AAA") + assert c._profile_to_wire()["image"] == "data:image/png;base64,AAA" diff --git a/packages/simplex-chat-python/tests/test_codegen.py b/packages/simplex-chat-python/tests/test_codegen.py index 509d919cfd..c5842f5d56 100644 --- a/packages/simplex-chat-python/tests/test_codegen.py +++ b/packages/simplex-chat-python/tests/test_codegen.py @@ -2,7 +2,7 @@ import typing -from simplex_chat.types import CC, CEvt, CR, T +from simplex_chat.types import CC, CR, CEvt, T def test_types_module_imports(): diff --git a/packages/simplex-chat-python/tests/test_native_cache.py b/packages/simplex-chat-python/tests/test_native_cache.py index 55084eeae8..c2938ee3e4 100644 --- a/packages/simplex-chat-python/tests/test_native_cache.py +++ b/packages/simplex-chat-python/tests/test_native_cache.py @@ -3,7 +3,7 @@ from pathlib import Path import pytest -from simplex_chat._native import _cache_root, _resolve_libs_dir, _download +from simplex_chat._native import _cache_root, _download, _resolve_libs_dir from simplex_chat._version import LIBS_VERSION diff --git a/packages/simplex-chat-python/tests/test_native_url.py b/packages/simplex-chat-python/tests/test_native_url.py index df96fff8ae..12270c9db1 100644 --- a/packages/simplex-chat-python/tests/test_native_url.py +++ b/packages/simplex-chat-python/tests/test_native_url.py @@ -1,6 +1,8 @@ from unittest.mock import patch + import pytest -from simplex_chat._native import _platform_tag, _libs_url, _libname + +from simplex_chat._native import _libname, _libs_url, _platform_tag from simplex_chat._version import LIBS_VERSION diff --git a/packages/simplex-chat-python/tests/test_util.py b/packages/simplex-chat-python/tests/test_util.py index 983b1c2a56..3ea0d87e6d 100644 --- a/packages/simplex-chat-python/tests/test_util.py +++ b/packages/simplex-chat-python/tests/test_util.py @@ -1,3 +1,5 @@ +import pytest + from simplex_chat import util @@ -173,3 +175,73 @@ def test_reaction_text_emoji(): def test_reaction_text_tag(): r = {"chatReaction": {"reaction": {"type": "unknown", "tag": "thumbs_up"}}} assert util.reaction_text(r) == "thumbs_up" + + +def test_merged_custom_data_adds_a_key_keeping_the_others(): + data = {"other": {"kept": True}} + assert util.merged_custom_data(data, "mine", {"roster": "active"}) == { + "other": {"kept": True}, + "mine": {"roster": "active"}, + } + + +def test_merged_custom_data_does_not_mutate_the_original(): + data = {"other": 1} + util.merged_custom_data(data, "mine", 2) + assert data == {"other": 1} + + +def test_merged_custom_data_replaces_an_existing_key(): + assert util.merged_custom_data({"mine": "old"}, "mine", "new") == {"mine": "new"} + + +def test_merged_custom_data_on_an_empty_column(): + assert util.merged_custom_data(None, "mine", 1) == {"mine": 1} + + +def test_merged_custom_data_removes_a_key(): + assert util.merged_custom_data({"mine": 1, "other": 2}, "mine", None) == {"other": 2} + + +def test_merged_custom_data_clears_the_column_when_nothing_is_left(): + # None is what the set commands read as "clear"; {} would be a wasted write + # of an empty object. + assert util.merged_custom_data({"mine": 1}, "mine", None) is None + + +def test_merged_custom_data_removing_a_key_that_is_not_there(): + assert util.merged_custom_data({"other": 2}, "mine", None) == {"other": 2} + + +def test_conn_status_reads_the_tag(): + contact = {"activeConn": {"connStatus": {"type": "ready"}}} + assert util.conn_status(contact) == "ready" + + +def test_conn_status_without_a_connection(): + # api_create_member_contact produces exactly this: a contact row before + # any connection exists. + assert util.conn_status({"contactId": 3}) is None + + +def test_conn_status_with_a_null_connection(): + assert util.conn_status({"activeConn": None}) is None + + +def test_check_profile_image_accepts_what_the_apps_decode(): + png = "data:image/png;base64,AAA" + jpg = "data:image/jpg;base64,AAA" + assert util.check_profile_image(png) == png + assert util.check_profile_image(jpg) == jpg + + +def test_check_profile_image_rejects_another_media_type(): + # image/jpeg is the easy mistake: the file extension is .jpeg, and the + # core stores it, but no client strips that prefix before decoding. + with pytest.raises(ValueError, match="must start with"): + util.check_profile_image("data:image/jpeg;base64,AAA") + + +def test_check_profile_image_rejects_a_remote_url(): + with pytest.raises(ValueError, match="must start with"): + util.check_profile_image("https://simplex.chat/logo.png") diff --git a/plans/2026-08-04-directory-link-approval.md b/plans/2026-08-04-directory-link-approval.md new file mode 100644 index 0000000000..6c92ca5bd9 --- /dev/null +++ b/plans/2026-08-04-directory-link-approval.md @@ -0,0 +1,100 @@ +# Directory: group link creation at approval + +Date: 2026-08-04 + +## Goal + +- The directory creates the group join link at first approval. +- The directory issues every link data update; the automatic refresh in core is disabled by config. +- The welcome message link requirement is replaced by a post-approval recommendation. +- A link sent to the directory is resolved to its registered group. + +Existing functions are amended; the diff is kept minimal, in code and in tests. + +## 1. Core (simplex-chat library) + +1.1. `ChatConfig`: add `updateGroupLinksFromApp :: Bool`, default `False`. The directory service sets `True` in `directoryService` and `directoryServiceCLI`. + +1.2. `xGrpInfo` (Subscriber.hs ~3750): condition before the fork: + +```haskell +ChatConfig {updateGroupLinksFromApp} <- asks config +unless (useRelays' g'' || updateGroupLinksFromApp) $ + void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' +``` + +`setGroupLinkData'` stays unchanged. The call in `runUpdateGroupProfile` (Commands.hs ~4043) stays unconditional. + +1.3. Link data sync from the directory: a Service.hs helper reads the link with `getGroupLink` and runs `setGroupLinkData NRMBackground user gInfo gLink` (Internal.hs ~1461, exported) via `runReaderT (runExceptT …) cc`; the `GroupInfo` argument supplies the profile. + +## 2. Registration flow (Service.hs) + +2.1. `deServiceJoinedGroup`: after `setGroupRegOwner` — set `GRSPendingApproval 1`, notify the owner ("Joined the group X. Registration is pending approval — it may take up to 48 hours."), send `recommendedSettingsNotice`, call `verifyAndSendToApprove`. The `APICreateGroupLink` call and the `GRSPendingUpdate` transition are removed. This mirrors the channel flow in `deMemberUpdated`. + +2.2. `DCApproveGroup`, after the duplicate and roles checks, before `setGroupStatusPromo`: + +- link record present (legacy registration or re-approval): the §1.3 sync with the `GroupInfo` from `getGroupAndReg`; +- link record absent: `APICreateGroupLink groupId GRMember`; on failure reply with the error and keep the status. + +Owner notification: approved, the link, "We recommend adding this link to the group welcome message." + +## 3. Profile update handling (`deGroupUpdated`, non-public groups) + +3.1. `GroupProfileUpdate` and `groupProfileUpdate` are replaced by one check — link-only change: fields other than description equal, descriptions equal after removal of the service link and the recommended phrase "Link to join the group :", with `T.words` normalization. The link is read with `APIGetGroupLink`; on `SEGroupLinkNotFound` the comparison runs without link removal; on other failures — log, no action (as today). The description-contains-link check (`profileGroupLinkText`, Service.hs ~641) moves to a helper shared with §6. + +3.2. Transitions. `n'` — n+1 when the status is `GRSPendingApproval n`, 1 otherwise. "Send to approve" — `checkRolesSendToApprove` as today. + +| status | change | status' | actions | +|---|---|---|---| +| GRSActive | link-only | GRSActive | notify owner; §1.3 sync with the event `toGroup` | +| GRSPendingApproval n | link-only | unchanged | — (the sent approval code stays valid) | +| GRSSuspended, GRSSuspendedBadRoles | link-only | unchanged | — | +| GRSPendingUpdate (legacy data only) | any | GRSPendingApproval 1 | notify owner; send to approve | +| any of the above | other change | GRSPendingApproval n' | notify owner and admins; send to approve | + +The `GRSPendingUpdate` branch of the `deGroupUpdated` dispatch (~533) is removed; the status is routed through `processProfileChange`. Link removal while active keeps the group listed; `GRSPendingUpdate` is unreachable for new registrations. Channel handling (`publicGroupProfileChange`) stays unchanged. + +## 4. Command replies (Service.hs) + +- `DCMemberRole`, group without a link: "The group link is created when the group is approved." +- `DCShowUpgradeGroupLink`: the `SEGroupLinkNotFound` reply mentions approval; the `APIAddGroupShortLink` upgrade branch requires `GRSActive`. +- `DCResumeGroup`, `DCSuspendGroup`, `DCApproveGroup` fallback replies include `groupRegStatusText`. +- `DCHelp DHSRegistration`: the welcome message step is replaced by approval; link inclusion is described as a post-approval recommendation. + +## 5. Search by link + +5.1. Detection: in `DCSearchGroup`, when the formatted text holds a `SimplexLink` of type `XLGroup` or `XLChannel`, the first such `simplexUri`, wrapped with `aConnectTarget`, is the lookup target. + +5.2. Lookup: `APIConnectPlan userId (Just target) PRMNever Nothing`: + +- `CPGroupLink (GLPOwnLink g)`, `CPGroupLink (GLPKnown {groupInfo})` → `getGroupReg` by group id; +- other plans, `CENotResolvedLocally` → unknown link. + +5.3. Replies: + +- user, `GRSActive` → the found-group message (single entry, existing format); +- user, other status or unknown link → the current not-found reply; +- admin, registered → group info with `groupRegStatusText` and owner, as in `sendGroupsInfo` admin format; +- admin, unknown link → "This link is not registered in the directory." + +5.4. Card path: `deChatLinkReceived` branches without a valid owner signature run the §5.2 lookup on `connLink`; `GLPKnown` → reply per §5.3; otherwise the current replies. + +## 6. Link in listings and search results + +6.1. Bot search results: `sendFoundGroups` appends the join-link line for non-public groups when the description omits the link; result rows are extended with the group link via `getGroupLink`. + +6.2. `groupDirectoryEntry` (Listing.hs): the join-link line, currently appended for public groups, is appended for non-public groups too when the description omits the link. + +## 7. Legacy registrations + +Registrations created before deployment keep their links. Their updates follow §3.2. Their approval syncs the link data (§2.2, first branch). + +## 8. Tests (DirectoryTests.hs) + +- Amend `submitGroup`, `groupAccepted`, `completeRegistrationId`, `updateProfileWithLink`, `notifySuperUser`, `approveRegistrationId` to the new sequence — submit → pending approval → approve → link in the approval notification — changing only the affected expected lines. +- New cases: link-only description change keeps the listing and syncs link data; content change requires re-approval; link-only change while pending keeps the approval code valid; profile change while suspended; legacy waiting-for-link registration moves to approval on profile change; `/resume` reply with status; `/role` and `/link` replies before approval; search by link as user and as admin; card from a non-owner for a listed group. + +## 9. Docs + +- `apps/simplex-directory-service/README.md`: registration steps and the state machine section. +- Bot `/help` text is covered by §4. diff --git a/plans/2026-08-07-webm-video-detection.md b/plans/2026-08-07-webm-video-detection.md new file mode 100644 index 0000000000..e8d4ff1e08 --- /dev/null +++ b/plans/2026-08-07-webm-video-detection.md @@ -0,0 +1,19 @@ +# Send dropped `.webm` as video only when it has a video track + +## Problem + +Dragging a `.webm` file onto the desktop compose area attaches it as a plain file instead of embedding it as a video with a preview frame and duration. Every other video container the app recognises (`.mov`, `.avi`, `.mp4`, `.mpg`, `.mpeg`, `.mkv`) embeds. The same omission hides `.webm` from the "Attach → video" file picker, so the only way to send one is "Choose file", which sends it as a document. + +## Cause + +`isVideoUri` (`apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt:298`) classifies attachments by file extension and does not list `.webm`; the desktop picker filter `isVideo` (`apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/Videos.desktop.kt:5`) repeats the same list with the same omission. `onFilesAttached` groups the dropped URIs by `isImage(it) || isVideoUri(it)`, so a `.webm` fails both predicates, falls into the files group and reaches `processPickedFile`, which builds a `ComposePreview.FilePreview`. + +Adding the extension to both lists is not sufficient on its own. Unlike the other containers, `.webm` is used about as often for audio alone as for video — it is `MediaRecorder`'s default audio container, and Opus/Vorbis in WebM is widespread on the web. An audio-only file classified as video reaches the video branch of `processPickedMedia`, where `getBitmapFromVideo` finds no video track, returns a null preview and raises the "video decoding" alert; the item is then skipped and nothing is attached at all (`ComposeView.kt:366-376`). That is strictly worse than the file attachment the same drop produced before. + +## Fix + +Add `.webm` to both extension lists, and for `.webm` alone decide from the file's content rather than its name. A new `expect suspend fun hasVideoTrack(uri)` (`views/helpers/Utils.kt`) reports whether the container declares a video track, reading metadata only and never decoding a frame. On desktop it is implemented with libvlc's media parse (`platform/VideoPlayer.desktop.kt`), which signals completion with an event rather than a poll, so no frame-decoding budget is needed; measured at 12-346 ms across VP8, VP9, AV1, alpha and a 42 MB file, with a 3 s timeout as a guard against a stuck parse. On Android it uses `MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO`. Either implementation answering "no", or failing, attaches the file as a file, which is always safe. + +`onFilesAttached` consults it only when a `.webm` is actually among the dropped URIs; every other attachment keeps the original synchronous code path on the caller thread, so the change adds no latency and no threading difference to images, documents or the other video containers. Files with a video track are sent as video, the rest as files. + +The content check is applied only where the user has not said how the file should be sent — drag & drop and paste. An explicitly picked video is still trusted: selecting an audio-only `.webm` through "Attach → video" raises the existing decoding error, which matches how the other containers already behave. diff --git a/plans/2026-08-11-desktop-animated-images.md b/plans/2026-08-11-desktop-animated-images.md new file mode 100644 index 0000000000..82a0257b28 --- /dev/null +++ b/plans/2026-08-11-desktop-animated-images.md @@ -0,0 +1,113 @@ +# Animated images on desktop + +## The problem + +`SimpleAndAnimatedImageView` on desktop drew a single `BitmapPainter` and carried the marker +`// LALAL make it animated too`. Android decodes animations with coil, iOS with SwiftyGif, and desktop showed +the first frame and stopped. `ImageFullScreenView` carried a matching marker over the image branch. + +## Why this shape + +**Skia's `Codec`, which skiko already puts on the desktop classpath.** No new dependency. It decodes both GIF +and animated WebP, reports per-frame durations and repeat counts, and supports random access into frames. + +**Not `components-animatedimage`** (declared and unused until this change removed it). Its `animate()` +ignores the result of `allocPixels` and decodes inside composition. A 35-byte GIF declaring 65535x65535 asks +for a 17GB raster; `allocPixels` returns false, and the following `readPixels` throws +`IllegalArgumentException` from inside the composition — a remote crash from anyone who can send a file. It +also decodes on the UI thread, measured at ~11ms per frame for a 1244x554 animation. + +## Bounds + +Everything below is decoded from bytes somebody else composed, so each bound answers a specific crafted +input, and anything outside them keeps showing the still image the chat already renders. Animation degrades +to a picture, never to an error, and failures are never alerted — an alert per malformed file would itself +let a sender disrupt the app. + +| Bound | What it answers | +| --- | --- | +| raster measured in bytes, sides multiplied as `Long` | `65535 * 65535` overflows `Int` to a negative number and would pass a naive budget check | +| per-side cap, independent of the raster bound | 65535x32 is only 2.1MP and would otherwise animate with a 65535-pixel scanline | +| bytes per pixel read from the codec | the file chooses its color type; the budget must not assume four bytes | +| file size checked before the bytes are copied natively | Skia copies the encoded bytes and scans them to count frames | +| magic-byte prefilter (`GIF8`, `RIFF....WEBP`) | photos are most of what a chat holds and none are animations; they never reach a second decoder | +| `allocPixels` result honoured | it reports failure by returning false, and reading into an unallocated bitmap throws | +| frame count bound | counting the frames also builds a table of them, which a file of minimal frames makes several times its own size, and the codec holds it for as long as the animation plays | +| rebuilt frame chain bound | a frame the codec is given no prior frame for is rebuilt from its whole chain, and Skia recurses to do it: frames alternating their disposal make that chain as long as the file likes, and 8000 frames of it overflows the native stack and kills the app, which no catch can prevent. Real animations rebuild nothing at all | +| destination allocated with a premultiplied alpha type | the codec reports the alpha type of the first frame, and a frame that has alpha cannot be read into an opaque bitmap | +| frame duration floor, and 100ms substituted for delays of 10ms and less | the frames a file is allowed can all declare no delay at all, and Skia reports the usual "as fast as possible" delay of one centisecond as 10ms | +| a frame is waited out for what it cost as well as what it asks for | one very expensive frame among cheap ones owes nothing once the cheap ones have paid the debt off, and held 96.7% of a decoder thread indefinitely; waiting out the cost leaves any animation about half of one | +| an animation that owes too much for its frames stops on the one it reached | frames that alternate expensive with cheap are never slow twice in a row, so a count that resets never stops them | +| every native call that reads the file is inside an exception boundary | the frame count, the frame table and the repeat count are read from it too | + +Long frame delays are honoured rather than clamped - they are the author's, and they cost only the codec, the +raster and the frame table staying alive while nothing decodes. + +## Cost, and the optimisations that were rejected + +A frame continues the one before it, and the codec has to be told that the bitmap already holds it. Without +that it decodes the whole chain back to the last independent frame, so a frame costs as much as its index and +a loop costs the square of the frame count. Measured over one loop of the GIFs in `images/`, decode only: + +| | frames | chain re-decoded | prior frame reused | +| --- | --- | --- | --- | +| files.gif | 196 | 5.93 ms/frame | 0.06 ms | +| connection.gif | 240 | 9.22 ms/frame | 0.09 ms | +| groups.gif | 309 | 9.10 ms/frame | 0.05 ms | +| user-addresses.gif | 1041 | 25.92 ms/frame, worst 77 ms | 0.04 ms | + +Pixels are identical either way. The cost of a frame is then its own, and an animation stops on the frame it +reached once it owes too much: a frame over 100ms counts double what a frame under it forgives. This is wall +time, so a single frame can overrun by being descheduled, and a busy machine should not turn a cheap +animation into a still - but a file whose frames alternate expensive and cheap is never slow twice in a row, +and a run of them is what a count that resets would miss. Measured on a 1920x1920 GIF of 400 such frames, which holds +67% of a core indefinitely against a count that resets. Frames tuned to stay just under the threshold owe +nothing at all, and one expensive frame among cheap enough ones owes nothing for long, which is why a frame +is also waited out for what it cost: a frame of 3s among four cheap ones drops from 96.7% of a decoder thread +to 49.3%, every frame at 99ms from 83.0% to 49.9%, and the GIFs in `images/` stay exactly where they were - +none of their frames decodes in as long as it asks to be shown, by three orders of magnitude. + +Two optimisations were measured and **rejected**. Both were measured before the prior frame was reused, so +their per-frame figures are against a decode that was two orders of magnitude more expensive; the conclusions +are kept because they are about ratios, but the numbers are worth taking again: + +- **Decoding at display size.** Scaled decode is supported at arbitrary sizes, but it costs CPU rather than + saving it: 2000x891 goes from 177.8ms to 300.3ms per frame (+68%) to save 59% of the raster — and it only + engages on the files that are already the most expensive. +- **Half-depth pixels.** Skia refuses `RGB_565` and `ARGB_4444` for GIF outright. It works only for opaque + WebP, at +11% decode for -50% raster, which does not justify a format-specific path. + +What was kept: decoding is confined to two threads of the shared pool, so untrusted decode work cannot starve +the long running calls that share it; and frames are only decoded while they can be seen — not while the app +is minimized or sits in the tray, not while the image is behind the privacy blur, where each frame would otherwise be +decoded, uploaded and then blurred away again for nobody, and not while a full screen modal covers the +chat, which is shown beside it rather than in place of it: the viewer would otherwise leave the same +animation decoding twice, and the rest of the chat decoding where nobody can see it. The chat list preview stays a still image for +the same reason: it is a 36sp box that the desktop layout keeps on screen the whole time, so animating it +would hold a raster and spend a frame of work per listed chat, without pause. + +## Verification + +- 20 000 fuzzed mutations (bit flips, truncations, header corruption) over a real corpus plus crafted hostile + files: no exception escapes the structure, no hangs. +- Frames advance, per-frame delays are read correctly, and the loop wraps back to frame 0 after a full cycle + with byte-identical pixels. +- An oversized animation is refused by the bounds and still renders through the existing still-image path. +- A GIF of 8000 frames alternating their disposal, which passes every other bound at an 8x8 raster, crashed + the process with SIGSEGV before the chain bound and is refused by it now, while the GIFs in `images/`, a + 1920x1920 animation and a GIF disposing to what came before it all still play. +- Every frame of the GIFs in `images/` decodes with the prior frame reused, with pixels identical to decoding + the chain, and a GIF whose first frame is opaque and disposed to the background decodes past its first frame + only into a premultiplied destination. +- Unit tests cover every bound as arithmetic - the raster, the frame count, the rebuilt chains, the frame + durations and the debt an expensive frame owes; skiko's native library is not on the test + runtime classpath, so decoding is measured with the library added to a standalone classpath. + +## Deliberately not in this change + +- **WebP still images do not decode on desktop at all.** Desktop decodes images with ImageIO, which has no + WebP reader, so a received `.webp` never loads and picking one to send is dropped. Both the chat item and + the full screen viewer reach this code only after that decode has succeeded, so until that separate fix + lands it is GIFs that animate in the app, and the WebP path here is exercised by measurement only. +- **The decode raster is left to the collector.** Releasing it explicitly needs to know which thread Compose + Desktop draws on, and skiko uses a different redrawer per platform; guessing risks a use-after-free. diff --git a/plans/2026-08-13-auto-accept-group-invitations.md b/plans/2026-08-13-auto-accept-group-invitations.md new file mode 100644 index 0000000000..fdce3ebd2f --- /dev/null +++ b/plans/2026-08-13-auto-accept-group-invitations.md @@ -0,0 +1,149 @@ +# Auto-accept Group Invitations — Plan + +## Table of Contents +1. [Context](#1-context) +2. [Why This Belongs in the Core](#2-why-this-belongs-in-the-core) +3. [Design](#3-design) +4. [Decisions and Justification](#4-decisions-and-justification) +5. [Scope](#5-scope) +6. [Verification](#6-verification) + +--- + +## 1. Context + +**Problem**: Every group invitation requires the user to tap accept, even when they have +already decided they want to join groups from their contacts. Users in active communities +accumulate invitations that are pure friction — the decision was made when they added the +contact, not when the invitation arrived. + +**Precedent**: Privacy & security already carries a per-profile "Contact requests from +groups / Auto-accept" toggle (`users.auto_accept_member_contacts`, added in +`M20250729_member_contact_requests`). This change adds the equivalent for group +invitations — per-profile flag, same command pair — and regroups both under a single +**Auto-accept** section, so the two rows name what is being accepted rather than repeating +"Auto-accept" as two adjacent section headers: + +``` +Auto-accept + Contact requests in groups [ ] + Group invitations [ ] + These settings are for your current profile . +``` + +--- + +## 2. Why This Belongs in the Core + +The naive implementation is client-side: observe `CEvtReceivedGroupInvitation`, then call +`APIJoinGroup`. That is wrong here for three reasons. + +1. **It does not work when the app is closed.** Invitations arrive through the notification + extension and background message processing. A client-side rule cannot run there. +2. **`APIJoinGroup` blocks.** It takes `withGroupLock`, calls the agent's `joinConnection` + synchronously, and rolls member status back to `GSMemInvited` via `catchAllErrors` on + failure. Driving that from an event handler couples message processing to a network + round trip. +3. **It would be reimplemented per client.** Android, desktop and iOS would each carry the + decision, and they would drift. + +The flag therefore lives on `users`, and the decision is made where the invitation is +received. + +--- + +## 3. Design + +`processGroupInvitation` already contained an async accept path, used when an invitation +matches a group link the user opened: + +``` +prepareAgentJoin -> createMemberConnectionAsync -> joinAgentConnectionAsync +``` + +The outcome is reported later against the `CFJoinConn` command id, so nothing blocks. This +change does not add a second mechanism — it turns the existing two-way branch into three, +and auto-accept takes the path that already exists: + +| Condition | Behaviour | +|---|---| +| invitation matches an opened group link | join async, no chat item (unchanged) | +| profile auto-accepts, membership still `GSMemInvited` | join async, record accepted item | +| otherwise | create pending invitation item, notify (unchanged) | + +The shared sequence is extracted as `joinGroupAsync`; the invitation item as +`createInvitationItem`, parameterised by `CIGroupInvitationStatus` rather than a boolean. + +--- + +## 4. Decisions and Justification + +**Per-profile, not global.** Matches the sibling toggle and the user's mental model: a +profile is an identity, and willingness to auto-join groups is a property of that identity. +An incognito or work profile should not inherit a personal profile's setting. + +**A chat item is still recorded.** The group-link branch creates no item because the user +initiated the join. Auto-accept is not user-initiated, so a `CIRcvGroupInvitation` with +status `CIGISAccepted` is written to the chat with the inviting contact — a durable record +of who added the user to what. It is deliberately left counting as unread +(`ciRequiresAttention`), so an auto-join is noticed rather than silent. + +**`hostContact` is reported only for group links.** Clients respond to `hostContact` on +`CEvtUserAcceptedGroupSent` by replacing the transient host connection view with the group +and removing that chat. That is right for a group link, where the contact is a placeholder +created to join. For a plain invitation the contact is a real one, and removing their chat +would be destructive — so auto-accept passes `Nothing`, matching `APIJoinGroup`. + +**The join only runs while membership is `GSMemInvited`.** `createGroupInvitation` is +idempotent on `inv_queue_info`: a resent invitation returns the existing group rather than +failing. Without the guard, every resend would open another agent connection, which a +hostile or buggy host could drive indefinitely. A resend after joining is ignored. + +**UI strings reuse legacy keys deliberately.** The section header, the contact-requests row +and the footer use `auto_accept_contact`, `settings_section_title_contact_requests_from_groups` +and `receipts_section_description` — key names that no longer describe where they are used. +This is intentional: those keys are already translated in 35, 20 and 28 of 41 locales +respectively, while any newly added key ships English everywhere until translators catch up, +which would have put an English header and footer around a translated row. Only +`group_invitations` is genuinely new, so the feature adds exactly one string. Renaming these +keys to match their new use would discard the existing translations. All 28 translations of +`receipts_section_description` were checked and none mention receipts, so the reuse is safe. + +**Security note.** This converts a user-gated action into an automatic one: a contact can +cause the client to open group connections and fetch history without a prompt. It is +opt-in and per-profile, and channels cannot be used for it — `processGroupInvitation` +rejects `publicGroup` invitations outright. No rate cap is applied; the setting is +explicit. + +**Known behaviour.** Clients drop events for non-active profiles (`active(user)` guard), so +a group auto-accepted on a background profile becomes visible when switching to that +profile. The join itself happens on arrival: `subscribeUsers` passes the agent the active +user's id rather than the user list, and the agent enumerates every user's servers from its +own store — the active id orders subscriptions, it does not filter them. + +--- + +## 5. Scope + +| Layer | Change | +|---|---| +| Schema | `users.auto_accept_group_invitations` (`M20260813`, SQLite + Postgres) | +| Core | `User` field; `APISetUserAutoAcceptGroupInvitations` / `SetUserAutoAcceptGroupInvitations`; third branch in `processGroupInvitation` | +| Bot API | documented command; regenerated TypeScript/Python bindings and markdown | +| Android/desktop | Privacy & security: two per-profile toggles regrouped under one **Auto-accept** section; the contact-requests row relabelled "Contact requests in groups" | +| iOS | same section in `PrivacySettings.swift` | + +--- + +## 6. Verification + +- Schema dump, `.lint`, strict tables, and **down-migration round-trip** pass; the down + migration restores the original DDL byte-for-byte, so no skip-list entry is needed. +- JSON fixtures and all Bot API doc/codegen specs pass with no regenerated drift. +- `testGroupCheckMessages` and `testGroupLink` pass — the manual and group-link branches + are behaviour-preserving through the refactor. +- Three new tests: auto-accept on the active profile, on a second profile, and on an + inactive profile while another is active. + +**Not verified**: iOS is not compiled (no toolchain available); the Postgres schema test is +gated behind `#if defined(dbPostgres)` and was not run. diff --git a/plans/2026-08-18-desktop-video-preview-aspect-ratio.md b/plans/2026-08-18-desktop-video-preview-aspect-ratio.md new file mode 100644 index 0000000000..f3fa4bfe85 --- /dev/null +++ b/plans/2026-08-18-desktop-video-preview-aspect-ratio.md @@ -0,0 +1,73 @@ +# Desktop: video preview and playback stretched for AV1 + +## Problem + +Videos sent from the desktop app arrive with the wrong aspect ratio. It is most visible with +AV1, and only at some resolutions. The preview image sent with the message carries the wrong +dimensions, so the distortion is seen by every recipient on every platform, not only by the +sender. Desktop playback is distorted in the same way. + +## Cause + +The preview frame and the playback surface both come from `SkiaBitmapVideoSurface`, which +allocates its bitmap from the size libvlc passes to the vmem buffer format callback. + +That size is the size the decoder padded the picture to, not the size of the picture. dav1d +pads to a multiple of 128, so a 1920x1080 AV1 video is reported as 1920x1152. libvlc then sets +the visible area of the output format to whatever size the callback returns, which makes the +converter stretch the picture to fill it — the buffer holds a stretched frame, not a padded one. + +Measured against the bundled VLC 3.0.21, comparing the resulting bitmap with the true picture: + +| source | AV1 bitmap | error | H264 bitmap | error | +| ------------- | ---------- | --------------- | ----------- | ------ | +| 1920x1080 | 1920x1152 | 6.7% too tall | 1920x1090 | +0.9% | +| 1280x720 | 1280x768 | 6.7% too tall | 1280x738 | +2.5% | +| 640x360 | 640x384 | 6.7% too tall | 640x386 | +7.2% | +| 1080x1350 | 1152x1408 | 2.2% too wide | 1088x1378 | -1.3% | +| 1080x1080 | 1152x1152 | none | 1088x1090 | +0.2% | +| 1024x768 | 1024x768 | none | 1024x770 | +0.3% | + +Sizes already on the 128 grid are unaffected, which is why the report was "only at certain +dimensions". H264 pads much less, so it was there all along but barely visible. + +Android and iOS are not affected: they use `MediaMetadataRetriever` and return cropped frames. + +## Fix + +Ask libvlc for the size of the track being played instead of accepting the padded size, and +fall back to the padded size when the track is not known. The media player is captured in +`attach`, which always runs before the format callback, because it is what registers the +native callbacks in the first place. + +Two details the implementation depends on, both observed rather than assumed: + +- The format is negotiated more than once, and vlc has not selected the track on the first + calls (`video().track()` returns -1 there), so the single-track fallback is load-bearing. +- The first listed video track is not necessarily the one being decoded. Matching on the + playing track id keeps a file with several video tracks correct; an earlier revision using + the first track made such a file worse than before the fix (320x240 instead of 1920x1080). + +Fixing it at the buffer format keeps the preview and playback correct from one change, and +costs nothing: libvlc already runs a converter to fill the buffer, so it is only given the +right destination size. Rescaling the snapshot afterwards was considered and rejected — it +leaves playback distorted and resamples the frame twice. + +## Verification + +- The buffer produced with the fix is pixel identical to the frame decoded by ffmpeg + (PSNR inf) for both landscape and portrait clips. +- The real compiled class driven against the bundled VLC 3.0.21 produces correct bitmaps + where the current code does not: 1920x1152 -> 1920x1080, 1152x1408 -> 1080x1350, + 1280x768 -> 1280x720, 768x384 -> 641x361. +- 33 clips covering AV1/H264/VP9, mp4/mkv/webm, rotated, anamorphic, multi-track, cover art, + odd and 1x1 sizes, audio only and corrupt files, on both VLC 3.0.21 and 3.0.23. +- No buffer/format tearing across repeated, pooled and concurrent playback; with the fix every + negotiation returns the same size, where before they disagreed. + +## Out of scope + +- `getBitmapFromVideo` reads the orientation from the first video track and has the same + first-track assumption. +- Sample aspect ratio is ignored throughout, so anamorphic video is still shown with square + pixels. Unchanged by this fix. diff --git a/plans/2026-08-18-member-profile-open-in-large-groups.md b/plans/2026-08-18-member-profile-open-in-large-groups.md new file mode 100644 index 0000000000..2e5ccc3bbd --- /dev/null +++ b/plans/2026-08-18-member-profile-open-in-large-groups.md @@ -0,0 +1,150 @@ +# Open Member Profile Without Loading All Group Members + +## Context + +Tapping a member's avatar in chat history takes several seconds in a group with +10000 members, on every tap, on Android and desktop. + +**Root cause**: `showMemberInfo` (ChatView.kt:499) awaits three API calls before +showing the modal: + +1. `apiGroupMemberInfo` — single member, O(1) in group size +2. `apiGetGroupMemberCode` — single member, O(1) +3. `setGroupMembers` (ChatListNavLinkView.kt:254) — `apiListMembers`, the **whole + member list** + +Call 3 is the cost. `APIListMembers` (Commands.hs:3212) runs `getGroup`, which +loads every member with its profile (Groups.hs:938, 1222), the profile includes +the avatar (`p.image` in `groupMemberQuery`, Shared.hs:762), and the result is +encoded to JSON, passed across the FFI boundary and decoded into 10000 +`GroupMember` objects by the client. The codebase already annotates this call as +"very heavy query in large groups" (SimpleXAPI.kt:846). + +There is no `membersLoaded` guard on this call, unlike `GroupMentions.kt:116`, so +the full list is re-loaded on *every* tap even when it is already in the model. + +Measured on a 10009-member group (real member rows, half with a 11.7 KB avatar): +the SQL itself takes 0.21 s and returns ~64 MB of column data (5.1 MB without +avatars). The seconds are the JSON encode/transfer/decode of that payload. + +The full member list is not needed to show one member's profile. It is loaded +only so that `chatModel.getGroupMember` (ChatModel.kt:357) resolves the member +for the modal (ChatView.kt:521) and for the "Verify security code" screen +(GroupMemberInfoView.kt:209). + +## Solution Summary + +Do not load the member list on this path. Add the opened member to the model +instead, and show the modal — this is what the iOS app already does +(ChatView.swift:2051-2058, since 03bc4e5d0, "ios: display reactions in groups by +member"). The Kotlin path was never updated to match. + +```kotlin +val (updatedMember, code) = if (member.memberActive) { + val memCode = chatModel.controller.apiGetGroupMemberCode(...) + (memCode?.first ?: r?.first ?: member) to memCode?.second +} else { + (r?.first ?: member) to null +} +if (!isActive || chatModel.chatId.value != groupInfo.id) return@launch +withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(chatRh, groupInfo, updatedMember) +} +``` + +After the change the tap runs two single-row queries. Measured against the core +with 10009 members and 100035 messages in the group: `APIGroupMemberInfo` takes +1-2 ms, first call included, and does not depend on group size. + +## Technical Design + +### Which member is added to the model + +The member returned by `apiGetGroupMemberCode` is preferred over the one from +`apiGroupMemberInfo`. `APIGetGroupMemberCode` (Commands.hs:2016) clears +verification in the database when the peer's security code no longer matches +(`setGroupMemberVerified ... Nothing` / `setConnectionVerified ... Nothing`) and +returns the updated member. `apiGroupMemberInfo` runs before that, so its member +can still show the connection as verified. The previous code re-read all members +from the database *after* the code call, so the model saw the cleared state; +using the code call's member preserves that behaviour, and the verified shield +(GroupMemberInfoView.kt:736) does not go stale. + +### Guard on the open chat + +`upsertGroupMember` (ChatModel.kt:927) is a no-op when the open chat changed +while the two calls were in flight, which would leave `getGroupMember` null and +open an empty card. The explicit `chatModel.chatId.value != groupInfo.id` check +closes the modal path in that case instead. The previous code filled the model in +that race by writing the *previous* group's members into it — the stale data +hazard that `upsertGroupMember`'s own comment warns about (ChatModel.kt:936). + +### Duplicate protection is retained + +`#5462` ("improving group members loading to prevent crashes") made the wholesale +replacement safe against duplicated entries crashing `LazyColumn`. +`upsertGroupMember` carries the same protection: it clears the list when the +first member belongs to another group (ChatModel.kt:936) and looks the member up +by index before appending (ChatModel.kt:940, 956-966). + +## Consequences + +`chatModel.groupMembers` can now hold a partial list (previously it was either +empty or complete). This state already existed — channel creation writes a +relays-only list (ComposeView.kt:693) — and `membersLoaded` is deliberately left +`false`, so every screen that needs the full list still loads it: +`GroupChatInfoView.kt:117`, `ChannelMembersView`, `ChannelRelaysView.kt:38`, +`MemberSupportView.kt:45`, `addGroupMembers` (ChatView.kt:3217), and +`GroupMentions.kt:116` which checks the flag. + +One behaviour changes: + +- **Mention picker.** Until `@` triggers the load, the picker briefly lists the + members opened so far instead of nothing. Self-correcting. + +The relay removal warning is *not* affected. `activeRelays.size <= 1` +(GroupMemberInfoView.kt:250, GroupChatInfoView.kt:277) is computed from the +model, so it is already conservative while the member list is loading — with an +empty list the count is 0 and the warning fires. A partial list is a subset, so +the count can only move towards the correct value, and the opposite error is +impossible: a subset with two active relays implies the group has at least two. +It is also not reachable from the modified path — messages delivered through +relays have no item member (`CDChannelRcv`, Subscriber.hs:2161, `chatItemMember` +returns Nothing, Messages.hs:375) and their avatar opens chat info +(ChatView.kt:2150), not member info. A relay's profile is opened from the +channel members and relays screens, which load all members themselves +(GroupChatInfoView.kt:117, ChannelRelaysView.kt:38). + +The bulk refresh of all members that happened as a side effect of every tap is +gone; each screen refreshes its own data on open. + +## Alternatives Rejected + +- **Guard the load with `!membersLoaded`** — cures repeat taps, leaves the first + tap in a group costing seconds. +- **No model write, fall back to the chat item's member in the modal** — smaller + diff, but "Verify security code" (GroupMemberInfoView.kt:209) resolves the + member through the model too and would open empty; it would need a second + fallback in another file. +- **Move `apiGroupMemberInfo`/`apiGetGroupMemberCode` into `GroupMemberInfoView` + behind a `connectionLoaded` gate (full iOS parity, GroupMemberInfoView.swift:291)** — + opens the card with no API calls at all, but changes the view's signature, three + call sites and two previews, and makes the connection rows appear after the card + on every open. Worth doing separately; it does not affect the cost removed here. + +## Out of Scope + +- The first profile opened after entering a large group is still slower than the + rest. The core is not the cause (1-2 ms measured, agent call included); the tap + waits for the UI thread, which is busy composing the just-loaded page of + messages (`MergedItems.create` over all loaded items, ChatView.kt:1800) and + decoding avatars. Fixing it means making chat opening cheaper, or adding the + member to the model synchronously in the click handler as iOS does. +- Opening a second member's profile while one is already open does not switch the + card: `showInView` drives `AnimatedContent` from `modalCount` alone + (ModalView.kt:207), while `modalViews` is a plain list, so a close and open in + one frame leaves the target state unchanged. Pre-existing, unrelated to this + change. +- Message *Info* (ChatView.kt:706) still loads all members; it needs them to + resolve delivery recipients (ChatItemInfoView.kt:550). A narrower core API + would be required. diff --git a/plans/2026-08-22-forward-link.md b/plans/2026-08-22-forward-link.md new file mode 100644 index 0000000000..091bfc60dd --- /dev/null +++ b/plans/2026-08-22-forward-link.md @@ -0,0 +1,185 @@ +# Forward attribution: `forwardLink` in MsgContainer + +When a message is forwarded from a channel (public group), the sending client +attaches the source channel's name, join link, identity and message id; +recipients see "forwarded from \" and can open or join the channel. + +- The link is attached whenever the source is a public group; for other sources + only `forward: true` is sent. +- `forward = Just True` is always set alongside `forwardLink`, so old clients + show plain "forwarded". +- The simplex name is not included: paired with a forwarder-chosen link it + would be an unverifiable claim. It can be added later as a verifiable claim. +- When a forwarded message is received in a group that prohibits SimpleX links + for the sender, the link is removed. + +## Protocol + +`Protocol.hs`. aeson ignores unknown fields and parses an absent field as +`Nothing`, so the addition is compatible in both directions. + +```haskell +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, -- the recipient looks up the local group by this id, then compares groupLink with the stored link + memberId :: Maybe MemberId, -- the author, only for items the author sent as themselves + msgId :: SharedMsgId -- the original item's SharedMsgId + } +``` + +`memberId` is absent for items sent as the channel: their authorship is the +channel's, and subscribers do not see the author's member id. The fill rule is +`chatItemMember` (Messages.hs:369): the member for received authored items, +the membership for own items sent as themselves, absent otherwise. + +- New field `forwardLink :: Maybe ForwardLink` in `MsgContainer` + (Protocol.hs:678) after `forward`; `mcSimple` (:695) sets + `forwardLink = Nothing`. +- `mcForward` (:716) takes `Maybe ForwardLink`: + `mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl}`. +- JSON instances: `deriveJSON defaultJSON ''ForwardLink` before the + `''MsgContainer` splice (:899). + +## CIForwardedFrom + +`Messages.hs:1319`: + +```haskell + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, + chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, + sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, + groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, + groupType :: Maybe GroupType} +``` + +Both variants retain the wire `memberId` and `sharedMsgId`, so a re-forward +re-serializes `ForwardLink` from the CIFF without item lookups. + +- `groupType` in `CIFFGroup` is present exactly when the sent message included + the link, so it doubles as that marker; the apps read it for the source type + icon without lookups. In `CIFFGroupLink` it mirrors the link's type so the + apps avoid inspecting the URI. +- `CIFFGroupLink` is the recipient's variant for an unknown channel; the user + opens it via the connection plan. +- New tag `CIFFGroupLink_` / `"groupLink"` in `CIForwardedFromTag` (:1325). + +## Sending + +`Commands.hs` `APIForwardChatItems`, `prepareForward` group branch (:1094-1110): + +- Local `ciff`: `CIFFGroup` with `memberId = memberId' <$> chatItemMember + gInfo ci`, the item's `itemSharedMsgId`, and `groupType = itemSharedMsgId *> + sourceGroupType gInfo` - `Just` the source profile's type under the same + condition in which `ciffForwardLink` later returns a link (the link value is + computed at the `mcForward` call site, after the `ciff` is built). +- The two `mcForward` call sites - `sendContactContentMessages.prepareMsgs` + (Commands.hs:4772) and `prepareGroupMsg` (Internal.hs:208-209), both matching + `(Nothing, Just _) -> pure (mcForward mc, Nothing)` on + `(quotedItemId, itemForwarded)` - compute the link from the + `CIForwardedFrom` in scope: `ciffForwardLink db ciff` returns the link for + `CIFFGroup` with `groupId` and `sharedMsgId` set, reading the group profile + (current name and link), for `CIFFGroupLink` from its stored fields, and + `Nothing` for other variants. Deriving from the stored `CIForwardedFrom` + attributes a re-forwarded message to the original source. +- `forwardCIFF` (:1130) already returns the original `CIForwardedFrom` when a + forwarded item is forwarded again, so a received `CIFFGroupLink` item is sent + onwards with the same link. + +## Receiving + +`Store/Messages.hs createNewRcvChatItem` (:563-572), inside the existing DB +transaction: + +```haskell +itemForwarded = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward, forwardLink}) | forward == Just True -> ... +``` + +1. `forwardLink = Nothing` -> `CIFFUnknown` (today's behavior). +2. Destination is a group where SimpleX links are prohibited for the sender -> + remove the link: store `CIFFGroup` with only the name and `msgDir = MDRcv` - + attribution text only. The check: the sender's role (the member's for + `CDGroupRcv`, `GROwner` for `CDChannelRcv` - a channel message is posted + with owner authority) against the group's SimplexLinks feature. Direct + chats: the link is kept. +3. Lookup by `publicGroupId`: `group_profiles.public_group_id` is a column + with an existing query that filters on it (Store/Groups.hs:2009-2015). New + query `getGroupViaPublicGroupId`; on a match, compare the received + `groupLink` with the stored one (`sameShortLinkContact`); when both match -> + `CIFFGroup` with `groupId`, the wire `memberId` and `msgId`, `groupType` + from the link's `ContactConnType` (equal to the stored link's type - + `sameShortLinkContact` compares it), and `chatItemId = ciId_` resolved by + the id query factored out of `getGroupChatItemBySharedMsgId` + (`getGroupChatItemBySharedMsgId_`). + The author scope: wire `memberId` absent -> `Nothing` (items sent as the + channel and own items are stored with `group_member_id` NULL); present -> + the member resolved by `member_id`, with the user's own membership mapped + to `Nothing`; an unknown member -> no item. +4. Lookup miss, or the link differs from the stored one -> `CIFFGroupLink` + with the wire fields. + +## DB + +`chat_items` persists `CIForwardedFrom` as columns (`fwd_from_tag, +fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, +fwd_from_chat_item_id`, Store/Messages.hs:606). Migration (SQLite + Postgres, +same shape) adds: + +- `fwd_from_group_type TEXT` (`GroupType`'s `TextEncoding`) +- `fwd_from_group_link BLOB/BYTEA` (the `ToField (ConnShortLink c)` instance + stores `Binary . strEncode`, matching `short_link_contact`) +- `fwd_from_public_group_id BLOB/BYTEA` +- `fwd_from_member_id BLOB/BYTEA` +- `fwd_from_shared_msg_id BLOB/BYTEA` + +Code changes: the CIFF-to-row tuple (Store/Messages.hs:657-660), the +row-to-CIFF case (:2343-2344), the three SELECT lists (:2696, :3085, :3197), +and the INSERT statement in `createNewChatItem_`. Binary columns use `Binary` +on both backends. + +## View / UI + +- `View.hs:1010`: render the source name for `CIFFGroup` and `CIFFGroupLink`. +- `/item info` renders "forwarded from: #\" from `itemForwarded` + when the source item is not stored locally (`CIFFGroupLink` and link-removed + `CIFFGroup`). +- The `CIForwardedFrom` JSON reaches the apps in `CIMeta`: the iOS + (`ChatTypes.swift`) and Kotlin (`ChatModel.kt`) mirrors are extended with the + new field and variant. +- The sender and the recipient of a forwarded message see the same header; the + only difference between them is the goto arrow, shown where the original + item exists locally (`chatTypeApiIdMsgId`), in notes too. +- Two-row header at double the single-header height - row 1: forward icon + + "forwarded from" ("saved from" in notes); row 2: the name in the header text + style, starting under the forward icon. Rendered when the attribution is + part of the message - `CIFFGroupLink`, and `CIFFGroup` with `groupType` + present - and in notes whenever navigation is possible (a local target or a + link), items saved from contacts and p2p groups included. The whole header + opens the source: known - the chat, positioned at the original item when + `chatItemId` is present; unknown - `planAndConnect` with `groupLink`. +- All other forwards keep the single-line header: "forwarded" (p2p forwards + without attribution, the link-removed name-only `CIFFGroup`) or "saved" + (non-navigable notes items). The `forwarded_from_description` and + `saved_from_description` strings are removed; "forwarded from" and + "saved from" are added. +- The goto arrow beside the bubble applies only to locally resolved items + (`chatTypeApiIdMsgId`), never to joining. + +## Tests + +`ChatTests/Groups.hs`: +1. Forward from a channel to a direct chat: the recipient item includes + `CIFFGroupLink` with name/link/publicGroupId/msgId; the view shows + "forwarded from" with the name. +2. Forward to a group where the recipient is a member of the source channel: + the recipient stores `CIFFGroup` with the local groupId. +3. Destination group with SimpleX links prohibited: the link is removed; + attribution text only. +4. Forwarding a received forwarded item again sends the original channel's + link. +5. Old-client compatibility: a container with `forward: true` and no + `forwardLink` parses to `CIFFUnknown`. +6. Private (non-public) source group: the container includes no `forwardLink`. diff --git a/scripts/android/build-android-bundle.sh b/scripts/android/build-android-bundle.sh index b784da2aad..972fb0ee72 100755 --- a/scripts/android/build-android-bundle.sh +++ b/scripts/android/build-android-bundle.sh @@ -23,5 +23,8 @@ unzip -o "$tmp/libsimplex.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/s curl -sSf "$libsup" -o "$tmp/libsupport.zip" unzip -o "$tmp/libsupport.zip" -d "$tmp/simplex-chat/apps/multiplatform/common/src/commonMain/cpp/android/libs/arm64-v8a" -gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean build -cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/release/android-release-unsigned.apk" "$PWD/simplex-chat.apk" +# Build only the arch the libs were downloaded for +sed -i.bak 's/include(.*/include("arm64-v8a")/' "$tmp/simplex-chat/apps/multiplatform/android/build.gradle.kts" + +gradle -p "$tmp/simplex-chat/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease +cp "$tmp/simplex-chat/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-arm64-v8a-release-unsigned.apk" "$PWD/simplex-chat.apk" diff --git a/scripts/android/build-android.sh b/scripts/android/build-android.sh index 7edee9c304..267db9f243 100755 --- a/scripts/android/build-android.sh +++ b/scripts/android/build-android.sh @@ -101,7 +101,7 @@ build() { sed -i.bak 's/${extract_native_libs}/true/' "$folder/apps/multiplatform/android/src/main/AndroidManifest.xml" sed -i.bak 's/jniLibs.useLegacyPackaging =.*/jniLibs.useLegacyPackaging = true/' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak '/android {/a lint {abortOnError = false}' "$folder/apps/multiplatform/android/build.gradle.kts" - sed -i.bak '/tasks/Q' "$folder/apps/multiplatform/android/build.gradle.kts" + sed -i.bak '/^tasks {/Q' "$folder/apps/multiplatform/android/build.gradle.kts" sed -i.bak "s/android.version_code=.*/android.version_code=${vercode}/" "$folder/apps/multiplatform/gradle.properties" for arch in $arches; do @@ -119,7 +119,7 @@ build() { arch_map "$arch" android_tmp_folder="${tmp}/android-${arch}" - android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/release/android-${android_arch}-release-unsigned.apk" + android_apk_output="${folder}/apps/multiplatform/android/build/outputs/apk/foss/release/android-foss-${android_arch}-release-unsigned.apk" android_apk_output_final="simplex-chat-${android_arch}.apk" libs_folder="${folder}/apps/multiplatform/common/src/commonMain/cpp/android/libs" @@ -134,7 +134,7 @@ build() { # Build only one arch sed -i.bak "s/include(.*/include(\"${android_arch}\")/" "$folder/apps/multiplatform/android/build.gradle.kts" - gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleRelease + gradle -p "$folder/apps/multiplatform/" -Psimplex.assets.dir=../../assets clean :android:assembleFossRelease mkdir -p "$android_tmp_folder" unzip -oqd "$android_tmp_folder" "$android_apk_output" diff --git a/scripts/flatpak/chat.simplex.simplex.metainfo.xml b/scripts/flatpak/chat.simplex.simplex.metainfo.xml index e6ecc7478d..ca55a08383 100644 --- a/scripts/flatpak/chat.simplex.simplex.metainfo.xml +++ b/scripts/flatpak/chat.simplex.simplex.metainfo.xml @@ -38,6 +38,23 @@ + + https://simplex.chat/blog/20260722-simplex-public-names.html + +

New in v7.0-v7.0.1.

+

SimpleX public names for channels and businesses (BETA).

+

Better channels:

+
    +
  • Promote subscribers to contributors.
  • +
  • Publish your channel on your website.
  • +
  • Verify security code with contributors.
  • +
  • Wider messages, easier to read.
  • +
  • Host your own chat relays.
  • +
+

Add a longer description to your profile.

+

Simplified app settings.

+
+
https://simplex.chat/blog/20260722-simplex-public-names.html diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2a39a6baf4..f1456b7143 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e3d53428a0c5776f9682264a56436ce97bc3eff8" = "1i3x4q6sc8w6hndrmmrsgc15di0bz6w29r4y5cr985rvs9c2mx7d"; + "https://github.com/simplex-chat/simplexmq.git"."0d3cf39c27aec1c51c88d7bf3d6762bc22278967" = "115m1h8pc4wdbd5y1prz7qgqnxhsin1f6m53k4szab9c1iawlf4v"; "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/scripts/simplex-chat-reproduce-builds-android.sh b/scripts/simplex-chat-reproduce-builds-android.sh index f8bb3224cc..4bd7262d17 100755 --- a/scripts/simplex-chat-reproduce-builds-android.sh +++ b/scripts/simplex-chat-reproduce-builds-android.sh @@ -118,7 +118,7 @@ check_apk() { verify_apk() { apk_name="$1" - # Release APKs are packaged by AGP (gradle :android:assembleRelease; AGP version is + # Release APKs are packaged by AGP (gradle :android:assembleFossRelease; AGP version is # gradle.plugin.version in apps/multiplatform/gradle.properties), which zero-pads ZIP # alignment. Do NOT add --pad-like-apksigner (standalone apksigner >= 35.0.0-rc1 uses # the 0xd935 extra-field padding) unless AGP is bumped to a packager that uses it — diff --git a/scripts/simplex-chat-reproduce-builds.sh b/scripts/simplex-chat-reproduce-builds.sh index d512735bf1..d0f121c6ea 100755 --- a/scripts/simplex-chat-reproduce-builds.sh +++ b/scripts/simplex-chat-reproduce-builds.sh @@ -36,7 +36,8 @@ mkdir -p "${init_dir}/${TAG}-${repo_name}/from-source" "${init_dir}/${TAG}-${rep git -C "${tempdir}" clone "${repo}.git" &&\ cd "${tempdir}/${repo_name}" &&\ - git checkout "${TAG}" + git checkout "${TAG}" &&\ + git submodule update --init --recursive oses="22.04@sha256:5c8b2c0a6c745bc177669abfaa716b4bc57d58e2ea3882fb5da67f4d59e3dda5 24.04@sha256:98ff7968124952e719a8a69bb3cccdd217f5fe758108ac4f21ad22e1df44d237" diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 2e8416547f..97a7297efb 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: 7.1.0.0 +version: 7.1.0.3 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -153,6 +153,8 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link else exposed-modules: Simplex.Chat.Archive @@ -323,6 +325,8 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection + Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations + Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link other-modules: Paths_simplex_chat hs-source-dirs: @@ -456,7 +460,7 @@ executable simplex-broadcast-bot Broadcast.Bot Broadcast.Options Paths_simplex_chat - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: async ==2.2.* , base >=4.7 && <5 @@ -486,7 +490,7 @@ executable simplex-chat apps/simplex-chat default-extensions: StrictData - ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded + ghc-options: -O2 -Weverything -Wno-missing-exported-signatures -Wno-missing-import-lists -Wno-missed-specialisations -Wno-all-missed-specialisations -Wno-unsafe -Wno-safe -Wno-missing-local-signatures -Wno-missing-kind-signatures -Wno-missing-deriving-strategies -Wno-monomorphism-restriction -Wno-prepositive-qualified-module -Wno-unused-packages -Wno-implicit-prelude -Wno-missing-safe-haskell-mode -Wno-missing-export-lists -Wno-partial-fields -Wcompat -Werror=incomplete-record-updates -Werror=incomplete-patterns -Werror=missing-methods -Werror=incomplete-uni-patterns -Werror=tabs -Wredundant-constraints -Wincomplete-record-updates -Wunused-type-patterns -Werror=name-shadowing -threaded -rtsopts build-depends: aeson ==2.2.* , base >=4.7 && <5 diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 69e271e927..cd44b69df6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -137,6 +137,7 @@ defaultChatConfig = relayRequestExpiry = (10, nominalDay), deviceNameForRemote = "", remoteCompression = True, + updateGroupLinksFromApp = False, chatHooks = defaultChatHooks } diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index e92bdc5655..7076ff1593 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -174,6 +174,7 @@ data ChatConfig = ChatConfig highlyAvailable :: Bool, deviceNameForRemote :: Text, remoteCompression :: Bool, + updateGroupLinksFromApp :: Bool, chatHooks :: ChatHooks } @@ -339,6 +340,8 @@ data ChatCommand | SetUserGroupReceipts UserMsgReceiptSettings | APISetUserAutoAcceptMemberContacts {userId :: UserId, onOff :: Bool} | SetUserAutoAcceptMemberContacts Bool + | APISetUserAutoAcceptGroupInvitations {userId :: UserId, onOff :: Bool} + | SetUserAutoAcceptGroupInvitations Bool | APIHideUser UserId UserPwd | APIUnhideUser UserId UserPwd | APIMuteUser UserId @@ -696,6 +699,11 @@ planResolveModeP = "never" -> pure PRMNever _ -> fail "bad PlanResolveMode" +data CommandSource + = CSLocal -- entered on this device + | CSRemoteHost RemoteHostId -- forwarded to a paired remote host + | CSRemoteCtrl -- received from a paired remote controller + allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal allowRemoteCommand = \case StartChat {} -> False diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs index 79a9026ec5..dd6be947c5 100644 --- a/src/Simplex/Chat/Core.hs +++ b/src/Simplex/Chat/Core.hs @@ -97,7 +97,7 @@ runSimplexChat ChatConfig {testView} ChatOpts {coreOptions = CoreChatOpts {chatR waitEither_ a1 a2 sendChatCmdStr :: ChatController -> String -> IO (Either ChatError ChatResponse) -sendChatCmdStr cc s = runReaderT (execChatCommand Nothing (encodeUtf8 $ T.pack s) 0) cc +sendChatCmdStr cc s = runReaderT (execChatCommand CSLocal (encodeUtf8 $ T.pack s) 0) cc sendChatCmd :: ChatController -> ChatCommand -> IO (Either ChatError ChatResponse) sendChatCmd cc cmd = runReaderT (execChatCommand' cmd 0) cc diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7b9c3debdb..f737afde77 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -378,19 +378,24 @@ useServers as opDomains uss = xftp' = useServerCfgs SPXFTP as opDomains $ concatMap (servers' SPXFTP) uss in (smp', xftp') -execChatCommand :: Maybe RemoteHostId -> ByteString -> Int -> CM' (Either ChatError ChatResponse) -execChatCommand rh s retryNum = +execChatCommand :: CommandSource -> ByteString -> Int -> CM' (Either ChatError ChatResponse) +execChatCommand src s retryNum = case parseChatCommand s of Left e -> pure $ chatCmdError e - Right cmd -> case rh of - Just rhId + Right cmd -> case src of + CSRemoteHost rhId | allowRemoteCommand cmd -> execRemoteCommand rhId cmd s retryNum | otherwise -> pure $ Left $ ChatErrorRemoteHost (RHId rhId) $ RHELocalCommand - _ -> do - cc@ChatController {config = ChatConfig {chatHooks}} <- ask - case preCmdHook chatHooks of - Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) - Nothing -> execChatCommand' cmd retryNum + CSRemoteCtrl + | allowRemoteCommand cmd -> execLocal cmd + | otherwise -> pure $ Left $ ChatErrorRemoteCtrl $ RCEProtocolError $ RPEInvalidBody "prohibited command" + CSLocal -> execLocal cmd + where + execLocal cmd = do + cc@ChatController {config = ChatConfig {chatHooks}} <- ask + case preCmdHook chatHooks of + Just hook -> liftIO (hook cc cmd) >>= either pure (`execChatCommand'` retryNum) + Nothing -> execChatCommand' cmd retryNum execChatCommand' :: ChatCommand -> Int -> CM' (Either ChatError ChatResponse) execChatCommand' cmd retryNum = handleCommandError $ do @@ -501,6 +506,12 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> updateUserAutoAcceptMemberContacts db user' onOff ok user SetUserAutoAcceptMemberContacts onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptMemberContacts userId onOff + APISetUserAutoAcceptGroupInvitations userId' onOff -> withUser $ \user -> do + user' <- privateGetUser userId' + validateUserPassword user user' Nothing + withFastStore' $ \db -> updateUserAutoAcceptGroupInvitations db user' onOff + ok user + SetUserAutoAcceptGroupInvitations onOff -> withUser $ \User {userId} -> processChatCommand cxt nm $ APISetUserAutoAcceptGroupInvitations userId onOff APIHideUser userId' (UserPwd viewPwd) -> withUser $ \user -> do user' <- privateGetUser userId' case viewPwdHash user' of @@ -700,7 +711,7 @@ processChatCommand cxt nm = \case getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTDirect ctId Nothing) fwdItemId) - Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> + Just (CIFFGroup _ _ (Just gId) (Just fwdItemId) _ _ _) -> -- TODO [knocking] getAChatItem doesn't differentiate how to read based on scope - it should, instead of using group filter Just <$> withFastStore (\db -> getAChatItem db cxt user (ChatRef CTGroup gId Nothing) fwdItemId) _ -> pure Nothing @@ -1085,9 +1096,11 @@ processChatCommand cxt nm = \case catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items where ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposedMessageReq - ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText}) (mc, file) = do + ciComposeMsgReq gInfo (CChatItem md ci@ChatItem {mentions, formattedText, meta = CIMeta {itemSharedMsgId}}) (mc, file) = do let itemId = chatItemId' ci - ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId)) + fwdMemberId = memberId' <$> chatItemMember gInfo ci + fwdGroupType = itemSharedMsgId *> sourceGroupType gInfo + ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId) fwdMemberId itemSharedMsgId fwdGroupType) -- updates text to reflect current mentioned member names (mc', _, mentions') = updatedMentionNames mc formattedText mentions -- only includes mentions when forwarding to the same group @@ -1097,6 +1110,8 @@ processChatCommand cxt nm = \case where forwardName :: GroupInfo -> ContactName forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName + sourceGroupType :: GroupInfo -> Maybe GroupType + sourceGroupType GroupInfo {groupProfile = GroupProfile {publicGroup}} = (\PublicGroupProfile {groupType} -> groupType) <$> publicGroup CTLocal -> do (_, items) <- getCommandLocalChatItems user fromChatId itemIds catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items @@ -1701,7 +1716,7 @@ processChatCommand cxt nm = \case Just RelayAddressLinkData {relayProfile} -> do let failWithProfile step e = pure $ CRChatRelayTestResult user (Just relayProfile) (Just $ RelayTestFailure step e) - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> failWithProfile RTSConnect (ChatError $ CERelayTestError "invalid connection request") Just _ -> do let chatV = initialChatVersion @@ -3382,7 +3397,7 @@ processChatCommand cxt nm = \case _ -> throwChatError $ CEException "connection already started (past prepared status)" where joinNewConn subMode = do - -- possible improvement: use agent connRequestPQSupport to determine pqSupport here; + -- possible improvement: use agent connRequestAgentVersion to determine pqSupport here; -- for joinPreparedConn below - same + encodeConnInfoPQ; -- same for auto-accept on xGrpDirectInv acId <- withAgent $ \a -> prepareConnectionToJoin a (aUserId user) True cReq PQSupportOff @@ -3612,7 +3627,7 @@ processChatCommand cxt nm = \case ConfirmRemoteCtrl rcId -> withUser_ $ do (rc, ctrlAppInfo) <- confirmRemoteCtrl rcId pure CRRemoteCtrlConnecting {remoteCtrl_ = Just rc, ctrlAppInfo, appVersion = currentAppVersion} - VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand Nothing) sessId + VerifyRemoteCtrlSession sessId -> withUser_ $ verifyRemoteCtrlSession (execChatCommand CSRemoteCtrl) sessId StopRemoteCtrl -> withUser_ $ stopRemoteCtrl >> ok_ ListRemoteCtrls -> withUser_ $ CRRemoteCtrlList <$> listRemoteCtrls DeleteRemoteCtrl rc -> withUser_ $ deleteRemoteCtrl rc >> ok_ @@ -3770,10 +3785,10 @@ processChatCommand cxt nm = \case connectViaInvitation user@User {userId} incognito (CCLink cReq@(CRInvitationUri crData e2e) sLnk_) contactId_ = withInvitationLock "connect" (strEncode cReq) $ do subMode <- chatReadVar subscriptionMode - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOn cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq -- TODO PQ the error above should be CEIncompatibleConnReqVersion, also the same API should be called in Plan - Just (_, pqSup') -> do + Just _ -> do let chatV = initialChatVersion withFastStore' (\db -> getConnectionEntityByConnReq db cxt user cReqs) >>= \case Nothing -> joinNewConn chatV @@ -3784,6 +3799,8 @@ processChatCommand cxt nm = \case joinPreparedConn conn (fromLocalProfile <$> localIncognitoProfile) Just ent -> throwCmdError $ "connection is not RcvDirectMsgConnection: " <> show (connEntityInfo ent) where + -- all supported versions support PQ encryption + pqSup' = PQSupportOn joinNewConn chatV = do -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -3930,10 +3947,7 @@ processChatCommand cxt nm = \case _ -> pure () prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat) prepareContact user cReq pqSup = do - -- 0) toggle disabled - PQSupportOff - -- 1) toggle enabled, address supports PQ (connRequestPQSupport returns Just True) - PQSupportOn, enable support with compression - -- 2) toggle enabled, address doesn't support PQ - PQSupportOn but without compression, with version range indicating support - lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -4263,7 +4277,7 @@ processChatCommand cxt nm = \case addRelay :: UserChatRelay -> CM (UserChatRelay, Either ChatError GroupRelay) addRelay relay@UserChatRelay {address} = fmap (relay,) . tryAllErrors $ do (_, _, cReq) <- getShortLinkConnReq nm user address - lift (withAgent' $ \a -> connRequestPQSupport a PQSupportOff cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -4768,7 +4782,9 @@ processChatCommand cxt nm = \case forM cmsFileInvs $ \((ComposedMessage {quotedItemId, msgContent = mc}, itemForwarded, _, _), fInv_) -> do (mc', quotedItem_) <- case (quotedItemId, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just qiId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- getDirectChatItem db user contactId qiId @@ -5438,6 +5454,8 @@ chatCommandP = "/set receipts groups " *> (SetUserGroupReceipts <$> receiptSettings), "/_set accept member contacts " *> (APISetUserAutoAcceptMemberContacts <$> A.decimal <* A.space <*> onOffP), "/set accept member contacts " *> (SetUserAutoAcceptMemberContacts <$> onOffP), + "/_set accept group invitations " *> (APISetUserAutoAcceptGroupInvitations <$> A.decimal <* A.space <*> onOffP), + "/set accept group invitations " *> (SetUserAutoAcceptGroupInvitations <$> onOffP), "/_hide user " *> (APIHideUser <$> A.decimal <* A.space <*> jsonP), "/_unhide user " *> (APIUnhideUser <$> A.decimal <* A.space <*> jsonP), "/_mute user " *> (APIMuteUser <$> A.decimal), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index ca6440d155..165371c1a3 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -208,7 +208,9 @@ prepareGroupMsg :: DB.Connection -> User -> GroupInfo -> Maybe MsgScope -> ShowG prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc mentions quotedItemId_ itemForwarded fInv_ timed_ live = do (mc', quotedItem_) <- case (quotedItemId_, itemForwarded) of (Nothing, Nothing) -> pure (mcSimple mc, Nothing) - (Nothing, Just _) -> pure (mcForward mc, Nothing) + (Nothing, Just ciff) -> do + fl_ <- liftIO $ ciffForwardLink db ciff + pure (mcForward fl_ mc, Nothing) (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, mentions = quoteMentions, file} <- getGroupCIWithReactions db user g quotedItemId @@ -231,6 +233,56 @@ prepareGroupMsg db user g@GroupInfo {membership} msgScope showGroupAsSender mc m quoteData ChatItem {chatDir = CIChannelRcv, content = CIRcvMsgContent qmc} _ = pure (qmc, CIQGroupRcv Nothing, False, Nothing) quoteData _ _ = throwError SEInvalidQuote +-- re-forwarded message is attributed to the original source +ciffForwardLink :: DB.Connection -> CIForwardedFrom -> IO (Maybe ForwardLink) +ciffForwardLink db = \case + CIFFGroup {groupId = Just gId, memberId, sharedMsgId_ = Just msgId} -> + getGroupProfileById db gId >>= \case + Just GroupProfile {displayName, publicGroup = Just PublicGroupProfile {groupLink, publicGroupId}} -> + pure $ Just ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} + _ -> pure Nothing + CIFFGroupLink {chatName, groupLink, publicGroupId, memberId, sharedMsgId} -> + pure $ Just ForwardLink {displayName = chatName, groupLink, publicGroupId, memberId, msgId = sharedMsgId} + _ -> pure Nothing + +rcvForwardedFrom :: DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> IO (Maybe CIForwardedFrom) +rcvForwardedFrom db user chatDirection RcvMessage {chatMsgEvent} = case chatMsgEvent of + ACME _ (XMsgNew MsgContainer {forward = Just True, forwardLink}) -> case forwardLink of + Nothing -> pure $ Just CIFFUnknown + Just fl@ForwardLink {displayName} + | linkAllowed -> Just <$> forwardLinkCIFF db user fl + | otherwise -> pure $ Just $ CIFFGroup displayName MDRcv Nothing Nothing Nothing Nothing Nothing + _ -> pure Nothing + where + linkAllowed = case chatDirection of + CDGroupRcv gInfo _ GroupMember {memberRole} -> allowed memberRole gInfo + CDChannelRcv gInfo _ -> allowed GROwner gInfo + _ -> True + where + allowed role = groupFeatureMemberAllowed' SGFSimplexLinks role . fullGroupPreferences + +forwardLinkCIFF :: DB.Connection -> User -> ForwardLink -> IO CIForwardedFrom +forwardLinkCIFF db user ForwardLink {displayName, groupLink, publicGroupId, memberId, msgId} = + getGroupViaPublicGroupId db user publicGroupId >>= \case + Just (gId, Just storedLink) + | sameShortLinkContact groupLink storedLink -> do + ciId_ <- itemId_ gId + pure $ CIFFGroup displayName MDRcv (Just gId) ciId_ memberId (Just msgId) linkGroupType + _ -> pure $ CIFFGroupLink displayName MDRcv groupLink publicGroupId memberId msgId linkGroupType + where + linkGroupType = case groupLink of + CSLContact _ CCTChannel _ _ -> Just GTChannel + CSLContact _ CCTGroup _ _ -> Just GTGroup + _ -> Nothing + itemId_ gId = case memberId of + Nothing -> getGroupChatItemBySharedMsgId_ db user gId Nothing msgId + Just mId -> + getGroupMemberViaMemberId_ db user gId mId >>= \case + Just (gmId, category) -> + let scope = if category == GCUserMember then Nothing else Just gmId + in getGroupChatItemBySharedMsgId_ db user gId scope msgId + Nothing -> pure Nothing + updatedMentionNames :: MsgContent -> Maybe MarkdownList -> Map MemberName CIMention -> (MsgContent, Maybe MarkdownList, Map MemberName CIMention) updatedMentionNames mc ft_ mentions = case ft_ of Just ft @@ -2767,7 +2819,8 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, msgSigned, forwardedByMem else pure $ toChatInfo cd let showAsGroup = case cd of CDChannelRcv {} -> True; _ -> False hasLink_ = ciContentHasLink content ft_ - (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention hasLink_ brokerTs createdAt + itemForwarded <- rcvForwardedFrom db user cd msg + (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemForwarded itemTimed live userMention hasLink_ brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd showAsGroup ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention hasLink_ brokerTs forwardedByMember (toMsgVerified (signMessagesRequired cd) msgSigned) createdAt ci' <- case toChatInfo cd of @@ -2940,8 +2993,7 @@ connRequestPQEncryption = \case CRContactUri _ rks -> pqEnc . snd <$> rks CRInvitationUri _ e2e -> Just $ pqEnc e2e where - pqEnc (CR.E2ERatchetParamsUri vr' _ _ pq) = - PQEncryption $ maxVersion vr' >= CR.pqRatchetE2EEncryptVersion && isJust pq + pqEnc (CR.E2ERatchetParamsUri _ _ _ pq) = PQEncryption $ isJust pq createRcvFeatureItems :: User -> Contact -> Contact -> CM' () createRcvFeatureItems user ct ct' = diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9672f9b863..4810f079f1 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1201,7 +1201,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = case cReq of CRContactUri crData@ConnReqUriData {crClientData} e2e -> do let pqSup = PQSupportOff - lift (withAgent' $ \a -> connRequestPQSupport a pqSup cReq) >>= \case + lift (withAgent' (`connRequestAgentVersion` cReq)) >>= \case Nothing -> throwChatError CEInvalidConnReq Just _ -> do let chatV = initialChatVersion @@ -2620,24 +2620,35 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = (gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership}, hostId) <- withStore $ \db -> createGroupInvitation db cxt user ct inv customUserProfileId void $ createChatItem user (CDGroupSnd gInfo Nothing) False CIChatBanner Nothing Nothing (Just epochStart) let GroupMember {groupMemberId, memberId = membershipMemId} = membership - if sameGroupLinkId groupLinkId groupLinkId' - then do - subMode <- chatReadVar subscriptionMode - dm <- encodeConnInfo $ XGrpAcpt membershipMemId - connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest - withStore' $ \db -> do - setViaGroupLinkUri db groupId connId - createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode - updateGroupMemberStatusById db userId hostId GSMemAccepted - updateGroupMemberStatus db userId membership GSMemAccepted - joinAgentConnectionAsync cmdId False acId True connRequest dm subMode - toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) - else do - let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content - withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] - toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} + -- hostContact is only reported for group links, where the client replaces + -- the transient host connection view with the group and removes its chat + joinGroupAsync hostContact_ sameLink = do + subMode <- chatReadVar subscriptionMode + dm <- encodeConnInfo $ XGrpAcpt membershipMemId + connIds@(cmdId, acId) <- prepareAgentJoin user Nothing True connRequest + withStore' $ \db -> do + when sameLink $ setViaGroupLinkUri db groupId connId + createMemberConnectionAsync db user hostId connIds connChatVersion peerChatVRange subMode + updateGroupMemberStatusById db userId hostId GSMemAccepted + updateGroupMemberStatus db userId membership GSMemAccepted + joinAgentConnectionAsync cmdId False acId True connRequest dm subMode + toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} hostContact_ + createInvitationItem invStatus = do + let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = invStatus}) memRole + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] + if + | sameGroupLinkId groupLinkId groupLinkId' -> + joinGroupAsync (Just ct) True + | isTrue (autoAcceptGroupInvitations user) -> + -- a resent invitation returns the existing group, so only join while still invited + when (memberStatus membership == GSMemInvited) $ do + joinGroupAsync Nothing False + createInvitationItem CIGISAccepted + | otherwise -> do + createInvitationItem CIGISPending + toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where GroupInvitation {groupProfile = GroupProfile {publicGroup}} = inv brokerTs = metaBrokerTs msgMeta @@ -3709,7 +3720,8 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage = createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' -- in channels, link data is updated by the owner making the change in runUpdateGroupProfile; -- other owners receiving the update do not refresh the same link - unless (useRelays' g'') $ + ChatConfig {updateGroupLinksFromApp} <- asks config + unless (useRelays' g'' || updateGroupLinksFromApp) $ void $ forkIO $ void $ setGroupLinkData' NRMBackground user g'' Just _ -> updateGroupPrefs_ msgSigned g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' -- relay advertises its web capability now that the owner's version is known (bumped by saveGroupRcvMsg) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 836fb004fe..df3921f508 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -1319,13 +1319,15 @@ itemDeletedTs = \case data CIForwardedFrom = CIFFUnknown | CIFFContact {chatName :: Text, msgDir :: MsgDirection, contactId :: Maybe ContactId, chatItemId :: Maybe ChatItemId} - | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId} + | CIFFGroup {chatName :: Text, msgDir :: MsgDirection, groupId :: Maybe GroupId, chatItemId :: Maybe ChatItemId, memberId :: Maybe MemberId, sharedMsgId_ :: Maybe SharedMsgId, groupType :: Maybe GroupType} + | CIFFGroupLink {chatName :: Text, msgDir :: MsgDirection, groupLink :: ShortLinkContact, publicGroupId :: B64UrlByteString, memberId :: Maybe MemberId, sharedMsgId :: SharedMsgId, groupType :: Maybe GroupType} deriving (Show) data CIForwardedFromTag = CIFFUnknown_ | CIFFContact_ | CIFFGroup_ + | CIFFGroupLink_ instance FromField CIForwardedFromTag where fromField = fromTextField_ textDecode @@ -1336,11 +1338,13 @@ instance TextEncoding CIForwardedFromTag where "unknown" -> Just CIFFUnknown_ "contact" -> Just CIFFContact_ "group" -> Just CIFFGroup_ + "groupLink" -> Just CIFFGroupLink_ _ -> Nothing textEncode = \case CIFFUnknown_ -> "unknown" CIFFContact_ -> "contact" CIFFGroup_ -> "group" + CIFFGroupLink_ -> "groupLink" data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], diff --git a/src/Simplex/Chat/Messages/Batch.hs b/src/Simplex/Chat/Messages/Batch.hs index 9c0ed521c7..1f9ae0b9ea 100644 --- a/src/Simplex/Chat/Messages/Batch.hs +++ b/src/Simplex/Chat/Messages/Batch.hs @@ -63,7 +63,7 @@ batchMessages mode maxLen = addBatch . foldr addToBatch ([], [], [], 0, 0) addToBatch :: Either ChatError SndMessage -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) -> ([Either ChatError MsgBatch], [ByteString], [SndMessage], Int, Int) addToBatch (Left err) acc = (Left err : addBatch acc, [], [], 0, 0) -- step over original error addToBatch (Right msg@SndMessage {msgBody, signedMsg_}) acc@(batches, bodies, msgs, len, n) - | batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') + | n' <= maxBatchElementCount && batchLen mode len' n' <= maxLen = (batches, body : bodies, msg : msgs, len', n') | msgLen <= maxLen = (addBatch acc, [body], [msg], msgLen, 1) | otherwise = (errLarge msg : addBatch acc, [], [], 0, 0) where @@ -90,7 +90,7 @@ batchDeliveryTasks1 _vr maxLen = toResult . foldl' addToBatch ([], [], [], 0, 0) | msgLen + 4 > 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, task : accepted, large, len', n + 1) + | n + 1 <= maxBatchElementCount && len' + (n + 1) * 2 + 2 <= maxLen = (msgBody : msgBodies, task : accepted, large, len', n + 1) -- doesn't fit: stop adding further messages | otherwise = (msgBodies, accepted, large, len, n) where @@ -112,7 +112,7 @@ batchElements maxLen = finish . foldl' addToBatch ([], [], 0, 0, 0) where addToBatch (batches, elems, len, n, dropped) el | elLen + 4 > maxLen = (batches, elems, len, n, dropped + 1) - | len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) + | n + 1 <= maxBatchElementCount && len + elLen + (n + 1) * 2 + 2 <= maxLen = (batches, el : elems, len + elLen, n + 1, dropped) | otherwise = (closeBatch elems : batches, [el], elLen, 1, dropped) where elLen = B.length el @@ -182,7 +182,7 @@ batchProfilesWithBody maxLen body labeled = 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 + | count >= maxBatchElementCount = full | candidateLen <= maxLen = (candidateLen, count + 1, (s, e) : acceptedPairs, overflow, large) | otherwise = full where @@ -215,7 +215,7 @@ batchProfiles maxLen = 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 = + | n + 1 <= maxBatchElementCount && 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 = diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index a537e6aa79..b8efb264bb 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -353,7 +353,7 @@ chatSendCmd cc cmd = chatSendRemoteCmdRetry cc Nothing cmd 0 {-# INLINE chatSendCmd #-} chatSendRemoteCmdRetry :: ChatController -> Maybe RemoteHostId -> B.ByteString -> Int -> IO JSONByteString -chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand rh s retryNum) cc +chatSendRemoteCmdRetry cc rh s retryNum = J.encode . eitherToResult rh <$> runReaderT (execChatCommand (maybe CSLocal CSRemoteHost rh) s retryNum) cc chatRecvMsg :: ChatController -> IO JSONByteString chatRecvMsg ChatController {outputQ} = J.encode . uncurry eitherToResult <$> readChatResponse diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index fb519cc4a6..d27f1eb9c6 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DerivingStrategies #-} @@ -687,7 +688,17 @@ data MsgContainer = MsgContainer asGroup :: Maybe Bool, quote :: Maybe QuotedMsg, parent :: Maybe MsgRef, - forward :: Maybe Bool + forward :: Maybe Bool, + forwardLink :: Maybe ForwardLink + } + deriving (Eq, Show) + +data ForwardLink = ForwardLink + { displayName :: Text, + groupLink :: ShortLinkContact, + publicGroupId :: B64UrlByteString, + memberId :: Maybe MemberId, + msgId :: SharedMsgId } deriving (Eq, Show) @@ -703,7 +714,8 @@ mcSimple content = asGroup = Nothing, quote = Nothing, parent = Nothing, - forward = Nothing + forward = Nothing, + forwardLink = Nothing } mcQuote :: QuotedMsg -> MsgContent -> MsgContainer @@ -712,8 +724,8 @@ mcQuote q c = (mcSimple c) {quote = Just q} mcComment :: MsgRef -> MsgContent -> MsgContainer mcComment p c = (mcSimple c) {parent = Just p} -mcForward :: MsgContent -> MsgContainer -mcForward c = (mcSimple c) {forward = Just True} +mcForward :: Maybe ForwardLink -> MsgContent -> MsgContainer +mcForward fl c = (mcSimple c) {forward = Just True, forwardLink = fl} data MsgContent = MCText {text :: Text} @@ -895,6 +907,8 @@ instance ToJSON MsgContent where MCReport {text, reason} -> J.pairs $ "type" .= MCReport_ <> "text" .= text <> "reason" .= reason MCChat {text, chatLink, ownerSig} -> J.pairs $ "type" .= MCChat_ <> "text" .= text <> "chatLink" .= chatLink <> maybe mempty ("ownerSig" .=) ownerSig +$(JQ.deriveJSON defaultJSON ''ForwardLink) + $(JQ.deriveJSON defaultJSON ''MsgContainer) -- this limit reserves space for metadata in forwarded messages @@ -909,6 +923,10 @@ maxCompressedMsgLength = 13380 maxDecompressedMsgLength :: Int maxDecompressedMsgLength = 65536 +-- Applies to all batch formats; 255 is the maximum for the 1-byte count in the binary batch format. +maxBatchElementCount :: Int +maxBatchElementCount = 255 + -- Defensive entry-count bound for the roster blob parser (rosterBlobP) and the -- promotion cap over the promoted (member/moderator/admin) set. maxGroupRosterSize :: Int @@ -953,10 +971,17 @@ encodeChatMessage maxSize msg = do parseChatMessages :: ByteString -> [Either String AParsedMsg] parseChatMessages "" = [Left "empty string"] -parseChatMessages msg = case B.head msg of +parseChatMessages msg = checkBatchLimit $ case B.head msg of 'X' -> decodeCompressed (B.tail msg) c -> parseUncompressed c msg where + checkBatchLimit ms + | ms `lengthLE` maxBatchElementCount = ms + | otherwise = [Left "too many messages in batch"] + -- defined prefix: GHC 8.10 does not parse a bang operand in an infix definition + lengthLE :: [a] -> Int -> Bool + lengthLE [] !n = n >= 0 + lengthLE (_ : xs) !n = n > 0 && lengthLE xs (n - 1) parseUncompressed c s = case c of '[' -> case J.eitherDecodeStrict' s of Right v -> map (fmap plainMsg . parseItem) v diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 0e23cc795c..991218367a 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -553,7 +553,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do logDebug $ "Send: " <> tshow command - -- execCC checks for remote-allowed commands + -- execCC is execChatCommand CSRemoteCtrl, which checks allowRemoteCommand -- convert errors thrown in execCC into error responses to prevent aborting the protocol wrapper RRChatResponse . eitherToResult <$> execCC (encodeUtf8 command) retryNum diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 7ddcc08a58..53d5cd619e 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -199,7 +199,7 @@ createConnReqConnection db userId acId preparedEntity_ cReq cReqHash sLnk xConta -- TODO (proposed): -- - add agent version 8 for short links -- - update agentToChatVersion to convert 8 to 16 - -- - return and correctly set peer's range from link (via connRequestPQSupport) + -- - return and correctly set peer's range from link (via connRequestAgentVersion) peerChatVRange = chatInitialVRange, -- this is 1-1 connLevel = 0, viaContact = Nothing, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index c8fd232e3f..bac41a0dff 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -63,6 +63,7 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getCreateUnknownGMByMemberId, getGroupMemberIdViaMemberId, + getGroupMemberViaMemberId_, getScopeMemberIdViaMemberId, getGroupMembers, getGroupMembersByIndexes, @@ -128,6 +129,8 @@ module Simplex.Chat.Store.Groups getRelayServedGroups, getRelayPublishableGroups, getRelayInactiveGroups, + getGroupViaPublicGroupId, + getGroupProfileById, createJoiningMember, getMemberJoinRequest, createJoiningMemberConnection, @@ -1212,11 +1215,16 @@ getScopeMemberIdViaMemberId db user g@GroupInfo {membership} sender scopeMemberI | otherwise = getGroupMemberIdViaMemberId db user g scopeMemberId getGroupMemberIdViaMemberId :: DB.Connection -> User -> GroupInfo -> MemberId -> ExceptT StoreError IO GroupMemberId -getGroupMemberIdViaMemberId db User {userId} GroupInfo {groupId} memberId = - ExceptT . firstRow fromOnly (SEGroupMemberNotFoundByMemberId memberId) $ +getGroupMemberIdViaMemberId db user GroupInfo {groupId} memberId = do + m_ <- liftIO $ getGroupMemberViaMemberId_ db user groupId memberId + maybe (throwError $ SEGroupMemberNotFoundByMemberId memberId) (pure . fst) m_ + +getGroupMemberViaMemberId_ :: DB.Connection -> User -> GroupId -> MemberId -> IO (Maybe (GroupMemberId, GroupMemberCategory)) +getGroupMemberViaMemberId_ db User {userId} groupId memberId = + maybeFirstRow id $ DB.query db - "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" + "SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?" (userId, groupId, memberId) getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember] @@ -2018,6 +2026,20 @@ getRelayPublishableGroups db User {userId, userContactId} = where toRow ((gId, pgId) :. accessRow) = (gId, pgId, toPublicGroupAccess accessRow) +getGroupViaPublicGroupId :: DB.Connection -> User -> B64UrlByteString -> IO (Maybe (GroupId, Maybe ShortLinkContact)) +getGroupViaPublicGroupId db User {userId} publicGroupId = + maybeFirstRow id $ + DB.query + db + [sql| + SELECT g.group_id, gp.group_link + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.public_group_id = ? + LIMIT 1 + |] + (userId, publicGroupId) + getRelayInactiveGroups :: DB.Connection -> StoreCxt -> User -> NominalDiffTime -> IO [GroupInfo] getRelayInactiveGroups db cxt User {userId, userContactId} ttl = do currentTs <- getCurrentTime @@ -2759,26 +2781,28 @@ updateGroupPreferences db User {userId} g@GroupInfo {groupId, groupProfile = p} updateGroupProfileFromMember :: DB.Connection -> User -> GroupInfo -> Profile -> ExceptT StoreError IO GroupInfo updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} = do - p <- getGroupProfile -- to avoid any race conditions with UI + p_ <- liftIO $ getGroupProfileById db groupId -- to avoid any race conditions with UI + p <- maybe (throwError $ SEGroupNotFound groupId) pure p_ let g' = g {groupProfile = p} :: GroupInfo p' = p {displayName = n, fullName = fn, shortDescr = sd, description = descr, image = img} :: GroupProfile updateGroupProfile db user g' p' + +getGroupProfileById :: DB.Connection -> GroupId -> IO (Maybe GroupProfile) +getGroupProfileById db groupId = + maybeFirstRow toGroupProfile $ + DB.query + db + [sql| + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, + gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + |] + (Only groupId) where - getGroupProfile = - ExceptT $ - firstRow toGroupProfile (SEGroupNotFound groupId) $ - DB.query - db - [sql| - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, - gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission - FROM group_profiles gp - JOIN groups g ON gp.group_profile_id = g.group_profile_id - WHERE g.group_id = ? - |] - (Only groupId) toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) = let publicGroupAccess = toPublicGroupAccess accessRow in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 773d9c1f32..e5057f38dd 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -100,6 +100,7 @@ module Simplex.Chat.Store.Messages getDirectChatItem, getDirectCIWithReactions, getDirectChatItemBySharedMsgId, + getGroupChatItemBySharedMsgId_, getDirectChatItemsByAgentMsgId, getGroupChatItem, getGroupCIWithReactions, @@ -560,16 +561,13 @@ createNewSndChatItem db user chatDirection showGroupAsSender SndMessage {msgId, CIQGroupRcv (Just GroupMember {memberId}) -> (Just False, Just memberId) CIQGroupRcv Nothing -> (Just False, Nothing) -createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) -createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent timed live userMention hasLink itemTs createdAt = do +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, msgSigned, signedMsg_, signedByGMId_, forwardedByMember} sharedMsgId_ ciContent itemForwarded timed live userMention hasLink itemTs createdAt = do let showAsGroup = case chatDirection of CDChannelRcv {} -> True; _ -> False ciId <- createNewChatItem_ db user chatDirection showAsGroup (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live userMention hasLink itemTs forwardedByMember (toMsgVerified (signMessagesRequired chatDirection) msgSigned) signedMsg_ signedByGMId_ createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg - pure (ciId, quotedItem, itemForwarded) + pure (ciId, quotedItem) where - itemForwarded = case chatMsgEvent of - ACME _ (XMsgNew MsgContainer {forward}) | forward == Just True -> Just CIFFUnknown - _ -> Nothing quotedMsg = cmToQuotedMsg chatMsgEvent quoteRow :: NewQuoteRow quoteRow = case quotedMsg of @@ -603,8 +601,9 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from - fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id, + fwd_from_group_type, fwd_from_group_link, fwd_from_public_group_id, fwd_from_member_id, fwd_from_shared_msg_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((userId, msgId_) :. idsRow :. groupScopeRow :. itemRow :. quoteRow' :. forwardedFromRow) ciId <- insertedRowId db @@ -648,16 +647,20 @@ createNewChatItem_ db User {userId} chatDirection showGroupAsSender msgId_ share SMDSnd -> isJust mcTag_ SMDRcv -> False mcTag_ = msgContentTag <$> ciMsgContent ciContent - forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) + forwardedFromRow :: ChatItemForwardedFromRow forwardedFromRow = case itemForwarded of Nothing -> - (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFUnknown -> - (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) + (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) :. noLinkRow Just CIFFContact {chatName, msgDir, contactId, chatItemId} -> - (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) - Just CIFFGroup {chatName, msgDir, groupId, chatItemId} -> - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) + (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, chatItemId) :. noLinkRow + Just CIFFGroup {chatName, msgDir, groupId, chatItemId, memberId, sharedMsgId_, groupType} -> + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, chatItemId) :. (groupType, Nothing, Nothing, memberId, sharedMsgId_) + Just CIFFGroupLink {chatName, msgDir, groupLink, publicGroupId, memberId, sharedMsgId = fwdSharedMsgId, groupType} -> + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) :. (groupType, Just groupLink, Just publicGroupId, memberId, Just fwdSharedMsgId) + noLinkRow :: ChatItemForwardedLinkRow + noLinkRow = (Nothing, Nothing, Nothing, Nothing, Nothing) ciTimedRow :: Maybe CITimed -> (Maybe Int, Maybe UTCTime) ciTimedRow (Just CITimed {ttl, deleteAt}) = (Just ttl, deleteAt) @@ -2277,7 +2280,9 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe BoolInt, BoolInt, BoolInt, Maybe MsgVerified) -type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) +type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) :. ChatItemForwardedLinkRow + +type ChatItemForwardedLinkRow = (Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString, Maybe MemberId, Maybe SharedMsgId) type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe BoolInt, Maybe SharedMsgId) @@ -2337,11 +2342,14 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} toCIForwardedFrom :: ChatItemForwardedFromRow -> Maybe CIForwardedFrom -toCIForwardedFrom (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) = - case (fwdFromTag, fwdFromChatName, fwdFromMsgDir, fwdFromContactId, fwdFromGroupId, fwdFromChatItemId) of +toCIForwardedFrom (fwdFromRow :. (groupType_, groupLink_, publicGroupId_, memberId_, sharedMsgId_)) = + case fwdFromRow of (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) -> Just CIFFUnknown (Just CIFFContact_, Just chatName, Just msgDir, contactId, Nothing, ciId) -> Just $ CIFFContact chatName msgDir contactId ciId - (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId + (Just CIFFGroup_, Just chatName, Just msgDir, Nothing, groupId, ciId) -> Just $ CIFFGroup chatName msgDir groupId ciId memberId_ sharedMsgId_ groupType_ + (Just CIFFGroupLink_, Just chatName, Just msgDir, Nothing, Nothing, Nothing) + | Just groupLink <- groupLink_, Just publicGroupId <- publicGroupId_, Just sharedMsgId <- sharedMsgId_ -> + Just $ CIFFGroupLink chatName msgDir groupLink publicGroupId memberId_ sharedMsgId groupType_ _ -> Nothing type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -2694,6 +2702,7 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3030,21 +3039,25 @@ markReceivedGroupReportsDeleted db User {userId} GroupInfo {groupId, membership} (DBCIDeleted, deletedTs, groupMemberId' membership, currentTs, userId, groupId, MCReport_, DBCINotDeleted) getGroupChatItemBySharedMsgId :: DB.Connection -> User -> GroupInfo -> Maybe GroupMemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) -getGroupChatItemBySharedMsgId db user@User {userId} g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do - itemId <- - ExceptT . firstRow fromOnly (SEChatItemSharedMsgIdNotFound sharedMsgId) $ - DB.query - db - [sql| - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - |] - (userId, groupId, groupMemberId_, sharedMsgId) +getGroupChatItemBySharedMsgId db user g@GroupInfo {groupId} groupMemberId_ sharedMsgId = do + itemId_ <- liftIO $ getGroupChatItemBySharedMsgId_ db user groupId groupMemberId_ sharedMsgId + itemId <- maybe (throwError $ SEChatItemSharedMsgIdNotFound sharedMsgId) pure itemId_ getGroupCIWithReactions db user g itemId +getGroupChatItemBySharedMsgId_ :: DB.Connection -> User -> GroupId -> Maybe GroupMemberId -> SharedMsgId -> IO (Maybe ChatItemId) +getGroupChatItemBySharedMsgId_ db User {userId} groupId groupMemberId_ sharedMsgId = + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + |] + (userId, groupId, groupMemberId_, sharedMsgId) + getGroupMemberCIBySharedMsgId :: DB.Connection -> User -> GroupInfo -> MemberId -> SharedMsgId -> ExceptT StoreError IO (CChatItem 'CTGroup) getGroupMemberCIBySharedMsgId db user@User {userId} g@GroupInfo {groupId} memberId sharedMsgId = do itemId <- @@ -3083,6 +3096,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -3195,6 +3209,7 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 19c07edbf8..a8b4958de4 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -46,6 +46,8 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260715_profile_description import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection +import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -91,7 +93,9 @@ schemaMigrations = ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), - ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection) + ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs new file mode 100644 index 0000000000..fa1e621619 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260813_auto_accept_group_invitations.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260813_auto_accept_group_invitations :: Text +m20260813_auto_accept_group_invitations = + [r| +ALTER TABLE users ADD COLUMN auto_accept_group_invitations SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20260813_auto_accept_group_invitations :: Text +down_m20260813_auto_accept_group_invitations = + [r| +ALTER TABLE users DROP COLUMN auto_accept_group_invitations; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..d8cf34448d --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260822_forward_link.hs @@ -0,0 +1,27 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260822_forward_link :: Text +m20260822_forward_link = + [r| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BYTEA; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BYTEA; +|] + +down_m20260822_forward_link :: Text +down_m20260822_forward_link = + [r| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_id; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index ae695d3c37..ab6384cb29 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -349,7 +349,12 @@ CREATE TABLE test_chat_schema.chat_items ( item_msg_body bytea, item_chat_binding text, item_signatures bytea, - item_signed_by_group_member_id bigint + item_signed_by_group_member_id bigint, + fwd_from_group_type text, + fwd_from_group_link bytea, + fwd_from_public_group_id bytea, + fwd_from_member_id bytea, + fwd_from_shared_msg_id bytea ); @@ -1510,7 +1515,8 @@ CREATE TABLE test_chat_schema.users ( active_order bigint DEFAULT 0 NOT NULL, auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, - client_service smallint DEFAULT 0 NOT NULL + client_service smallint DEFAULT 0 NOT NULL, + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ce6a8c4c9f..965cbcbc78 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -42,6 +42,7 @@ module Simplex.Chat.Store.Profiles updateUserContactReceipts, updateUserGroupReceipts, updateUserAutoAcceptMemberContacts, + updateUserAutoAcceptGroupInvitations, updateUserProfile, setUserBadge, setUserProfileContactLink, @@ -135,19 +136,22 @@ import Database.SQLite.Simple.QQ (sql) createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, description, image, peerType, preferences = userPreferences} activeUser currentTs = checkConstraint SEDuplicateName . liftIO $ do - when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0" let showNtfs = True sendRcptsContacts = True sendRcptsSmallGroups = True autoAcceptMemberContacts = False + autoAcceptGroupInvitations = False order <- getNextActiveOrder db DB.execute db - "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?)" + "INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?)" ( (auId, displayName, BI activeUser, BI userChatRelay, order) - :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI clientService, currentTs, currentTs) + :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, BI clientService, currentTs, currentTs) ) userId <- insertedRowId db + -- After the insert: the name is unique in users, so a duplicate fails + -- above, and deactivating first would commit a database with no active user. + when activeUser $ DB.execute db "UPDATE users SET active_user = 0 WHERE user_id != ?" (Only userId) DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" @@ -163,7 +167,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di (profileId, displayName, userId, BI True, currentTs, currentTs, currentTs) contactId <- insertedRowId db DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId) - pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) + pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInvitations, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing) -- TODO [mentions] getUsersInfo :: DB.Connection -> IO [UserInfo] @@ -328,6 +332,10 @@ updateUserAutoAcceptMemberContacts :: DB.Connection -> User -> Bool -> IO () updateUserAutoAcceptMemberContacts db User {userId} autoAccept = DB.execute db "UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ?" (BI autoAccept, userId) +updateUserAutoAcceptGroupInvitations :: DB.Connection -> User -> Bool -> IO () +updateUserAutoAcceptGroupInvitations db User {userId} autoAccept = + DB.execute db "UPDATE users SET auto_accept_group_invitations = ? WHERE user_id = ?" (BI autoAccept, userId) + updateUserProfile :: DB.Connection -> User -> Profile -> ExceptT StoreError IO User updateUserProfile db user p' | displayName == newName = liftIO $ do @@ -338,12 +346,14 @@ updateUserProfile db user p' | otherwise = checkConstraint SEDuplicateName . liftIO $ do currentTs <- getCurrentTime - DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) - userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs + -- Insert first: checkConstraint returns the violation as a value, so the + -- transaction commits, keeping whatever ran before the failing insert. DB.execute db "INSERT INTO display_names (local_display_name, ldn_base, user_id, created_at, updated_at) VALUES (?,?,?,?,?)" (newName, newName, userId, currentTs, currentTs) + DB.execute db "UPDATE users SET local_display_name = ?, updated_at = ? WHERE user_id = ?" (newName, currentTs, userId) + userMemberProfileUpdatedAt' <- updateUserMemberProfileUpdatedAt_ currentTs updateUserProfileFields_' db userId profileId p' currentTs updateContactLDN_ db user userContactId localDisplayName newName currentTs pure user {localDisplayName = newName, profile = (toLocalProfile profileId p' localAlias currentTs (Just False) Nothing) {localBadge}, fullPreferences, userMemberProfileUpdatedAt = userMemberProfileUpdatedAt'} diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index a4e7ab04a2..de5acc3c82 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -169,6 +169,8 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260715_profile_description import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection +import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations +import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -337,7 +339,9 @@ schemaMigrations = ("20260715_profile_description", m20260715_profile_description, Just down_m20260715_profile_description), ("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history), ("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles), - ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection) + ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), + ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), + ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs new file mode 100644 index 0000000000..f0fab81d68 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260813_auto_accept_group_invitations.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260813_auto_accept_group_invitations :: Query +m20260813_auto_accept_group_invitations = + [sql| +ALTER TABLE users ADD COLUMN auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0; +|] + +down_m20260813_auto_accept_group_invitations :: Query +down_m20260813_auto_accept_group_invitations = + [sql| +ALTER TABLE users DROP COLUMN auto_accept_group_invitations; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs new file mode 100644 index 0000000000..e10352521d --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260822_forward_link.hs @@ -0,0 +1,26 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260822_forward_link :: Query +m20260822_forward_link = + [sql| +ALTER TABLE chat_items ADD COLUMN fwd_from_group_type TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_link BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_public_group_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_member_id BLOB; +ALTER TABLE chat_items ADD COLUMN fwd_from_shared_msg_id BLOB; +|] + +down_m20260822_forward_link :: Query +down_m20260822_forward_link = + [sql| +ALTER TABLE chat_items DROP COLUMN fwd_from_group_type; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_link; +ALTER TABLE chat_items DROP COLUMN fwd_from_public_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_member_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_shared_msg_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 4438182941..509b9b35f4 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -691,6 +691,15 @@ Query: Plan: +Query: + INSERT INTO snd_message_deliveries (conn_id, snd_queue_id, internal_id) + SELECT conn_id, ?, internal_id + FROM snd_message_deliveries + WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 + +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=?) + Query: INSERT INTO snd_messages ( conn_id, internal_snd_id, internal_id, internal_hash, previous_msg_hash, msg_encrypt_key, padded_msg_len, snd_message_body_id) @@ -1221,6 +1230,10 @@ Query: SELECT count(1) FROM snd_message_bodies Plan: SCAN snd_message_bodies +Query: SELECT count(1) FROM snd_message_deliveries WHERE conn_id = ? AND snd_queue_id = ? AND failed = 0 +Plan: +SEARCH snd_message_deliveries USING COVERING INDEX idx_snd_message_deliveries_expired (conn_id=? AND snd_queue_id=? AND failed=?) + Query: SELECT count(1) FROM users Plan: SCAN users 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 6884bf7e04..5105f7b610 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -437,7 +437,7 @@ Query: AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_user_mention (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_group_scope_stats_all (user_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) Query: @@ -1035,19 +1035,6 @@ Query: Plan: SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? AND worker_scope=? AND failed=? AND task_status=?) -Query: - SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, - gp.group_type, gp.group_link, gp.public_group_id, - gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.member_admission - FROM group_profiles gp - JOIN groups g ON gp.group_profile_id = g.group_profile_id - WHERE g.group_id = ? - -Plan: -SEARCH g USING INTEGER PRIMARY KEY (rowid=?) -SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) - Query: SELECT group_id FROM groups @@ -1367,6 +1354,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol @@ -1384,6 +1372,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -1440,6 +1429,7 @@ Query: i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.via_proxy, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.fwd_from_tag, i.fwd_from_chat_name, i.fwd_from_msg_dir, i.fwd_from_contact_id, i.fwd_from_group_id, i.fwd_from_chat_item_id, + i.fwd_from_group_type, i.fwd_from_group_link, i.fwd_from_public_group_id, i.fwd_from_member_id, i.fwd_from_shared_msg_id, i.timed_ttl, i.timed_delete_at, i.item_live, i.user_mention, i.has_link, i.msg_signed, -- CIFile f.file_id, f.file_name, f.file_size, f.file_path, f.file_crypto_key, f.file_crypto_nonce, f.ci_file_status, f.protocol, @@ -1528,16 +1518,6 @@ Plan: SEARCH c USING INDEX idx_connections_contact_id (contact_id=?) SEARCH ct USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT chat_item_id - FROM chat_items - WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? - ORDER BY chat_item_id DESC - LIMIT 1 - -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) - Query: SELECT chat_item_id FROM chat_items @@ -3596,6 +3576,16 @@ Query: Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_direct_shared_msg_id (user_id=? AND contact_id=? AND shared_msg_id=?) +Query: + SELECT chat_item_id + FROM chat_items + WHERE user_id = ? AND group_id = ? AND group_member_id IS NOT DISTINCT FROM ? AND shared_msg_id = ? + ORDER BY chat_item_id DESC + LIMIT 1 + +Plan: +SEARCH chat_items USING COVERING INDEX idx_chat_items_group_shared_msg_id (user_id=? AND group_id=? AND group_member_id=? AND shared_msg_id=?) + Query: SELECT chat_item_id FROM chat_items @@ -3844,6 +3834,17 @@ Plan: SEARCH g USING INDEX sqlite_autoindex_groups_2 (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT g.group_id, gp.group_link + FROM groups g + JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id + WHERE g.user_id = ? AND gp.public_group_id = ? + LIMIT 1 + +Plan: +SEARCH g USING COVERING INDEX sqlite_autoindex_groups_2 (user_id=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT g.group_id, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof @@ -3858,6 +3859,19 @@ SEARCH mu USING INDEX idx_group_members_contact_id (contact_id=?) SEARCH g USING INTEGER PRIMARY KEY (rowid=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) +Query: + SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, + gp.group_type, gp.group_link, gp.public_group_id, + gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, + gp.preferences, gp.member_admission + FROM group_profiles gp + JOIN groups g ON gp.group_profile_id = g.group_profile_id + WHERE g.group_id = ? + +Plan: +SEARCH g USING INTEGER PRIMARY KEY (rowid=?) +SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) + Query: SELECT group_member_id FROM group_members @@ -4791,8 +4805,9 @@ Query: -- quote quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id, -- forwarded from - fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + fwd_from_tag, fwd_from_chat_name, fwd_from_msg_dir, fwd_from_contact_id, fwd_from_group_id, fwd_from_chat_item_id, + fwd_from_group_type, fwd_from_group_link, fwd_from_public_group_id, fwd_from_member_id, fwd_from_shared_msg_id + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -6007,7 +6022,7 @@ Query: JOIN files f ON f.chat_item_id = i.chat_item_id WHERE i.user_id = ? Plan: -SEARCH i USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) +SEARCH i USING COVERING INDEX idx_chat_items_user_id_item_status (user_id=?) SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?) Query: @@ -6185,7 +6200,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6198,7 +6213,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6212,7 +6227,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6226,7 +6241,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6241,7 +6256,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6255,7 +6270,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6269,7 +6284,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6283,7 +6298,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6297,7 +6312,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6310,7 +6325,7 @@ SEARCH ucp USING INTEGER PRIMARY KEY (rowid=?) Query: SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id @@ -6941,7 +6956,7 @@ SEARCH protocol_servers USING COVERING INDEX idx_smp_servers_user_id (user_id=?) SEARCH settings USING COVERING INDEX idx_settings_user_id (user_id=?) SEARCH commands USING COVERING INDEX idx_commands_user_id (user_id=?) SEARCH calls USING COVERING INDEX idx_calls_user_id (user_id=?) -SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_item_viewed (user_id=?) +SEARCH chat_items USING COVERING INDEX idx_chat_items_user_id_item_status (user_id=?) SEARCH contact_requests USING COVERING INDEX sqlite_autoindex_contact_requests_2 (user_id=?) SEARCH user_contact_links USING COVERING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) SEARCH connections USING COVERING INDEX idx_connections_to_subscribe (user_id=?) @@ -7069,7 +7084,7 @@ Plan: Query: INSERT INTO user_contact_links (user_id, group_id, group_link_id, local_display_name, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, group_link_member_role, auto_accept, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?) +Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) @@ -7355,7 +7370,7 @@ Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id Plan: SEARCH group_members USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -Query: SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ? +Query: SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ? Plan: SEARCH group_members USING INDEX sqlite_autoindex_group_members_1 (group_id=? AND member_id=?) @@ -7931,10 +7946,18 @@ Query: UPDATE users SET active_user = 0 Plan: SCAN users +Query: UPDATE users SET active_user = 0 WHERE user_id != ? +Plan: +SCAN users + Query: UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE users SET auto_accept_group_invitations = ? WHERE user_id = ? +Plan: +SEARCH users USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE users SET auto_accept_member_contacts = ? WHERE user_id = ? Plan: SEARCH users 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 cfbcf70599..a488d7da21 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -53,7 +53,8 @@ CREATE TABLE users( active_order INTEGER NOT NULL DEFAULT 0, auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, is_user_chat_relay INTEGER NOT NULL DEFAULT 0, - client_service INTEGER NOT NULL DEFAULT 0, -- 1 for active user + client_service INTEGER NOT NULL DEFAULT 0, + auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -513,7 +514,12 @@ CREATE TABLE chat_items( item_msg_body BLOB, item_chat_binding TEXT, item_signatures BLOB, - item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL + item_signed_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, + fwd_from_group_type TEXT, + fwd_from_group_link BLOB, + fwd_from_public_group_id BLOB, + fwd_from_member_id BLOB, + fwd_from_shared_msg_id BLOB ) STRICT; CREATE TABLE sqlite_sequence(name,seq); CREATE TABLE chat_item_messages( diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 6516b3cc66..ceaf905df0 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -557,20 +557,21 @@ userQuery :: Query userQuery = [sql| SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences, - u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, + u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.auto_accept_group_invitations, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes, ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified FROM users u JOIN contacts uct ON uct.contact_id = u.contact_id JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id |] -toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User -toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = - User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} +toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User +toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, BI autoAcceptGroupInv, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) = + User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, autoAcceptGroupInvitations, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes} where profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""} fullPreferences = fullPreferences' userPreferences viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_ + autoAcceptGroupInvitations = BoolDef autoAcceptGroupInv toPendingContactConnection :: (Int64, ConnId, ConnStatus, Maybe ByteString, Maybe Int64, Maybe GroupLinkId, Maybe Int64, Maybe ConnReqInvitation, Maybe ShortLinkInvitation, LocalAlias, UTCTime, UTCTime) -> PendingContactConnection toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUserContactLink, groupLinkId, customUserProfileId, connReqInv, shortLinkInv, localAlias, createdAt, updatedAt) = diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index e0ee10aff9..746b1e137f 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -62,7 +62,7 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do cmd = parseChatCommand bs rh' = if either (const False) allowRemoteCommand cmd then rh else Nothing unless (isMessage cmd) $ echo s - r <- execChatCommand rh' bs 0 `runReaderT` cc + r <- execChatCommand (maybe CSLocal CSRemoteHost rh') bs 0 `runReaderT` cc case r of Right r' -> processResp cmd rh r' Left _ -> when (isMessage cmd) $ echo s diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 03f644e641..63a3d5cc70 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -167,7 +167,7 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} Cha _ -> pure () logResponse path s = withFile path AppendMode $ \h -> mapM_ (hPutStrLn h . unStyle) s getRemoteUser rhId = - runReaderT (execChatCommand (Just rhId) "/user" 0) cc >>= \case + runReaderT (execChatCommand (CSRemoteHost rhId) "/user" 0) cc >>= \case Right CRActiveUser {user} -> updateRemoteUser ct user rhId cr -> logError $ "Unexpected reply while getting remote user: " <> tshow cr removeRemoteUser rhId = atomically $ TM.delete rhId (currentRemoteUsers ct) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 7bccd04b71..c0e44277a4 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -143,6 +143,7 @@ data User = User sendRcptsContacts :: Bool, sendRcptsSmallGroups :: Bool, autoAcceptMemberContacts :: Bool, + autoAcceptGroupInvitations :: BoolDef, userMemberProfileUpdatedAt :: Maybe UTCTime, userChatRelay :: BoolDef, clientService :: BoolDef, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 72631dc3f8..6731b62fd6 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -790,7 +790,7 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwa prohibited = styled (colored Red) ("[unexpected chat item created, please report to developers]" :: String) viewChatItemInfo :: AChatItem -> ChatItemInfo -> TimeZone -> [StyledString] -viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = +viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt, itemForwarded}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = ["sent at: " <> ts itemTs] <> receivedAt <> toBeDeletedAt @@ -822,7 +822,10 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime (SMDRcv, GroupChat gInfo _scopeInfo) -> Just $ "#" <> viewGroupName gInfo _ -> Nothing fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) - _ -> [] + _ -> case itemForwarded of + Just (CIFFGroup g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + Just (CIFFGroupLink g _ _ _ _ _ _) -> ["forwarded from: #" <> (plain . viewName) g] + _ -> [] localTs :: TimeZone -> UTCTime -> String localTs tz ts = do @@ -1010,8 +1013,9 @@ forwardedFrom = \case CIFFUnknown -> ["-> forwarded"] CIFFContact c MDSnd _ _ -> ["<- you @" <> (plain . viewName) c] CIFFContact c MDRcv _ _ -> ["<- @" <> (plain . viewName) c] - CIFFGroup g MDSnd _ _ -> ["<- you #" <> (plain . viewName) g] - CIFFGroup g MDRcv _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroup g MDSnd _ _ _ _ _ -> ["<- you #" <> (plain . viewName) g] + CIFFGroup g MDRcv _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] + CIFFGroupLink g _ _ _ _ _ _ -> ["<- #" <> (plain . viewName) g] sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember sentByMember GroupInfo {membership} = \case @@ -1917,6 +1921,8 @@ viewSndQueuesInfo = plain . T.intercalate ", " . map showQueueInfo showSwitchStatus = \case SSSendingQKEY -> "switch started" SSSendingQTEST -> "switch secured" + SSSecuringQueue -> "switch confirmed" + SSSendingQEND -> "switch secured" viewContactSwitch :: Contact -> SwitchProgress -> [StyledString] viewContactSwitch _ (SwitchProgress _ SPConfirmed _) = [] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index 819ae98d96..a102222807 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -12,7 +12,7 @@ import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent (forkIO, killThread, threadDelay) import Control.Exception (finally) -import Control.Monad (forM_, when) +import Control.Monad (forM_, when, void) import qualified Data.Aeson as J import qualified Data.Text as T import Directory.Captcha @@ -42,6 +42,7 @@ directoryServiceTests = do it "admin should delete group registration" testDeleteGroupAdmin it "should change initial member role" testSetRole it "should join found group via link" testJoinGroup + it "should find registered group by link" testSearchByLink it "should support group names with spaces" testGroupNameWithSpaces it "should return more groups in search, all and recent groups" testSearchGroups it "should page from the sort key, not group ID" testSearchGroupsPaging @@ -53,6 +54,7 @@ directoryServiceTests = do it "should de-list if owner is removed from the group" testDelistedOwnerRemoved it "should NOT de-list if another member leaves the group" testNotDelistedMemberLeaves it "should NOT de-list if another member is removed from the group" testNotDelistedMemberRemoved + it "should NOT de-list if the owner rejoins via the group link and leaves the second membership" testNotDelistedOwnerRejoinsViaLink it "should de-list if service is removed from the group" testDelistedServiceRemoved it "should de-list if group is deleted" testDelistedGroupDeleted it "should de-list/re-list when service/owner roles change" testDelistedRoleChanges @@ -63,7 +65,7 @@ directoryServiceTests = do it "the registration owner" testRegOwnerChangedProfile it "another owner" testAnotherOwnerChangedProfile it "another owner not connected to directory" testNotConnectedOwnerChangedProfile - describe "should require profile update if group link is removed by " $ do + describe "should NOT require re-approval if group link is added or removed by" $ do it "the registration owner" testRegOwnerRemovedLink it "another owner" testAnotherOwnerRemovedLink it "another owner not connected to directory" testNotConnectedOwnerRemovedLink @@ -71,7 +73,7 @@ directoryServiceTests = do it "should ask for confirmation if a duplicate group is submitted" testDuplicateAskConfirmation it "should prohibit registration if a duplicate group is listed" testDuplicateProhibitRegistration it "should prohibit confirmation if a duplicate group is listed" testDuplicateProhibitConfirmation - it "should prohibit when profile is updated and not send for approval" testDuplicateProhibitWhenUpdated + it "should allow to rename and approve a duplicate registration" testDuplicateProhibitWhenUpdated it "should prohibit approval if a duplicate group is listed" testDuplicateProhibitApproval describe "list and promote groups" $ do it "should list and promote user's groups" $ testListUserGroups True @@ -172,34 +174,18 @@ testDirectoryService ps = bob <## "invitation to join the group #PSA sent to 'SimpleX Directory'" bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr PSA 'SimpleX Directory' admin" - -- putStrLn "*** discover service joins group and creates the link for profile" + -- putStrLn "*** discover service joins group and sends the registration for approval" bob <## "#PSA: you changed the role of 'SimpleX Directory' to admin" bob <# "'SimpleX Directory'> Joining the group PSA…" bob <## "#PSA: 'SimpleX Directory' joined the group" - bob <# "'SimpleX Directory'> Joined the group PSA, creating the link…" - bob <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." - bob <## "" - bob <## "Please add it to the group welcome message." - bob <## "For example, add:" - welcomeWithLink <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine bob + bob <# "'SimpleX Directory'> Joined the group PSA. Registration is pending approval — it may take up to 48 hours." bob <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." bob <## "Captcha verification is enabled. Use /'filter 1' to change it." - -- putStrLn "*** update profile without link" + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" Nothing 1 1 + -- putStrLn "*** update profile before approval - new approval code" updateGroupProfile bob "Welcome!" - bob <# "'SimpleX Directory'> The profile updated for ID 1 (PSA), but the group link is not added to the welcome message." - (superUser Thank you! The group link for ID 1 (PSA) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - approvalRequested superUser welcomeWithLink (1 :: Int) - -- putStrLn "*** update profile so that it still has link" - let welcomeWithLink' = "Welcome! " <> welcomeWithLink - updateGroupProfile bob welcomeWithLink' - bob <# "'SimpleX Directory'> The group ID 1 (PSA) is updated!" - bob <## "It is hidden from the directory until approved." - superUser <# "'SimpleX Directory'> The group ID 1 (PSA) is updated." - approvalRequested superUser welcomeWithLink' (2 :: Int) + groupUpdatedHidden superUser bob "PSA" "" + notifySuperUser_ superUser bob "PSA" "Privacy, Security & Anonymity" (Just "Welcome!") 1 2 -- putStrLn "*** try approving with the old registration code" bob #> "@'SimpleX Directory' /approve 1:PSA 1" bob <# "'SimpleX Directory'> > /approve 1:PSA 1" @@ -207,44 +193,36 @@ testDirectoryService ps = superUser #> "@'SimpleX Directory' /approve 1:PSA 1" superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" superUser <## " Incorrect approval code" - -- putStrLn "*** update profile so that it has no link" - updateGroupProfile bob "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (PSA) is removed from the welcome message." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (PSA), de-listed." - superUser #> "@'SimpleX Directory' /approve 1:PSA 2" - superUser <# "'SimpleX Directory'> > /approve 1:PSA 2" - superUser <## " Error: the group ID 1 (PSA) is not pending approval." - -- putStrLn "*** update profile so that it has link again" - updateGroupProfile bob welcomeWithLink' - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (PSA) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - approvalRequested superUser welcomeWithLink' (1 :: Int) superUser #> "@'SimpleX Directory' /pending" superUser <# "'SimpleX Directory'> > /pending" superUser <## " 1 registered group(s)" superUser <# "'SimpleX Directory'> 1. PSA (Privacy, Security & Anonymity)" superUser <## "Welcome message:" - superUser <##. "Welcome! Link to join the group PSA: " + superUser <## "Welcome!" superUser <## "Owner: bob" superUser <## "2 members" superUser <## "Status: pending admin approval" superUser <## "/'role 1', /'filter 1'" - superUser #> "@'SimpleX Directory' /approve 1:PSA 1" - superUser <# "'SimpleX Directory'> > /approve 1:PSA 1" - superUser <## " Group approved!" - bob <# "'SimpleX Directory'> The group ID 1 (PSA) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + welcomeWithLink <- approveRegistration_ superUser bob "PSA" 1 1 2 + -- putStrLn "*** add the link to the welcome message - the group remains listed" + let welcomeWithLink' = "Welcome! " <> welcomeWithLink + updateGroupProfile bob welcomeWithLink' + groupUpdatedListed superUser bob "PSA" "" search bob "privacy" welcomeWithLink' search bob "security" welcomeWithLink' cath `connectVia` dsLink search cath "privacy" welcomeWithLink' + -- putStrLn "*** remove the link from the welcome message - the group remains listed" + updateGroupProfile bob "Welcome!" + groupUpdatedListed superUser bob "PSA" "" + bob #> "@'SimpleX Directory' privacy" + bob <# "'SimpleX Directory'> > privacy" + bob <## " Found 1 group(s)." + bob <# "'SimpleX Directory'> PSA (Privacy, Security & Anonymity)" + bob <## "Welcome message:" + bob <## "Welcome!" + bob <##. "Link to join the group PSA: " + bob <## "2 members" bob #> "@'SimpleX Directory' /exec /contacts" bob <# "'SimpleX Directory'> > /exec /contacts" bob <## " You are not allowed to use this command" @@ -266,15 +244,6 @@ testDirectoryService ps = u ##> ("/set welcome #PSA " <> welcome) u <## "welcome message changed to:" u <## welcome - approvalRequested su welcome grId = do - su <# "'SimpleX Directory'> bob submitted the group ID 1:" - su <## "PSA (Privacy, Security & Anonymity)" - su <## "Welcome message:" - su <## welcome - su <## "2 members" - su <## "" - su <## "To approve send:" - su <# ("'SimpleX Directory'> /approve 1:PSA " <> show grId) testSuspendResume :: HasCallStack => TestParams -> IO () testSuspendResume ps = @@ -300,23 +269,18 @@ testSuspendResume ps = superUser <## " The link to join the group ID 1 (privacy):" superUser <##. "https://localhost/g#" superUser <## "New member role: member" - -- get and change the link to the equivalent - should not ask to re-approve + -- add the link to the welcome message - the group remains listed bob #> "@'SimpleX Directory' /link 1" bob <# "'SimpleX Directory'> > /link 1" bob <## " The link to join the group ID 1 (privacy):" gLink <- getTermLine bob gLink `shouldStartWith` "https://localhost/g#" bob <## "New member role: member" - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - bob <## ("Link to join the group privacy: " <> gLink) - bob ##> ("/set welcome #privacy Link to join the group privacy: " <> gLink <> "?same_link=true") - bob <## "welcome message changed to:" - bob <## ("Link to join the group privacy: " <> gLink <> "?same_link=true") - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" - bob <## "The group is listed in directory." - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated - only link or whitespace changes." - superUser <## "The group remained listed in directory." + setWelcomeMessage bob [] ("Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" "" + -- change the link to the equivalent - should not ask to re-approve + setWelcomeMessage bob [] ("Link to join the group privacy: " <> gLink <> "?same_link=true") + groupUpdatedListed superUser bob "privacy" "" #if !defined(dbPostgres) -- upgrade link -- make it upgradeable first @@ -420,7 +384,6 @@ testSetRole ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://localhost/g#" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -444,9 +407,8 @@ testJoinGroup ps = cath <# "'SimpleX Directory'> > privacy" cath <## " Found 1 group(s)." cath <# "'SimpleX Directory'> privacy (Privacy)" - cath <## "Welcome message:" - welcomeMsg <- getTermLine cath - let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeMsg + linkLine <- getTermLine cath + let groupLink = dropStrPrefix "Link to join the group privacy: " linkLine cath <## "2 members" cath ##> ("/c " <> groupLink) cath <## "connection request sent!" @@ -462,7 +424,6 @@ testJoinGroup ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -477,7 +438,6 @@ testJoinGroup ps = do dan <## "#privacy: joining the group..." dan <## "#privacy: you joined the group" - dan <# ("#privacy bob> " <> welcomeMsg) dan <### [ "#privacy: member 'SimpleX Directory' is connected", "#privacy: member cath (Catherine) is connected" @@ -487,6 +447,47 @@ testJoinGroup ps = cath <## "#privacy: new member dan is connected" ] +testSearchByLink :: HasCallStack => TestParams -> IO () +testSearchByLink ps = + withDirectoryService ps $ \superUser (_, dsLink) -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 + welcomeWithLink <- approveRegistration superUser bob "privacy" 1 + let link = dropStrPrefix "Link to join the group privacy: " welcomeWithLink + -- user finds the listed group by link + bob #> ("@'SimpleX Directory' " <> link) + bob <# ("'SimpleX Directory'> > " <> link) + bob <## " Found group:" + bob <# "'SimpleX Directory'> privacy (Privacy)" + bob <##. "Link to join the group privacy: " + bob <## "2 members" + -- admin receives the group with status + superUser #> ("@'SimpleX Directory' " <> link) + superUser <# ("'SimpleX Directory'> > " <> link) + superUser <## " 1 registered group(s)" + memberGroupListing superUser bob 1 "privacy" "Privacy" 2 "active" + -- content change hides the group from user search, admin still finds it by link + setWelcomeMessage bob [] "Welcome!" + groupUpdatedHidden superUser bob "privacy" "" + notifySuperUser_ superUser bob "privacy" "Privacy" (Just "Welcome!") 1 1 + bob #> ("@'SimpleX Directory' " <> link) + bob <# ("'SimpleX Directory'> > " <> link) + bob <## " No groups found." + bob <## "To register a group or a channel, please use \"Share via chat\" feature." + superUser #> ("@'SimpleX Directory' " <> link) + superUser <# ("'SimpleX Directory'> > " <> link) + superUser <## " 1 registered group(s)" + superUser <# "'SimpleX Directory'> 1. privacy (Privacy)" + superUser <## "Welcome message:" + superUser <## "Welcome!" + superUser <## "Owner: bob" + superUser <## "2 members" + superUser <## "Status: pending admin approval" + superUser <## "/'role 1', /'filter 1'" + testGroupNameWithSpaces :: HasCallStack => TestParams -> IO () testGroupNameWithSpaces ps = withDirectoryService ps $ \superUser (_, dsLink) -> @@ -587,7 +588,6 @@ testSearchGroups ps = receivedGroup :: TestCC -> Int -> Int -> IO () receivedGroup u ix count = do u <#. ("'SimpleX Directory'> " <> groups !! ix) - u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") @@ -649,7 +649,6 @@ testSearchGroupsPaging ps = receivedGroup :: TestCC -> Int -> Int -> IO () receivedGroup u ix count = do u <#. ("'SimpleX Directory'> " <> groups !! ix) - u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") @@ -782,6 +781,56 @@ testNotDelistedMemberRemoved ps = cath #> "@'SimpleX Directory_1' privacy" groupFoundN_ "_1" Nothing 2 cath "privacy" +-- Reproduces the de-listing bug where a non-owner member associated with the +-- registration owner's contact (via the probe-and-merge mechanism) de-lists the +-- group when it leaves. The owner joins the directory-managed link a second time +-- (a single client owning both connection ends completes the merge with no +-- modified client), then leaves that second membership while remaining the owner. +testNotDelistedOwnerRejoinsViaLink :: HasCallStack => TestParams -> IO () +testNotDelistedOwnerRejoinsViaLink ps = + withDirectoryService ps $ \superUser (_, dsLink) -> + withNewTestChat ps "bob" bobProfile $ \bob -> do + bob `connectVia` dsLink + submitGroup bob "privacy" "Privacy" + groupAccepted bob "privacy" 1 + welcomeWithLink <- completeRegistration superUser bob "privacy" "Privacy" 1 + let groupLink = dropStrPrefix "Link to join the group privacy: " welcomeWithLink + -- turn off the captcha filter so the owner's re-join is not screened + bob #> "@'SimpleX Directory' /filter 1 off" + bob <# "'SimpleX Directory'> > /filter 1 off" + bob <## " Spam filter settings for group privacy set to:" + bob <## "- reject long/inappropriate names: disabled" + bob <## "- pass captcha to join: disabled" + bob <## "" + bob <## "/'filter 1 name' - enable name filter" + bob <## "/'filter 1 captcha' - enable captcha challenge" + bob <## "/'filter 1 name captcha' - enable both" + -- the registration owner connects to the directory-managed link again, + -- creating a second membership that the probe-and-merge mechanism + -- associates with the owner's own contact on the directory service + bob ##> ("/c " <> groupLink) + bob <## "connection request sent!" + bob <## "#privacy_1: joining the group..." + bob <## "#privacy_1: you joined the group" + bob + <### [ "#privacy: 'SimpleX Directory' added bob_1 (Bob) to the group (connecting...)", + "contact and member are merged: 'SimpleX Directory', #privacy_1 'SimpleX Directory_1'", + "use @'SimpleX Directory' to send messages", + "#privacy_1: member bob_2 (Bob) is connected", + "#privacy: new member bob_1 is connected" + ] + -- allow the directory service to complete the contact/member merge that + -- associates the second membership (bob_1) with bob's contact + threadDelay 3000000 + -- owner leaves the second membership, which is not the owner member + bob ##> "/l privacy_1" + bob <## "#privacy_1: you left the group" + bob <## "use /d #privacy_1 to delete the group" + bob <## "#privacy: bob_1 left the group" + -- the group must remain listed: the leaving member is not the owner member + (superUser TestParams -> IO () testDelistedServiceRemoved ps = withDirectoryService ps $ \superUser (_, dsLink) -> @@ -898,19 +947,22 @@ testNotSentApprovalBadRoles ps = bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" - updateProfileWithLink bob "privacy" welcomeWithLink 1 + bob ##> "/gp privacy privacy Privacy!" + bob <## "description changed to: Privacy!" + groupUpdatedHidden superUser bob "privacy" "" bob <# "'SimpleX Directory'> You must grant directory service admin role to register the group" bob ##> "/mr privacy 'SimpleX Directory' admin" bob <## "#privacy: you changed the role of 'SimpleX Directory' to admin" bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + notifySuperUser_ superUser bob "privacy" "Privacy!" Nothing 1 2 groupNotFound cath "privacy" - approveRegistration superUser bob "privacy" 1 + void $ approveRegistration_ superUser bob "privacy" 1 1 2 groupFound cath "privacy" testNotApprovedBadRoles :: HasCallStack => TestParams -> IO () @@ -921,9 +973,8 @@ testNotApprovedBadRoles ps = bob `connectVia` dsLink cath `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 - updateProfileWithLink bob "privacy" welcomeWithLink 1 - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 bob ##> "/mr privacy 'SimpleX Directory' member" bob <## "#privacy: you changed the role of 'SimpleX Directory' to member" let approve = "/approve 1:privacy 1" @@ -936,8 +987,8 @@ testNotApprovedBadRoles ps = bob <# "'SimpleX Directory'> SimpleX Directory role in the group ID 1 (privacy) is changed to admin." bob <## "" bob <## "The group is submitted for approval." - notifySuperUser superUser bob "privacy" "Privacy" welcomeWithLink 1 - approveRegistration superUser bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" testRegOwnerChangedProfile :: HasCallStack => TestParams -> IO () @@ -1013,34 +1064,21 @@ testRegOwnerRemovedLink ps = bob `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - bob ##> "/set welcome #privacy Welcome!" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - cath <## "bob updated group #privacy:" - cath <## "welcome message changed to:" - cath <## "Welcome!" - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." + -- setting the welcome message requires re-approval + setWelcomeMessage bob [cath] "Welcome!" + groupUpdatedHidden superUser bob "privacy" "" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- adding the link keeps the group listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage bob [cath] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" "" + -- removing the link keeps the group listed + setWelcomeMessage bob [cath] "Welcome!" + groupUpdatedListed superUser bob "privacy" "" cath `connectVia` dsLink cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath <## "use @'SimpleX Directory' to send messages" - groupNotFound cath "privacy" - let withChangedLink = T.unpack $ T.replace "contact#/?v=2-7&" "contact#/?v=3-7&" $ T.pack welcomeWithLink - bob ##> ("/set welcome #privacy " <> withChangedLink) - bob <## "welcome message changed to:" - bob <## withChangedLink - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - cath <## "bob updated group #privacy:" - cath <## "welcome message changed to:" - cath <## withChangedLink - reapproveGroup 3 superUser bob - groupFoundN 3 cath "privacy" + groupFoundWelcome 3 cath "privacy" "Welcome!" testAnotherOwnerRemovedLink :: HasCallStack => TestParams -> IO () testAnotherOwnerRemovedLink ps = @@ -1053,30 +1091,18 @@ testAnotherOwnerRemovedLink ps = cath `connectVia` dsLink cath <## "contact and member are merged: 'SimpleX Directory_1', #privacy 'SimpleX Directory'" cath <## "use @'SimpleX Directory' to send messages" - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - cath ##> "/set welcome #privacy Welcome!" - cath <## "welcome message changed to:" - cath <## "Welcome!" - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." - groupNotFound cath "privacy" - cath ##> ("/set welcome #privacy " <> welcomeWithLink) - cath <## "welcome message changed to:" - cath <## welcomeWithLink - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## welcomeWithLink - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - reapproveGroup 3 superUser bob - groupFoundN 3 cath "privacy" + -- setting the welcome message requires re-approval + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedHidden superUser bob "privacy" " by cath" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- another owner adds the link - the group remains listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" " by cath" + -- another owner removes the link - the group remains listed + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedListed superUser bob "privacy" " by cath" + groupFoundWelcome 3 cath "privacy" "Welcome!" testNotConnectedOwnerRemovedLink :: HasCallStack => TestParams -> IO () testNotConnectedOwnerRemovedLink ps = @@ -1088,39 +1114,19 @@ testNotConnectedOwnerRemovedLink ps = dan `connectVia` dsLink registerGroup superUser bob "privacy" "Privacy" addCathAsOwner bob cath - bob ##> "/show welcome #privacy" - bob <## "Welcome message:" - welcomeWithLink <- getTermLine bob - cath ##> "/set welcome #privacy Welcome!" - cath <## "welcome message changed to:" - cath <## "Welcome!" - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## "Welcome!" - bob <# "'SimpleX Directory'> The group link for ID 1 (privacy) is removed from the welcome message by cath." - bob <## "" - bob <## "The group is hidden from the directory until the group link is added and the group is re-approved." - superUser <# "'SimpleX Directory'> The group link is removed from ID 1 (privacy), de-listed." + -- setting the welcome message requires re-approval + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedHidden superUser bob "privacy" " by cath" groupNotFound dan "privacy" - cath ##> ("/set welcome #privacy " <> welcomeWithLink) - cath <## "welcome message changed to:" - cath <## welcomeWithLink - bob <## "cath updated group #privacy:" - bob <## "welcome message changed to:" - bob <## welcomeWithLink - -- bob <# "'SimpleX Directory'> The group link is added by another group member, your registration will not be processed." - -- bob <## "" - -- bob <## "Please update the group profile yourself." - -- bob ##> ("/set welcome #privacy " <> welcomeWithLink <> " - welcome!") - -- bob <## "welcome message changed to:" - -- bob <## (welcomeWithLink <> " - welcome!") - bob <# "'SimpleX Directory'> Thank you! The group link for ID 1 (privacy) is added to the welcome message by cath." - bob <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - -- cath <## "bob updated group #privacy:" - -- cath <## "welcome message changed to:" - -- cath <## (welcomeWithLink <> " - welcome!") - reapproveGroup 3 superUser bob - groupFoundN 3 dan "privacy" + reapproveGroup_ 3 superUser bob (Just "Welcome!") + -- the not connected owner adds the link - the group remains listed + gLink <- getGroupLinkFromBot bob + setWelcomeMessage cath [bob] ("Welcome! Link to join the group privacy: " <> gLink) + groupUpdatedListed superUser bob "privacy" " by cath" + -- the not connected owner removes the link - the group remains listed + setWelcomeMessage cath [bob] "Welcome!" + groupUpdatedListed superUser bob "privacy" " by cath" + groupFoundWelcome 3 dan "privacy" "Welcome!" testDuplicateAskConfirmation :: HasCallStack => TestParams -> IO () testDuplicateAskConfirmation ps = @@ -1129,16 +1135,17 @@ testDuplicateAskConfirmation ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - _ <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink <- groupAccepted cath "privacy" 1 + groupAccepted cath "privacy" 1 groupNotFound bob "privacy" - completeRegistrationId superUser cath "privacy" "Privacy" welcomeWithLink 2 1 + void $ completeRegistrationId superUser cath "privacy" "Privacy" 2 1 groupFound bob "privacy" testDuplicateProhibitRegistration :: HasCallStack => TestParams -> IO () @@ -1160,14 +1167,14 @@ testDuplicateProhibitConfirmation ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ completeRegistration superUser bob "privacy" "Privacy" 1 groupFound cath "privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." @@ -1179,27 +1186,27 @@ testDuplicateProhibitWhenUpdated ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink' <- groupAccepted cath "privacy" 1 + groupAccepted cath "privacy" 1 + notifySuperUser superUser cath "privacy" "Privacy" 2 groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" - cath ##> ("/set welcome privacy " <> welcomeWithLink') - cath <## "welcome message changed to:" - cath <## welcomeWithLink' - cath <# "'SimpleX Directory'> The group privacy (Privacy) is already listed in the directory, please choose another name." + -- the duplicate registration is renamed and approved cath ##> "/gp privacy security Security" cath <## "changed to #security (Security)" - cath <# "'SimpleX Directory'> Thank you! The group link for ID 1 (security) is added to the welcome message." - cath <## "You will be notified once the group is added to the directory - it may take up to 48 hours." - notifySuperUser superUser cath "security" "Security" welcomeWithLink' 2 - approveRegistrationId superUser cath "security" 2 1 + cath <# "'SimpleX Directory'> The group ID 1 (security) is updated!" + cath <## "It is hidden from the directory until approved." + superUser <# "'SimpleX Directory'> The group ID 2 (security) is updated." + notifySuperUser_ superUser cath "security" "Security" Nothing 2 2 + void $ approveRegistration_ superUser cath "security" 2 1 2 groupFound bob "security" groupFound cath "security" @@ -1210,18 +1217,18 @@ testDuplicateProhibitApproval ps = withNewTestChat ps "cath" cathProfile $ \cath -> do bob `connectVia` dsLink submitGroup bob "privacy" "Privacy" - welcomeWithLink <- groupAccepted bob "privacy" 1 + groupAccepted bob "privacy" 1 + notifySuperUser superUser bob "privacy" "Privacy" 1 cath `connectVia` dsLink submitGroup cath "privacy" "Privacy" cath <# "'SimpleX Directory'> The group privacy (Privacy) is already submitted to the directory." cath <## "To confirm the registration, please send:" cath <# "'SimpleX Directory'> /confirm 1:privacy" cath #> "@'SimpleX Directory' /confirm 1:privacy" - welcomeWithLink' <- groupAccepted cath "privacy" 1 - updateProfileWithLink cath "privacy" welcomeWithLink' 1 - notifySuperUser superUser cath "privacy" "Privacy" welcomeWithLink' 2 + groupAccepted cath "privacy" 1 + notifySuperUser superUser cath "privacy" "Privacy" 2 groupNotFound cath "privacy" - completeRegistration superUser bob "privacy" "Privacy" welcomeWithLink 1 + void $ approveRegistration superUser bob "privacy" 1 groupFound cath "privacy" -- fails at approval, as already listed let approve = "/approve 2:privacy 1" @@ -1267,15 +1274,11 @@ testListUserGroups promote ps = checkListings ["privacy", "security"] ["privacy"] bob ##> "/gp privacy privacy" bob <## "description removed" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is updated!" - bob <## "It is hidden from the directory until approved." cath <## "bob updated group #privacy:" cath <## "description removed" - superUser <# "'SimpleX Directory'> The group ID 1 (privacy) is updated." + groupUpdatedHidden superUser bob "privacy" "" superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <## "privacy" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: https://localhost/g#" superUser <## "3 members" superUser <## "" superUser <## "To approve send:" @@ -1284,13 +1287,7 @@ testListUserGroups promote ps = superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved (promoted)!" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + void $ groupApprovedNotification bob "privacy" 1 checkListings ["privacy", "security"] ["privacy"] checkListings :: HasCallStack => [T.Text] -> [T.Text] -> IO () @@ -1340,7 +1337,6 @@ testAlwaysCaptcha ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1395,7 +1391,6 @@ testCaptchaByDefault ps = cath <# ("#privacy (support) 'SimpleX Directory'!> > cath " <> captcha) cath <## " Correct, you joined the group privacy" cath <## "#privacy: you joined the group" - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1422,7 +1417,6 @@ testCapthaScreening ps = cath <## " Incorrect text, please try again." captcha <- dropStrPrefix "#privacy (support) 'SimpleX Directory'> " . dropTime <$> getTermLine cath sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1446,7 +1440,6 @@ testCapthaScreening ps = -- message from cath that left pastMember <- dropStrPrefix "#privacy: 'SimpleX Directory' forwarded a message from an unknown member, creating unknown member record " <$> getTermLine cath cath <# ("#privacy " <> pastMember <> "> hello [>>]") - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath_1 (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath_1 is connected" @@ -1524,7 +1517,6 @@ testVoiceCaptchaScreening ps@TestParams {tmpPath} = do cath <## " Audio captcha is already enabled." -- send correct captcha sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1634,7 +1626,6 @@ testVoiceCaptchaVoiceDisabled ps@TestParams {tmpPath} = do cath <#. "#privacy (support) 'SimpleX Directory'> sends file " cath <##. "use /fr 1" sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1691,7 +1682,6 @@ testVoiceCaptchaOldClient ps@TestParams {tmpPath} = do cath <## " Voice captcha is not available - please update SimpleX Chat to v6.5+ or use text captcha." -- text captcha still works sendCaptcha cath captcha - cath <#. "#privacy 'SimpleX Directory'> Link to join the group privacy: https://" cath <## "#privacy: member bob (Bob) is connected" bob <## "#privacy: 'SimpleX Directory' added cath (Catherine) to the group (connecting...)" bob <## "#privacy: new member cath is connected" @@ -1791,8 +1781,6 @@ memberGroupListing su owner = groupListing_ su (Just owner) groupListing_ :: HasCallStack => TestCC -> Maybe TestCC -> Int -> String -> String -> Int -> String -> IO () groupListing_ su owner_ gId n fn count status = do su <# ("'SimpleX Directory'> " <> show gId <> ". " <> n <> " (" <> fn <> ")") - su <## "Welcome message:" - su <##. ("Link to join the group " <> n <> ": ") forM_ owner_ $ \owner -> do ownerName <- userName owner su <## ("Owner: " <> ownerName) @@ -1801,11 +1789,15 @@ groupListing_ su owner_ gId n fn count status = do su <## ("/'role " <> show gId <> "', /'filter " <> show gId <> "'") reapproveGroup :: HasCallStack => Int -> TestCC -> TestCC -> IO () -reapproveGroup count superUser bob = do +reapproveGroup count superUser bob = reapproveGroup_ count superUser bob Nothing + +reapproveGroup_ :: HasCallStack => Int -> TestCC -> TestCC -> Maybe String -> IO () +reapproveGroup_ count superUser bob welcome_ = do superUser <# "'SimpleX Directory'> bob submitted the group ID 1:" superUser <##. "privacy (" - superUser <## "Welcome message:" - superUser <##. "Link to join the group privacy: " + forM_ welcome_ $ \welcome -> do + superUser <## "Welcome message:" + superUser <## welcome superUser <## (show count <> " members") superUser <## "" superUser <## "To approve send:" @@ -1813,13 +1805,7 @@ reapproveGroup count superUser bob = do superUser #> "@'SimpleX Directory' /approve 1:privacy 1" superUser <# "'SimpleX Directory'> > /approve 1:privacy 1" superUser <## " Group approved!" - bob <# "'SimpleX Directory'> The group ID 1 (privacy) is approved and listed in directory - please moderate it!" - bob <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." - bob <## "" - bob <## "Supported commands:" - bob <## "/'filter 1' - to configure anti-spam filter." - bob <## "/'role 1' - to set default member role." - bob <## "/'link 1' - to view/upgrade group link." + void $ groupApprovedNotification bob "privacy" 1 addCathAsOwner :: HasCallStack => TestCC -> TestCC -> IO () addCathAsOwner bob cath = do @@ -1894,8 +1880,8 @@ registerGroup su u n fn = registerGroupId su u n fn 1 1 registerGroupId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO () registerGroupId su u n fn gId ugId = do submitGroup u n fn - welcomeWithLink <- groupAccepted u n ugId - completeRegistrationId su u n fn welcomeWithLink gId ugId + groupAccepted u n ugId + void $ completeRegistrationId su u n fn gId ugId submitGroup :: TestCC -> String -> String -> IO () submitGroup u n fn = do @@ -1905,70 +1891,91 @@ submitGroup u n fn = do u ##> ("/a " <> viewName n <> " 'SimpleX Directory' admin") u <## ("invitation to join the group #" <> viewName n <> " sent to 'SimpleX Directory'") -groupAccepted :: TestCC -> String -> Int -> IO String +groupAccepted :: TestCC -> String -> Int -> IO () groupAccepted u n ugId = do u <### [ WithTime ("'SimpleX Directory'> Joining the group " <> n <> "…"), ConsoleString ("#" <> viewName n <> ": 'SimpleX Directory' joined the group") ] - u <# ("'SimpleX Directory'> Joined the group " <> n <> ", creating the link…") - u <# "'SimpleX Directory'> Created the public link to join the group via this directory service that is always online." - u <## "" - u <## "Please add it to the group welcome message." - u <## "For example, add:" - welcomeWithLink <- dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u + u <# ("'SimpleX Directory'> Joined the group " <> n <> ". Registration is pending approval — it may take up to 48 hours.") u <# "'SimpleX Directory'> We recommend allowing direct messages, media, voice, and SimpleX links only for group moderators and admins. Use group preferences to set them." u <## ("Captcha verification is enabled. Use /'filter " <> show ugId <> "' to change it.") - pure welcomeWithLink -completeRegistration :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () -completeRegistration su u n fn welcomeWithLink gId = - completeRegistrationId su u n fn welcomeWithLink gId gId +completeRegistration :: TestCC -> TestCC -> String -> String -> Int -> IO String +completeRegistration su u n fn gId = + completeRegistrationId su u n fn gId gId -completeRegistrationId :: TestCC -> TestCC -> String -> String -> String -> Int -> Int -> IO () -completeRegistrationId su u n fn welcomeWithLink gId ugId = do - updateProfileWithLink u n welcomeWithLink ugId - notifySuperUser su u n fn welcomeWithLink gId +completeRegistrationId :: TestCC -> TestCC -> String -> String -> Int -> Int -> IO String +completeRegistrationId su u n fn gId ugId = do + notifySuperUser su u n fn gId approveRegistrationId su u n gId ugId -updateProfileWithLink :: TestCC -> String -> String -> Int -> IO () -updateProfileWithLink u n welcomeWithLink ugId = do - u ##> ("/set welcome " <> viewName n <> " " <> welcomeWithLink) - u <## "welcome message changed to:" - u <## welcomeWithLink - u <# ("'SimpleX Directory'> Thank you! The group link for ID " <> show ugId <> " (" <> n <> ") is added to the welcome message.") - u <## "You will be notified once the group is added to the directory - it may take up to 48 hours." +notifySuperUser :: TestCC -> TestCC -> String -> String -> Int -> IO () +notifySuperUser su u n fn gId = notifySuperUser_ su u n fn Nothing gId 1 -notifySuperUser :: TestCC -> TestCC -> String -> String -> String -> Int -> IO () -notifySuperUser su u n fn welcomeWithLink gId = do +notifySuperUser_ :: TestCC -> TestCC -> String -> String -> Maybe String -> Int -> Int -> IO () +notifySuperUser_ su u n fn welcome_ gId gaId = do uName <- userName u su <# ("'SimpleX Directory'> " <> uName <> " submitted the group ID " <> show gId <> ":") su <## (n <> if null fn then "" else " (" <> fn <> ")") - su <## "Welcome message:" - su <## welcomeWithLink + forM_ welcome_ $ \welcome -> do + su <## "Welcome message:" + su <## welcome su .<## "members" su <## "" su <## "To approve send:" - let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" + let approve = "/approve " <> show gId <> ":" <> viewName n <> " " <> show gaId su <# ("'SimpleX Directory'> " <> approve) -approveRegistration :: TestCC -> TestCC -> String -> Int -> IO () +approveRegistration :: TestCC -> TestCC -> String -> Int -> IO String approveRegistration su u n gId = approveRegistrationId su u n gId gId -approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO () -approveRegistrationId su u n gId ugId = do - let approve = "/approve " <> show gId <> ":" <> viewName n <> " 1" +approveRegistrationId :: TestCC -> TestCC -> String -> Int -> Int -> IO String +approveRegistrationId su u n gId ugId = approveRegistration_ su u n gId ugId 1 + +approveRegistration_ :: TestCC -> TestCC -> String -> Int -> Int -> Int -> IO String +approveRegistration_ su u n gId ugId gaId = do + let approve = "/approve " <> show gId <> ":" <> viewName n <> " " <> show gaId su #> ("@'SimpleX Directory' " <> approve) su <# ("'SimpleX Directory'> > " <> approve) su <## " Group approved!" + groupApprovedNotification u n ugId + +groupApprovedNotification :: TestCC -> String -> Int -> IO String +groupApprovedNotification u n ugId = do u <# ("'SimpleX Directory'> The group ID " <> show ugId <> " (" <> n <> ") is approved and listed in directory - please moderate it!") - u <## "Please note: if you change the group profile it will be hidden from directory until it is re-approved." + u <## "To help people join, copy the next message with the group link and add it to the end of the group welcome message. The group will remain listed. Any other change to the group profile hides it from the directory until it is re-approved." u <## "" u <## "Supported commands:" u <## ("/'filter " <> show ugId <> "' - to configure anti-spam filter.") u <## ("/'role " <> show ugId <> "' - to set default member role.") - u <## ("/'link " <> show ugId <> "' - to view/upgrade group link.") + u <## ("/'link " <> show ugId <> "' - to view group link.") + dropStrPrefix "'SimpleX Directory'> " . dropTime <$> getTermLine u + +groupUpdatedHidden :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +groupUpdatedHidden superUser u n byMember = do + u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") + u <## "It is hidden from the directory until approved." + superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> ".") + +groupUpdatedListed :: HasCallStack => TestCC -> TestCC -> String -> String -> IO () +groupUpdatedListed superUser u n byMember = do + u <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> "!") + u <## "The group is listed in directory." + superUser <# ("'SimpleX Directory'> The group ID 1 (" <> n <> ") is updated" <> byMember <> " - only link or whitespace changes.") + superUser <## "The group remained listed in directory." + +setWelcomeMessage :: HasCallStack => TestCC -> [TestCC] -> String -> IO () +setWelcomeMessage u others welcome = do + uName <- userName u + u ##> ("/set welcome #privacy " <> welcome) + u <## "welcome message changed to:" + u <## welcome + forM_ others $ \m -> do + m <## (uName <> " updated group #privacy:") + m <## "welcome message changed to:" + m <## welcome connectVia :: TestCC -> String -> IO () u `connectVia` dsLink = do @@ -1987,10 +1994,8 @@ joinGroup :: String -> TestCC -> TestCC -> IO () joinGroup gName member host = do let gn = "#" <> gName memberName <- userName member - hostName <- userName host member ##> ("/j " <> gName) member <## (gn <> ": you joined the group") - member <#. (gn <> " " <> hostName <> "> Link to join the group " <> gName <> ": ") host <## (gn <> ": " <> memberName <> " joined the group") leaveGroup :: String -> TestCC -> IO () @@ -2026,10 +2031,29 @@ groupFoundN_ suffix shownId_ count u name = do u <# ("'SimpleX Directory" <> suffix <> "'> > " <> name) u <## " Found 1 group(s)." u <#. ("'SimpleX Directory" <> suffix <> "'> " <> maybe "" (\gId -> show gId <> ". ") shownId_ <> name) - u <## "Welcome message:" u <##. "Link to join the group " u <## (show count <> " members") +groupFoundWelcome :: HasCallStack => Int -> TestCC -> String -> String -> IO () +groupFoundWelcome count u name welcome = do + u #> ("@'SimpleX Directory' " <> name) + u <# ("'SimpleX Directory'> > " <> name) + u <## " Found 1 group(s)." + u <#. ("'SimpleX Directory'> " <> name) + u <## "Welcome message:" + u <## welcome + u <##. "Link to join the group " + u <## (show count <> " members") + +getGroupLinkFromBot :: HasCallStack => TestCC -> IO String +getGroupLinkFromBot u = do + u #> "@'SimpleX Directory' /link 1" + u <# "'SimpleX Directory'> > /link 1" + u <## " The link to join the group ID 1 (privacy):" + gLink <- getTermLine u + u <## "New member role: member" + pure gLink + groupNotFound :: TestCC -> String -> IO () groupNotFound = groupNotFound_ "" @@ -2115,7 +2139,7 @@ testHelpNoAudio ps = bob <## "/list - list the groups you registered." bob <## "`/role ` - view and set default member role for your group." bob <## "`/filter ` - view and set spam filter settings for group." - bob <## "`/link ` - view and upgrade group link." + bob <## "`/link ` - view group link." bob <## "`/delete :` - remove the group you submitted from directory, with ID and name as shown by /list command." bob <## "" bob <## "To search for groups, send the search text." diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index d1d2ecb374..79ed09f381 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -50,7 +50,7 @@ import Simplex.FileTransfer.Server.Store import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange) import Simplex.Messaging.Agent (disposeAgentClient) import Simplex.Messaging.Agent.Env.SQLite -import Simplex.Messaging.Agent.Protocol (duplexHandshakeSMPAgentVersion, pqdrSMPAgentVersion, supportedSMPAgentVRange) +import Simplex.Messaging.Agent.Protocol (supportedSMPAgentVRange) import Simplex.Messaging.Agent.RetryInterval import Simplex.Messaging.Agent.Store.Entity (SDBStored (..)) import Simplex.Messaging.Agent.Store.Interface (closeDBStore) @@ -58,8 +58,6 @@ import Simplex.Messaging.Agent.Store.Shared (MigrationConfig (..), MigrationConf import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Client (ProtocolClientConfig (..)) import Simplex.Messaging.Client.Agent (defaultSMPClientAgentConfig) -import Simplex.Messaging.Crypto.Ratchet (supportedE2EEncryptVRange) -import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Protocol (ProtocolType (..)) import Simplex.Messaging.Server (runSMPServerBlocking) import Simplex.Messaging.Server.Env.STM (ServerConfig (..), ServerStoreCfg (..), StartOptions (..), StorePaths (..), defaultMessageExpiration, defaultIdleQueueInterval, defaultNtfExpiration, defaultInactiveClientExpiration) @@ -235,7 +233,7 @@ testAgentCfgVPrev = testAgentCfg { smpClientVRange = prevRange $ smpClientVRange testAgentCfg, smpAgentVRange = prevRange supportedSMPAgentVRange, - e2eEncryptVRange = prevRange supportedE2EEncryptVRange, + -- e2eEncryptVRange = prevRange supportedE2EEncryptVRange, smpCfg = (smpCfg testAgentCfg) {serverVRange = prevRange $ serverVRange $ smpCfg testAgentCfg} } @@ -243,8 +241,8 @@ testAgentCfgV1 :: AgentConfig testAgentCfgV1 = testAgentCfg { smpClientVRange = v1Range, - smpAgentVRange = mkVersionRange duplexHandshakeSMPAgentVersion pqdrSMPAgentVersion, - e2eEncryptVRange = mkVersionRange CR.kdfX3DHE2EEncryptVersion CR.pqRatchetE2EEncryptVersion, + smpAgentVRange = versionToRange (Version 6), + e2eEncryptVRange = versionToRange(Version 3), smpCfg = (smpCfg testAgentCfg) {serverVRange = versionToRange minClientSMPRelayVersion} } diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 1468ded7ac..98636a76b8 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -2927,7 +2927,7 @@ testSwitchContact = testAbortSwitchContact :: HasCallStack => TestParams -> IO () testAbortSwitchContact ps = do - withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfgVPrev "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do connectUsers alice bob alice #$> ("/switch bob", id, "switch started") @@ -2974,7 +2974,7 @@ testSwitchGroupMember = testAbortSwitchGroupMember :: HasCallStack => TestParams -> IO () testAbortSwitchGroupMember ps = do - withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatCfg ps testCfgVPrev "alice" aliceProfile $ \alice -> do withNewTestChat ps "bob" bobProfile $ \bob -> do createGroup2 "team" alice bob alice #$> ("/switch #team bob", id, "switch started") diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs index 483c2269b1..91ad46ba43 100644 --- a/tests/ChatTests/Forward.hs +++ b/tests/ChatTests/Forward.hs @@ -5,8 +5,10 @@ module ChatTests.Forward where import ChatClient import ChatTests.DBUtils +import ChatTests.Groups (memberJoinChannel, prepareChannel1Relay) import ChatTests.Utils import Control.Concurrent (threadDelay) +import Control.Concurrent.Async (concurrently_) import qualified Data.ByteString.Char8 as B import Data.List (intercalate) import qualified Data.Text as T @@ -18,6 +20,9 @@ import Test.Hspec hiding (it) chatForwardTests :: SpecWith TestParams chatForwardTests = do describe "forward messages" $ do + it "from channel: the recipient receives the channel link" testForwardChannelToContact + it "from channel: the channel is known to the recipient" testForwardChannelKnownGroup + it "from channel: the link is removed when the destination group prohibits links" testForwardChannelLinkRemoved it "from contact to contact" testForwardContactToContact it "from contact to group" testForwardContactToGroup it "from contact to notes" testForwardContactToNotes @@ -43,6 +48,113 @@ chatForwardTests = do it "from group to group" testForwardGroupToGroupMulti it "with relative paths: multiple files from contact to contact" testMultiForwardFiles +testForwardChannelToContact :: HasCallStack => TestParams -> IO () +testForwardChannelToContact 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 + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is not known to dan: the item includes the channel name and link + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + dan ##> "/item info @cath hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends the same link + connectUsers dan alice + dan `send` "@alice <- @cath hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> <- #team" + alice <## " hi" + +testForwardChannelKnownGroup :: HasCallStack => TestParams -> IO () +testForwardChannelKnownGroup 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 + connectUsers cath dan + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + dan <# "#team> hi [>>]" + threadDelay 1000000 + -- the channel is known to dan: the item references the local group + cath `send` "@dan <- #team hi" + cath <# "@dan <- #team" + cath <## " hi" + dan <# "cath> <- #team" + dan <## " hi" + -- forwarding onwards rebuilds the link from the local group + dan ##> "/c" + inv <- getInvitation dan + alice ##> ("/c " <> inv) + alice <## "confirmation sent!" + concurrently_ + (alice <## "dan_1 (Daniel): contact is connected") + (dan <## "alice_1 (Alice): contact is connected") + dan `send` "@alice_1 <- @cath hi" + dan <# "@alice_1 <- #team" + dan <## " hi" + alice <# "dan_1> <- #team" + alice <## " hi" + +testForwardChannelLinkRemoved :: HasCallStack => TestParams -> IO () +testForwardChannelLinkRemoved 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 + createGroup2 "club" cath dan + cath ##> "/set links #club off" + cath <## "updated group preferences:" + cath <## "SimpleX links: off" + dan <## "cath updated group #club:" + dan <## "updated group preferences:" + dan <## "SimpleX links: off" + alice #> "#team hi" + bob <# "#team> hi" + cath <# "#team> hi [>>]" + threadDelay 1000000 + cath `send` "#club <- #team hi" + cath <# "#club <- #team" + cath <## " hi" + -- the link is removed; the name text remains + dan <# "#club cath> <- #team" + dan <## " hi" + dan ##> "/item info #club hi" + dan <##. "sent at: " + dan <##. "received at: " + dan <## "message history:" + dan .<## ": hi" + dan <## "forwarded from: #team" + -- forwarding the received item onwards sends no link + connectUsers dan alice + dan `send` "@alice <- #club hi" + dan <# "@alice <- #team" + dan <## " hi" + alice <# "dan> -> forwarded" + alice <## " hi" + testForwardContactToContact :: HasCallStack => TestParams -> IO () testForwardContactToContact = testChat3 aliceProfile bobProfile cathProfile $ diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index c65306474c..ce62b300e4 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -52,6 +52,9 @@ chatProfileTests = do it "reject profile image that is too large" testSetProfileImageTooLarge it "set profile image from file" testSetProfileImageFromFile it "use multiword profile names" testMultiWordProfileNames + it "auto-accept group invitations" testAutoAcceptGroupInvitations + it "auto-accept group invitations on a second profile" testAutoAcceptGroupInvitationsSecondProfile + it "auto-accept group invitations on an inactive profile" testAutoAcceptGroupInvitationsInactiveProfile it "present supporter badge to contacts" testUserBadgeBroadcast it "supporter badge sent to contact connecting after attach" testUserBadgeOnConnect it "supporter badge sent to member joining via group link" testUserBadgeGroupLink @@ -539,6 +542,61 @@ testSetProfileImageFromFile ps = testChat aliceProfile test ps alice ##> ("/set profile image file " <> emptyPath) alice <##. "bad chat command: image file is empty" +testAutoAcceptGroupInvitations :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitations = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + bob ##> "/set accept group invitations on" + bob <## "ok" + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob admin" + alice <## "invitation to join the group #team sent to bob" + concurrently_ + (alice <## "#team: bob joined the group") + (bob <## "#team: you joined the group") + +testAutoAcceptGroupInvitationsSecondProfile :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitationsSecondProfile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user bob2" + showActiveUser bob "bob2" + bob ##> "/set accept group invitations on" + bob <## "ok" + connectUsers alice bob + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob2 admin" + alice <## "invitation to join the group #team sent to bob2" + concurrently_ + (alice <## "#team: bob2 joined the group") + (bob <## "#team: you joined the group") + +testAutoAcceptGroupInvitationsInactiveProfile :: HasCallStack => TestParams -> IO () +testAutoAcceptGroupInvitationsInactiveProfile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + bob ##> "/create user bob2" + showActiveUser bob "bob2" + bob ##> "/set accept group invitations on" + bob <## "ok" + connectUsers alice bob + -- switch away: bob2 now has auto-accept on but is NOT the active profile + bob ##> "/user bob" + showActiveUser bob "bob (Bob)" + alice ##> "/g team" + alice <## "group #team is created" + alice <## "to add members use /a team or /create link #team" + alice ##> "/a team bob2 admin" + alice <## "invitation to join the group #team sent to bob2" + concurrently_ + (alice <## "#team: bob2 joined the group") + (bob <## "[user: bob2] #team: you joined the group") + testMultiWordProfileNames :: HasCallStack => TestParams -> IO () testMultiWordProfileNames = testChat3 aliceProfile' bobProfile' cathProfile' $ diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index dfcf76f761..f16b3ac090 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -124,9 +124,9 @@ skip = before_ . pendingWith versionTestMatrix2 :: (HasCallStack => Bool -> Bool -> TestCC -> TestCC -> IO ()) -> SpecWith TestParams versionTestMatrix2 runTest = do it "current" $ testChat2 aliceProfile bobProfile (runTest True True) - it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest False True) - it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest False True) - it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest False True) + it "prev" $ runTestCfg2 testCfgVPrev testCfgVPrev (runTest True True) + it "prev to curr" $ runTestCfg2 testCfg testCfgVPrev (runTest True True) + it "curr to prev" $ runTestCfg2 testCfgVPrev testCfg (runTest True True) it "old (1st supported)" $ testChatCfg2 testCfgV1 aliceProfile bobProfile (runTest True False) it "old to curr" $ runTestCfg2 testCfg testCfgV1 (runTest True True) it "curr to old" $ runTestCfg2 testCfgV1 testCfg (runTest True False) diff --git a/tests/JSONFixtures.hs b/tests/JSONFixtures.hs index 37fab0e4f0..bbdf8c14b0 100644 --- a/tests/JSONFixtures.hs +++ b/tests/JSONFixtures.hs @@ -17,10 +17,10 @@ activeUserExistsTagged :: LB.ByteString activeUserExistsTagged = "{\"error\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}" activeUserSwift :: LB.ByteString -activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}}" +activeUserSwift = "{\"result\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}}" activeUserTagged :: LB.ByteString -activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false,\"clientService\":false}}}" +activeUserTagged = "{\"result\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false,\"clientService\":false}}}" chatStartedSwift :: LB.ByteString chatStartedSwift = "{\"result\":{\"_owsf\":true,\"chatStarted\":{}}}" @@ -35,7 +35,7 @@ connectionsDiffTagged :: LB.ByteString connectionsDiffTagged = "{\"result\":{\"type\":\"connectionsDiff\",\"userIds\":{\"missingIds\":[],\"extraIds\":[]},\"connIds\":{\"missingIds\":[],\"extraIds\":[]}}}" userJSON :: LB.ByteString -userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"userChatRelay\":false}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"\",\"shortDescr\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"files\":{\"allow\":\"always\"},\"calls\":{\"allow\":\"yes\"},\"sessions\":{\"allow\":\"no\"},\"commands\":[]},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true,\"autoAcceptMemberContacts\":false,\"autoAcceptGroupInvitations\":false,\"userChatRelay\":false}" parsedMarkdownSwift :: LB.ByteString parsedMarkdownSwift = "{\"formattedText\":[{\"format\":{\"_owsf\":true,\"bold\":{}},\"text\":\"hello\"}]}" diff --git a/tests/MessageBatching.hs b/tests/MessageBatching.hs index 00cbbd757b..01515fb6a3 100644 --- a/tests/MessageBatching.hs +++ b/tests/MessageBatching.hs @@ -33,6 +33,7 @@ import Simplex.Chat.Protocol GrpMsgForward (GrpMsgForward), MsgContent (MCText), VerifiedMsg (VMUnsigned), + maxBatchElementCount, maxEncodedMsgLength, mcSimple, ) @@ -45,6 +46,7 @@ batchingTests = describe "message batching tests" $ do testBatchingCorrectness testBinaryBatchingCorrectness it "image x.msg.new and x.msg.file.descr should fit into single batch" testImageFitsSingleBatch + it "splits a batch that exceeds the element count limit" testBatchElementCountLimit it "does not create a relay delivery body when every task is oversized" testRelayBatchAllLarge it "classifies a task that fits raw but not as a framed singleton as large" testRelayBatchSingletonOverflow @@ -150,6 +152,13 @@ testImageFitsSingleBatch = do runBatcherTest' BMJson maxEncodedMsgLength [msg xMsgNewStr, msg descrStr] [] [batched] +-- elements are far below maxEncodedMsgLength, so only the element count guard can split this +testBatchElementCountLimit :: IO () +testBatchElementCountLimit = + runBatcherTest' BMJson maxEncodedMsgLength (replicate (maxBatchElementCount + 1) "a") [] ["a", batched] + where + batched = "[" <> B.intercalate "," (replicate maxBatchElementCount "a") <> "]" + testRelayBatchAllLarge :: IO () testRelayBatchAllLarge = do let task1 = deliveryTask 1 "one" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 8b86554fd1..62ece5a8cf 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -11,6 +11,8 @@ import Control.Concurrent.STM (atomically) import qualified Data.Aeson as J import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B +import Data.List (isInfixOf) +import qualified Data.List.NonEmpty as L import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Library.Internal (decodeLinkUserData, encodeShortLinkData) import Simplex.Chat.Protocol @@ -18,8 +20,10 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol +import Simplex.Messaging.Compression (compress1) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet +import Simplex.Messaging.Encoding (smpEncode) import Simplex.Messaging.Protocol (EntityId (..), supportedSMPClientVRange) import Simplex.Messaging.ServiceScheme import Simplex.Messaging.Version @@ -30,6 +34,7 @@ protocolTests = do decodeChatMessageTest shortLinkDataTests serviceBodyTests + batchLimitTests serviceBodyTests :: Spec serviceBodyTests = describe "service payload compression" $ do @@ -53,6 +58,21 @@ serviceBodyTests = describe "service payload compression" $ do B.length bomb `shouldSatisfy` (< maxCompressedInfoLength) decompressServiceBody bomb `shouldBe` Left "decompressed size exceeds limit" +batchLimitTests :: Spec +batchLimitTests = describe "Chat message batch limits" $ do + it "parses a JSON batch at the element count limit" $ + length (parseChatMessages $ jsonBatch maxBatchElementCount) `shouldBe` maxBatchElementCount + it "rejects a JSON batch above the element count limit" $ + batchError (jsonBatch $ maxBatchElementCount + 1) `shouldSatisfy` isInfixOf "too many messages in batch" + it "rejects compressed blocks that together exceed the element count limit" $ + batchError (compressedBatch 2 maxBatchElementCount) `shouldSatisfy` isInfixOf "too many messages in batch" + where + jsonBatch n = "[" <> B.intercalate "," (replicate n "{}") <> "]" + compressedBatch k n = markCompressedBatch . smpEncode . L.fromList $ replicate k (compress1 $ jsonBatch n) + batchError s = case parseChatMessages s of + [Left e] -> e + rs -> "expected a single error, got " <> show (length rs) <> " results" + srv :: SMPServer srv = SMPServer "smp.simplex.im" "5223" (C.KeyHash "\215m\248\251") @@ -85,6 +105,16 @@ testE2ERatchetParams = E2ERatchetParamsUri supportedE2EEncryptVRange testDhPubKe testConnReq :: ConnectionRequestUri 'CMInvitation testConnReq = CRInvitationUri connReqData testE2ERatchetParams +testForwardLink :: ForwardLink +testForwardLink = + ForwardLink + { displayName = "team", + groupLink = CSLContact SLSSimplex CCTChannel srv (LinkKey "\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8\1\2\3\4\5\6\7\8"), + publicGroupId = B64UrlByteString "\1\2\3\4", + memberId = Just $ MemberId "\1\2\3\4", + msgId = SharedMsgId "\5\6\7\8" + } + quotedMsg :: QuotedMsg quotedMsg = QuotedMsg @@ -204,13 +234,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew ((mcQuote quotedMsg (MCText "hello to you too")) {live = Just True})) it "x.msg.new forward" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (MCText "hello")) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward Nothing (MCText "hello")) it "x.msg.new forward - timed message TTL" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"ttl\":3600}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {ttl = Just 3600}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {ttl = Just 3600}) it "x.msg.new forward - live message" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"live\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {live = Just True}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {live = Just True}) + it "x.msg.new forward with channel link" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"memberId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just testForwardLink) (MCText "hello")) + it "x.msg.new forward with channel link without author" $ + "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"forwardLink\":{\"displayName\":\"team\",\"groupLink\":\"simplex:/c#AQIDBAUGBwgBAgMEBQYHCAECAwQFBgcIAQIDBAUGBwg?h=smp.simplex.im&p=5223&c=1234-w\",\"publicGroupId\":\"AQIDBA==\",\"msgId\":\"BQYHCA==\"}}}" + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ mcForward (Just (testForwardLink {memberId = Nothing} :: ForwardLink)) (MCText "hello")) it "x.msg.new simple text with file" $ "{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XMsgNew ((mcSimple (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) @@ -234,7 +270,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do (XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam))) it "x.msg.new forward with file" $ "{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}" - ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) + ##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing}}) it "x.msg.update" $ "{\"v\":\"9\",\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") [] Nothing Nothing Nothing Nothing @@ -245,7 +281,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.msg.deleted\",\"params\":{}}" #==# XMsgDeleted it "x.file" $ - "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Just testConnReq, fileInline = Nothing, fileDescr = Nothing} it "x.file without file invitation" $ "{\"v\":\"9\",\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" @@ -254,7 +290,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" it "x.file.acpt.inv" $ - "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XFileAcptInv (SharedMsgId "\1\2\3\4") (Just testConnReq) "photo.jpg" it "x.file.acpt.inv" $ "{\"v\":\"9\",\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\"}}" @@ -281,10 +317,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.contact\",\"params\":{\"msgId\":\"AQIDBA==\",\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing Nothing (Just (SharedMsgId "\1\2\3\4", MCText {text = "hello"})) it "x.grp.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Nothing, groupSize = Nothing} it "x.grp.inv with group link id" $ - "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} it "x.grp.acpt without incognito profile" $ "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" @@ -305,16 +341,16 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} (Just MemberRestrictions {restriction = MRSBlocked}) it "x.grp.mem.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.inv w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.fwd" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq} it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $ - "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-19\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing} it "x.grp.mem.info" $ "{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" @@ -335,10 +371,10 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel it "x.grp.direct.inv" $ - "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XGrpDirectInv testConnReq (Just $ MCText "hello") Nothing it "x.grp.direct.inv without content" $ - "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D2-3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + "{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" #==# XGrpDirectInv testConnReq Nothing Nothing -- it "x.grp.msg.forward" -- $ "{\"v\":\"9\",\"event\":\"x.grp.msg.forward\",\"params\":{\"msgForward\":{\"memberId\":\"AQIDBA==\",\"msg\":\"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}\",\"msgTs\":\"1970-01-01T00:00:01.000000001Z\"}}}" diff --git a/website/.eleventyignore b/website/.eleventyignore new file mode 100644 index 0000000000..c899fd1cb3 --- /dev/null +++ b/website/.eleventyignore @@ -0,0 +1,5 @@ +src/blog/CLAUDE.md +src/blog/README.md +src/blog/.claude/ +src/blog/plans/ +src/blog/new/ diff --git a/website/langs/ar.json b/website/langs/ar.json index 264bf9561a..3f5c220f19 100644 --- a/website/langs/ar.json +++ b/website/langs/ar.json @@ -14,6 +14,7 @@ "smp-protocol": "بروتوكول SMP", "chat-protocol": "بروتوكول الدردشة", "donate": "تبرّع", + "invest": "استثمر", "terminal-cli": "طرفية CLI", "terms-and-privacy-policy": "سياسة الخصوصية", "hero-header": "إعادة تعريف الخصوصية", @@ -31,7 +32,7 @@ "simplex-explained-tab-2-p-1": "لكل اتصال، تستخدم قائمتي انتظار منفصلتين للمُراسلة لإرسال واستلام الرسائل عبر خوادم مختلفة.", "simplex-explained-tab-2-p-2": "تمرّر الخوادم الرسائل في اتجاه واحد فقط، دون الحصول على الصورة الكاملة لمُحادثات المستخدم أو اتصالاته.", "simplex-explained-tab-3-p-1": "تحتوي الخوادم على بيانات اعتماد مجهولة منفصلة لكل قائمة انتظار، ولا تعرف المستخدمين الذين ينتمون إليهم.", - "copyright-label": "مشروع مفتوح المصدر © © 2020-2025 SimpleX Chat | مشروع مفتوح المصدرSimpleX 2020-2025", + "copyright-label": "مشروع مفتوح المصدر © © 2020-2026 SimpleX Chat | مشروع مفتوح المصدرSimpleX 2020-2026", "simplex-chat-protocol": "بروتوكول دردشة SimpleX", "developers": "المطورين", "hero-subheader": "أول نظام مُراسلة
دون معرّفات مُستخدم", @@ -282,6 +283,8 @@ "index-nextweb-p2": "لا يتحكم أي كيان واحد في الشبكة – يمكن لأي شخص تشغيل الخوادم.", "index-token-h2": "يموّله مستخدموه", "index-token-p1": "للحفاظ على الاستقلالية، ستدفع القنوات والمجتمعات الكبيرة مقابل خوادمها.", + "index-token-p2-cf": "ويمكن للمستخدمين الآن الاستثمار في SimpleX Chat — التمويل الجماعي متاح!", + "index-token-cta-cf": "استثمر في SimpleX Chat", "index-token-p2": "سيغطي ذلك البنية التحتية وتطوير البرمجيات وإدارة الشبكة.", "index-roadmap-h2": "خارطة طريق SimpleX للإنترنت المجاني", "index-roadmap-now": "الآن", diff --git a/website/langs/bg.json b/website/langs/bg.json index 7661d916a3..990d23ad7e 100644 --- a/website/langs/bg.json +++ b/website/langs/bg.json @@ -21,7 +21,12 @@ "smp-protocol": "СМП Протокол", "chat-protocol": "Чат протокол", "donate": "Дарете", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект с отворен код", + "invest": "Инвестирайте", + "index-token-h2": "Финансира се от потребителите си", + "index-token-p1": "За да запазят независимостта си, големите канали и общности ще плащат за сървърите си.", + "index-token-p2-cf": "А сега потребителите могат да инвестират в SimpleX Chat — краудфандингът е активен!", + "index-token-cta-cf": "Инвестирайте в SimpleX Chat", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект с отворен код", "simplex-chat-protocol": "SimpleX Чат протокол", "terminal-cli": "Системна конзола", "terms-and-privacy-policy": "Политика за поверителност", diff --git a/website/langs/cs.json b/website/langs/cs.json index fbcfc430cd..ef06c53676 100644 --- a/website/langs/cs.json +++ b/website/langs/cs.json @@ -25,7 +25,8 @@ "smp-protocol": "SMP protokol", "chat-protocol": "Chat protokol", "donate": "Darovat", - "copyright-label": "© 2020-2025 SimpleX Chat | Projekt s otevřeným zdrojovým kódem", + "invest": "Investovat", + "copyright-label": "© 2020-2026 SimpleX Chat | Projekt s otevřeným zdrojovým kódem", "simplex-chat-protocol": "SimpleX Chat protokol", "terminal-cli": "Terminálové rozhraní příkazového řádku", "terms-and-privacy-policy": "Ochrana soukromí", @@ -283,6 +284,8 @@ "index-nextweb-p2": "Síť nekontroluje žádný subjekt – servery může provozovat kdokoli.", "index-token-h2": "Financováno uživateli", "index-token-p1": "Pro zachování nezávislosti budou velké kanály a komunity platit za své servery.", + "index-token-p2-cf": "A uživatelé nyní mohou investovat do SimpleX Chat — crowdfunding je spuštěn!", + "index-token-cta-cf": "Investovat do SimpleX Chat", "index-token-p2": "To pokryje infrastrukturu, vývoj softwaru a správu sítě.", "index-token-cta": "Zjistěte více o Community Credits", "index-roadmap-h2": "Plán SimpleX ke svobodnému internetu", diff --git a/website/langs/de.json b/website/langs/de.json index e3a3bafbfc..ac35879b3a 100644 --- a/website/langs/de.json +++ b/website/langs/de.json @@ -21,7 +21,8 @@ "smp-protocol": "SMP-Protokoll", "chat-bot-example": "Beispiel für einen Chatbot", "donate": "Spenden", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source-Projekt", + "invest": "Investieren", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source-Projekt", "chat-protocol": "Chat-Protokoll", "simplex-chat-protocol": "SimpleX Chat-Protokoll", "terminal-cli": "Terminal-Kommandozeilen-Schnittstelle", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Keine einzelne Instanz kontrolliert das Netzwerk – jeder kann Server betreiben.", "index-token-h2": "Finanziert von seinen Nutzern", "index-token-p1": "Um unabhängig zu bleiben, werden große Kanäle und Communitys für ihre Server bezahlen.", + "index-token-p2-cf": "Und die Nutzer können jetzt in SimpleX Chat investieren — das Crowdfunding ist gestartet!", + "index-token-cta-cf": "In SimpleX Chat investieren", "index-token-p2": "Dies deckt Infrastruktur, Softwareentwicklung und Netzwerkverwaltung ab.", "index-token-cta": "Erfahren Sie mehr über Community-Credits", "index-roadmap-h2": "SimpleX - Der Weg zum freien Internet", @@ -370,8 +373,10 @@ "file-proto-p-4": "Wenn die Datei in Fragmente aufgeteilt wurde, wird sie über Netzwerkrouter übertragen, die von unabhängigen Parteien betrieben werden. Kein Betreiber kann die tatsächliche Dateigröße oder den Dateinamen sehen. Selbst wenn ein Router kompromittiert wird, sieht er nur verschlüsselte Fragmente fester Größe. Die Fragmente werden von den Netzwerkroutern für etwa 48 Stunden zwischengespeichert.", "file-proto-spec": "Lesen Sie sich die XFTP‑Protokollspezifikation durch →", "send-file": "Datei senden", - "links": "Links", + "links": "Community", "links-title": "Community-Links", "links-all-languages": "Alle Sprachen", - "docs-dropdown-16": "Ein Chat-Relais hosten" + "docs-dropdown-16": "Ein Chat-Relais hosten", + "index-hero-invest": "Investieren Sie in SimpleX Chat.", + "index-hero-invest-cta": "Erfahren Sie mehr auf Wefunder." } diff --git a/website/langs/en.json b/website/langs/en.json index 482fe50042..8e4fba8470 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -23,7 +23,8 @@ "smp-protocol": "SMP protocol", "chat-protocol": "Chat protocol", "donate": "Donate", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "invest": "Invest", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "about-and-contact-us": "About & Contact us", @@ -289,6 +290,8 @@ "index-token-p1": "To stay independent, large channels and communities will pay for their servers.", "index-token-p2": "This will cover infrastructure, software development and network governance.", "index-token-cta": "Learn more about Community Credits", + "index-token-p2-cf": "And the users can now invest in SimpleX Chat — the equity crowdfunding is live!", + "index-token-cta-cf": "Get a stake in SimpleX Chat", "index-roadmap-h2": "SimpleX Roadmap to Free Internet", "index-roadmap-now": "Now", "index-roadmap-1": "2026", @@ -372,7 +375,7 @@ "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 →", - "links": "Links", + "links": "Community", "links-title": "Community Links", "links-all-languages": "All languages" } diff --git a/website/langs/es.json b/website/langs/es.json index cff716c8d0..c9e0747e7a 100644 --- a/website/langs/es.json +++ b/website/langs/es.json @@ -10,7 +10,8 @@ "simplex-explained-tab-3-p-2": "El usuario puede mejorar aún más la privacidad de sus metadatos, haciendo uso de la red Tor para acceder a los servidores, evitando así la correlación por dirección IP.", "smp-protocol": "Protocolo SMP", "donate": "Donación", - "copyright-label": "© 2020-2025 SimpleX Chat | Proyecto de Código Abierto", + "invest": "Invertir", + "copyright-label": "© 2020-2026 SimpleX Chat | Proyecto de Código Abierto", "simplex-chat-protocol": "Protocolo SimpleX Chat", "terms-and-privacy-policy": "Política de Privacidad", "hero-header": "Privacidad redefinida", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Ninguna entidad controla la red – cualquiera puede ejecutar servidores.", "index-token-h2": "Financiada por Sus Usuarios", "index-token-p1": "Para mantenerse independientes, los canales y comunidades grandes pagarán por sus servidores.", + "index-token-p2-cf": "Y los usuarios ya pueden invertir en SimpleX Chat — ¡el crowdfunding está activo!", + "index-token-cta-cf": "Invertir en SimpleX Chat", "index-token-p2": "Esto cubrirá infraestructura, desarrollo de software y gobernanza de la red.", "index-token-cta": "Descubre más sobre los Créditos Comunitarios", "index-roadmap-h2": "Ruta SimpleX hacía el Internet Libre", @@ -372,5 +375,6 @@ "send-file": "Enviar archivo", "links": "Enlaces", "links-title": "Enlaces de la comunidad", - "links-all-languages": "Todos los idiomas" + "links-all-languages": "Todos los idiomas", + "docs-dropdown-16": "Alojar un servidor de chat" } diff --git a/website/langs/fa.json b/website/langs/fa.json index 9fe7656d51..0afd6a8f83 100644 --- a/website/langs/fa.json +++ b/website/langs/fa.json @@ -21,6 +21,11 @@ "smp-protocol": "پروتکل SMP", "chat-protocol": "پروتکل چت", "donate": "حمایت مالی", + "invest": "سرمایه‌گذاری", + "index-token-h2": "با حمایت مالی کاربران", + "index-token-p1": "برای حفظ استقلال، کانال‌ها و انجمن‌های بزرگ هزینه سرورهای خود را می‌پردازند.", + "index-token-p2-cf": "و اکنون کاربران می‌توانند در SimpleX Chat سرمایه‌گذاری کنند — تأمین مالی جمعی آغاز شده است!", + "index-token-cta-cf": "در SimpleX Chat سرمایه‌گذاری کنید", "copyright-label": "© ۲۰۲۰-۲۰۲۵ SimpleX | پروژه متن‌باز", "simplex-chat-protocol": "پروتکل چت SimpleX", "terminal-cli": "رابط خط فرمان ترمینال", diff --git a/website/langs/fi.json b/website/langs/fi.json index 50f10331cf..3a138aa7c4 100644 --- a/website/langs/fi.json +++ b/website/langs/fi.json @@ -112,7 +112,12 @@ "simplex-explained-tab-1-p-1": "Voit luoda yhteyshenkilöitä ja ryhmiä sekä käydä kaksisuuntaisia keskusteluja kuten missä tahansa muussa viestisovelluksessa.", "simplex-explained-tab-3-p-1": "Palvelimilla on erilliset anonyymit tunnistetiedot kullekin jonolle, eivätkä ne tiedä, mille käyttäjille ne kuuluvat.", "donate": "Lahjoita", - "copyright-label": "© 2020-2025 SimpleX Chat | Avoin projekti", + "invest": "Sijoita", + "index-token-h2": "Käyttäjiensä rahoittama", + "index-token-p1": "Pysyäkseen riippumattomina suuret kanavat ja yhteisöt maksavat palvelimistaan.", + "index-token-p2-cf": "Ja käyttäjät voivat nyt sijoittaa SimpleX Chatiin — joukkorahoitus on käynnissä!", + "index-token-cta-cf": "Sijoita SimpleX Chatiin", + "copyright-label": "© 2020-2026 SimpleX Chat | Avoin projekti", "hero-p-1": "Muissa sovelluksissa on käyttäjätunnuksia: Signal, Matrix, Session, Briar, Jami, Cwtch, jne.
SimpleX ei käytä niitä, ei edes satunnaisia numeroita.
Tämä parantaa yksityisyyttäsi radikaalisti.", "simplex-private-1-title": "2 kerrosta päästä päähän salattua viestintää", "simplex-private-2-title": "Lisäkerros palvelimen salaukselle", diff --git a/website/langs/fr.json b/website/langs/fr.json index e71bdc2207..760ac037c3 100644 --- a/website/langs/fr.json +++ b/website/langs/fr.json @@ -21,7 +21,8 @@ "smp-protocol": "Protocole SMP", "chat-protocol": "Protocole de chat", "donate": "Faire un don", - "copyright-label": "© 2020-2025 SimpleX Chat | Projet Open-Source", + "invest": "Investir", + "copyright-label": "© 2020-2026 SimpleX Chat | Projet Open-Source", "simplex-chat-protocol": "Protocole SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politique de confidentialité", @@ -296,6 +297,8 @@ "index-messaging-p2": "Des dizaines de millions de messages envoyés en privé chaque jour.", "index-nextweb-p1": "Chaque contact et chaque groupe reste sur votre appareil, et non sur un serveur.", "index-token-p1": "Pour rester indépendants, les grands canaux et les communautés paieront leurs propres serveurs.", + "index-token-p2-cf": "Et les utilisateurs peuvent désormais investir dans SimpleX Chat — le financement participatif est lancé !", + "index-token-cta-cf": "Investir dans SimpleX Chat", "index-token-p2": "Cela couvrira l'infrastructure, le développement logiciel et la gouvernance du réseau.", "index-directory-h2": "Rejoindre les communautés SimpleX", "index-directory-p1": "Plus de 2 millions de personnes ont téléchargé les applications SimpleX.", diff --git a/website/langs/he.json b/website/langs/he.json index 9d72680c84..11dc4a0b4d 100644 --- a/website/langs/he.json +++ b/website/langs/he.json @@ -53,7 +53,12 @@ "smp-protocol": "פרוטוקול SMP", "chat-protocol": "פרוטוקול צ'אט", "donate": "תרומה", - "copyright-label": "© 2020-2025 SimpleX Chat | פרויקט קוד פתוח", + "invest": "השקעה", + "index-token-h2": "ממומן על ידי המשתמשים", + "index-token-p1": "כדי להישאר עצמאיים, ערוצים וקהילות גדולים ישלמו עבור השרתים שלהם.", + "index-token-p2-cf": "וכעת המשתמשים יכולים להשקיע ב-SimpleX Chat — מימון ההמונים פעיל!", + "index-token-cta-cf": "השקיעו ב-SimpleX Chat", + "copyright-label": "© 2020-2026 SimpleX Chat | פרויקט קוד פתוח", "hero-p-1": "לאפליקציות אחרות יש מזהי משתמש: Signal, Matrix, Session, Briar, Jami, Cwtch וכו'.
ל-SimpleX אין, אפילו לא מספרים אקראיים.
זה משפר באופן קיצוני את הפרטיות שלך.", "hero-overlay-2-title": "מדוע מזהי משתמש מזיקים לפרטיות?", "feature-6-title": "שיחות שמע ווידאו
מוצפנות מקצה לקצה", diff --git a/website/langs/hu.json b/website/langs/hu.json index 9775e2da57..4ea9d6ccb6 100644 --- a/website/langs/hu.json +++ b/website/langs/hu.json @@ -20,6 +20,7 @@ "smp-protocol": "SMP-protokoll", "chat-protocol": "Csevegési protokoll", "donate": "Adományozás", + "invest": "Befektetés", "copyright-label": "© 2020-2026 SimpleX Chat | Nyílt forráskódú projekt", "simplex-chat-protocol": "SimpleX Chat protokoll", "terminal-cli": "Terminál CLI", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Egyetlen szervezet sem irányítja a hálózatot – bárki üzemeltethet kiszolgálókat.", "index-token-h2": "A felhasználók finanszírozzák", "index-token-p1": "A függetlenség megőrzéséhez a nagy csatornák és közösségek fizetni fognak a kiszolgálóikért.", + "index-token-p2-cf": "És a felhasználók mostantól befektethetnek a SimpleX Chatbe — a közösségi finanszírozás elindult!", + "index-token-cta-cf": "Befektetés a SimpleX Chatbe", "index-token-p2": "Ez fedezi az infrastruktúrát, a szoftverfejlesztést és a hálózat irányítását.", "index-token-cta": "Tudjon meg többet a közösségi kreditekről", "index-roadmap-h2": "A SimpleX ütemterve a szabad internethez", @@ -370,8 +373,10 @@ "file-proto-p-4": "Amikor a fájl töredékekre oszlik, akkor a független felek által üzemeltetett hálózati útválasztókon keresztül kerül továbbításra. Egyetlen üzemeltető sem láthatja a fájl tényleges méretét és nevét. Még ha egy útválasztó biztonsága meg is sérül, csak a rögzített méretű titkosított töredékeket „láthatja”. A fájltöredékeket a hálózati útválasztók körülbelül 48 órán át tárolják a gyorsítótárban.", "file-proto-spec": "Olvassa el az XFTP-protokoll leírását →", "send-file": "Fájl küldése", - "links": "Hivatkozások", + "links": "Közösség", "links-title": "Közösségi hivatkozások", "links-all-languages": "Összes nyelv", - "docs-dropdown-16": "Csevegési átjátszó üzemeltetése" + "docs-dropdown-16": "Csevegési átjátszó üzemeltetése", + "index-hero-invest": "Fektessen be a SimpleX Chatbe.", + "index-hero-invest-cta": "Bővebben a Wefunderen." } diff --git a/website/langs/id.json b/website/langs/id.json index bb80effd21..2811873b46 100644 --- a/website/langs/id.json +++ b/website/langs/id.json @@ -10,6 +10,7 @@ "simplex-network": "Jaringan SimpleX", "simplex-explained": "SimpleX dijelaskan", "donate": "Donasi", + "invest": "Investasi", "hero-overlay-2-textlink": "Bagaimana cara kerja SimpleX?", "feature-5-title": "Pesan menghilang", "hero-overlay-1-title": "Bagaimana cara kerja SimpleX?", @@ -30,7 +31,7 @@ "simplex-explained-tab-2-text": "2. Bagaimana cara kerjanya", "simplex-chat-protocol": "Protokol SimpleX Chat", "hero-overlay-2-title": "Mengapa ID pengguna buruk untuk privasi?", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "simplex-explained-tab-3-text": "3. Apa yang dilihat server", "smp-protocol": "Protokol SMP", "please-use-link-in-mobile-app": "Mohon gunakan tautan di aplikasi seluler", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Tidak ada satu entitas pun yang mengendalikan jaringan – siapa saja bisa menjalankan server.", "index-token-h2": "Didanai oleh Penggunanya", "index-token-p1": "Untuk tetap independen, kanal dan komunitas besar akan membayar server mereka.", + "index-token-p2-cf": "Dan pengguna sekarang bisa berinvestasi di SimpleX Chat — crowdfunding kini aktif!", + "index-token-cta-cf": "Investasi di SimpleX Chat", "index-token-p2": "Ini akan mencakup infrastruktur, pengembangan perangkat lunak, dan tata kelola jaringan.", "index-token-cta": "Pelajari lebih lanjut tentang Kredit Komunitas", "index-roadmap-h2": "SimpleX Roadmap Menuju Internet Bebas", diff --git a/website/langs/it.json b/website/langs/it.json index 8970706295..f6befff000 100644 --- a/website/langs/it.json +++ b/website/langs/it.json @@ -10,7 +10,8 @@ "simplex-explained-tab-3-p-1": "I server hanno credenziali anonime separate per ogni coda e non sanno a quali utenti appartengano.", "chat-protocol": "Protocollo di chat", "donate": "Dona", - "copyright-label": "© 2020-2025 SimpleX Chat | Progetto Open-Source", + "invest": "Investi", + "copyright-label": "© 2020-2026 SimpleX Chat | Progetto Open-Source", "simplex-chat-protocol": "Protocollo di SimpleX Chat", "terminal-cli": "Terminale CLI", "terms-and-privacy-policy": "Informativa sulla privacy", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Non c'è una singola entità che controlla la rete: chiunque può gestire i server.", "index-token-h2": "Finanziato dai suoi utenti", "index-token-p1": "Per restare indipendenti, i grandi canali e le comunità pagheranno per i propri server.", + "index-token-p2-cf": "E ora gli utenti possono investire in SimpleX Chat — il crowdfunding è attivo!", + "index-token-cta-cf": "Investi in SimpleX Chat", "index-token-p2": "Ciò coprirà infrastruttura, sviluppo software e gestione della rete.", "index-token-cta": "Scopri di più sui Crediti Comunitari", "index-roadmap-h2": "Tabella di marcia per un internet libero", @@ -370,8 +373,10 @@ "file-proto-p-4": "Quando il file è diviso in frammenti, viene inviato tramite instradatori di rete operati da parti indipendenti. Nessun operatore può vedere la vera dimensione o il nome del file. Anche se un instradatore venisse compromesso, potrà vedere solo frammenti cifrati di dimensione fissa. I frammenti di file restano in cache dagli instradatori di rete per circa 48 ore.", "file-proto-spec": "Leggi le specifiche del protocollo XFTP →", "send-file": "Invia file", - "links": "Collegamenti", + "links": "Comunità", "links-title": "Link della comunità", "links-all-languages": "Tutte le lingue", - "docs-dropdown-16": "Ospita un relay di chat" + "docs-dropdown-16": "Ospita un relay di chat", + "index-hero-invest": "Investi in SimpleX Chat.", + "index-hero-invest-cta": "Scopri di più su Wefunder." } diff --git a/website/langs/ja.json b/website/langs/ja.json index 08807fcbb0..087a18ac6a 100644 --- a/website/langs/ja.json +++ b/website/langs/ja.json @@ -52,7 +52,8 @@ "chat-protocol": "チャットプロトコル", "chat-bot-example": "チャットボットの例", "donate": "寄付", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-Source Project", + "invest": "投資", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-Source Project", "hero-p-1": "他のアプリにはユーザー ID があります: Signal、Matrix、Session、Briar、Jami、Cwtch など。
SimpleX にはありません。乱数さえもありません
これにより、プライバシーが大幅に向上します。", "copy-the-command-below-text": "以下のコマンドをコピーしてチャットで使用します:", "simplex-private-card-9-point-1": "各メッセージ キューは、異なる送信アドレスと受信アドレスを使用してメッセージを一方向に渡します。", @@ -275,6 +276,8 @@ "index-nextweb-p2": "ネットワークを支配する単一の組織はありません – 誰でもサーバーを運用できます。", "index-token-h2": "ユーザーの資金で運営", "index-token-p1": "独立性を維持するため、大規模なチャンネルやコミュニティはサーバー費用を負担します。", + "index-token-p2-cf": "ユーザーはSimpleX Chatに投資できるようになりました — クラウドファンディングが始まりました!", + "index-token-cta-cf": "SimpleX Chatに投資する", "index-token-p2": "これにより、インフラ、ソフトウェア開発、ネットワークガバナンスの費用が賄われます。", "index-roadmap-h2": "自由なインターネットを目指す SimpleX ロードマップ", "index-roadmap-now": "現在", diff --git a/website/langs/nl.json b/website/langs/nl.json index 613e07ff5c..aebab52842 100644 --- a/website/langs/nl.json +++ b/website/langs/nl.json @@ -17,7 +17,12 @@ "chat-bot-example": "Chatbot voorbeeld", "smp-protocol": "SMP protocol", "donate": "Doneer", - "copyright-label": "© 2020-2025 SimpleX Chat | Open-sourceproject", + "invest": "Investeer", + "index-token-h2": "Gefinancierd door zijn gebruikers", + "index-token-p1": "Om onafhankelijk te blijven, zullen grote kanalen en gemeenschappen voor hun servers betalen.", + "index-token-p2-cf": "En gebruikers kunnen nu investeren in SimpleX Chat — de crowdfunding is live!", + "index-token-cta-cf": "Investeer in SimpleX Chat", + "copyright-label": "© 2020-2026 SimpleX Chat | Open-sourceproject", "simplex-chat-protocol": "SimpleX Chat protocol", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Privacybeleid", diff --git a/website/langs/pl.json b/website/langs/pl.json index 9a9fe79d16..21a3576cb8 100644 --- a/website/langs/pl.json +++ b/website/langs/pl.json @@ -15,7 +15,8 @@ "smp-protocol": "Protokół SMP", "chat-protocol": "Protokół czatu", "donate": "Darowizna", - "copyright-label": "© 2020-2025 SimpleX Chat | Projekt Open-Source", + "invest": "Inwestuj", + "copyright-label": "© 2020-2026 SimpleX Chat | Projekt Open-Source", "simplex-chat-protocol": "Protokół SimpleX Chat", "terminal-cli": "Terminal wiersza poleceń", "terms-and-privacy-policy": "Polityka prywatności", @@ -283,6 +284,8 @@ "index-nextweb-p2": "Żaden podmiot nie kontroluje sieci – każdy może uruchomić serwer.", "index-token-h2": "Finansowane Przez Użytkowników", "index-token-p1": "Aby zachować niezależność, duże kanały i społeczności będą opłacać swoje serwery.", + "index-token-p2-cf": "A użytkownicy mogą teraz inwestować w SimpleX Chat — crowdfunding wystartował!", + "index-token-cta-cf": "Zainwestuj w SimpleX Chat", "index-token-p2": "Pokryje to infrastrukturę, rozwój oprogramowania i zarządzanie siecią.", "index-token-cta": "Dowiedz się więcej o Community Credits", "index-roadmap-h2": "Plan Działania SimpleX dla Wolnego Internetu", diff --git a/website/langs/pt_BR.json b/website/langs/pt_BR.json index 32b2ac5e05..52848e5cea 100644 --- a/website/langs/pt_BR.json +++ b/website/langs/pt_BR.json @@ -25,7 +25,8 @@ "smp-protocol": "Protocolo SMP", "chat-protocol": "Protocolo de bate-papo", "donate": "Doar", - "copyright-label": "© 2020-2025 SimpleX Chat | Projeto de Código Livre", + "invest": "Investir", + "copyright-label": "© 2020-2026 SimpleX Chat | Projeto de Código Livre", "simplex-chat-protocol": "Protocolo Chat SimpleX", "terminal-cli": "CLI Terminal", "hero-header": "Privacidade redefinida", @@ -283,6 +284,8 @@ "index-nextweb-p2": "Nenhuma entidade controla a rede – qualquer pessoa pode operar servidores.", "index-token-h2": "Financiado Pelos Seus Usuários", "index-token-p1": "Para manter a independência, grandes canais e comunidades pagarão pelos seus servidores.", + "index-token-p2-cf": "E os usuários agora podem investir no SimpleX Chat — o crowdfunding está ativo!", + "index-token-cta-cf": "Investir no SimpleX Chat", "index-token-p2": "Isso cobrirá infraestrutura, desenvolvimento de software e governança da rede.", "index-token-cta": "Saiba mais sobre os Créditos da Comunidade", "index-roadmap-h2": "Roteiro do SimpleX para uma Internet Livre", diff --git a/website/langs/ro.json b/website/langs/ro.json index cfe489967f..57925505bb 100644 --- a/website/langs/ro.json +++ b/website/langs/ro.json @@ -20,7 +20,12 @@ "smp-protocol": "Protocolul SMP", "chat-protocol": "Protocol de chat", "donate": "Donează", - "copyright-label": "© 2020-2025 SimpleX Chat | Proiect Open-Source", + "invest": "Investește", + "index-token-h2": "Finanțat de utilizatorii săi", + "index-token-p1": "Pentru a rămâne independente, canalele și comunitățile mari vor plăti pentru serverele lor.", + "index-token-p2-cf": "Iar utilizatorii pot acum investi în SimpleX Chat — finanțarea participativă este activă!", + "index-token-cta-cf": "Investește în SimpleX Chat", + "copyright-label": "© 2020-2026 SimpleX Chat | Proiect Open-Source", "simplex-chat-protocol": "Protocolul SimpleX Chat", "terminal-cli": "Terminal CLI", "terms-and-privacy-policy": "Politică de confidențialitate", diff --git a/website/langs/ru.json b/website/langs/ru.json index fe8aee606e..9e714e59ff 100644 --- a/website/langs/ru.json +++ b/website/langs/ru.json @@ -1,6 +1,6 @@ { "copy-the-command-below-text": "скопируйте приведенную ниже команду и используйте ее в чате:", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект с открытым исходным кодом", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект с открытым исходным кодом", "chat-bot-example": "Пример Чат бота", "simplex-private-card-9-point-1": "Каждая очередь сообщений передает сообщения в одном направлении с разными адресами отправки и получения.", "simplex-private-card-1-point-2": "NaCL cryptobox в каждой очереди для предотвращения корреляции трафика между очередями сообщений, в случае компрометированного TLS.", @@ -207,6 +207,7 @@ "privacy-matters-overlay-card-2-p-3": "SimpleX — это первая сеть, которая не имеет никаких идентификаторов пользователей, таким образом защищая Ваши контакты лучше, чем любая известная альтернатива.", "learn-more": "Узнать больше", "donate": "Пожертвовать", + "invest": "Инвестировать", "simplex-private-8-title": "Смешивание сообщений
для уменьшения корреляции", "scan-qr-code-from-mobile-app": "Отсканируйте QR-код в мобильном приложении", "simplex-private-card-3-point-3": "Возобновление соединения отключено для предотвращения сеансовых атак.", @@ -282,6 +283,8 @@ "index-nextweb-p2": "Ни одна организация не контролирует сеть – каждый может запускать серверы.", "index-token-h2": "Финансируется Пользователями", "index-token-p1": "Для сохранения независимости крупные каналы и сообщества будут оплачивать свои серверы.", + "index-token-p2-cf": "А теперь пользователи могут инвестировать в SimpleX Chat — краудфандинг запущен!", + "index-token-cta-cf": "Инвестировать в SimpleX Chat", "index-token-p2": "Это покроет расходы на инфраструктуру, разработку программного обеспечения и управление сетью.", "index-token-cta": "Узнать больше про Community Credits", "index-roadmap-h2": "Путь Сети SimpleX к Свободному Интернету", diff --git a/website/langs/tr.json b/website/langs/tr.json index c832a80f74..de607f689a 100644 --- a/website/langs/tr.json +++ b/website/langs/tr.json @@ -21,7 +21,12 @@ "smp-protocol": "SMP Protokolü", "chat-protocol": "Sohbet Protokolü", "donate": "Bağış Yap", - "copyright-label": "© 2020-2025 SimpleX Chat | Açık Kaynak Projesi", + "invest": "Yatırım Yap", + "index-token-h2": "Kullanıcıları Tarafından Finanse Ediliyor", + "index-token-p1": "Bağımsız kalmak için büyük kanallar ve topluluklar sunucuları için ödeme yapacak.", + "index-token-p2-cf": "Ve kullanıcılar artık SimpleX Chat'e yatırım yapabilir — kitle fonlaması başladı!", + "index-token-cta-cf": "SimpleX Chat'e Yatırım Yap", + "copyright-label": "© 2020-2026 SimpleX Chat | Açık Kaynak Projesi", "simplex-chat-protocol": "SimpleX Sohbet Protokolü", "terminal-cli": "Terminal Komut Satırı Arayüzü", "terms-and-privacy-policy": "Gizlilik Politikası", diff --git a/website/langs/uk.json b/website/langs/uk.json index 7c719381d7..1848b5ce96 100644 --- a/website/langs/uk.json +++ b/website/langs/uk.json @@ -78,7 +78,12 @@ "smp-protocol": "Протокол SMP", "chat-protocol": "Протокол чату", "donate": "Пожертвувати", - "copyright-label": "© 2020-2025 SimpleX Chat | Проект з відкритим кодом", + "invest": "Інвестувати", + "index-token-h2": "Фінансується користувачами", + "index-token-p1": "Щоб залишатися незалежними, великі канали та спільноти платитимуть за свої сервери.", + "index-token-p2-cf": "А тепер користувачі можуть інвестувати в SimpleX Chat — краудфандинг запущено!", + "index-token-cta-cf": "Інвестувати в SimpleX Chat", + "copyright-label": "© 2020-2026 SimpleX Chat | Проект з відкритим кодом", "simplex-chat-protocol": "Протокол чату SimpleX", "terminal-cli": "Термінал CLI", "hero-header": "Приватність переосмислена", diff --git a/website/langs/zh_Hans.json b/website/langs/zh_Hans.json index f32e63556d..2ead3da487 100644 --- a/website/langs/zh_Hans.json +++ b/website/langs/zh_Hans.json @@ -53,11 +53,12 @@ "simplex-explained-tab-2-p-2": "服务器只单向传输消息,无法掌握用户的对话或连接的全貌。", "simplex-explained-tab-3-p-1": "服务器对每个队列都有单独的匿名凭证,并且不知道这些凭证属于哪些用户。", "donate": "捐赠", + "invest": "投资", "simplex-explained-tab-2-p-1": "对于每个连接,您都会使用两个单独的消息队列,通过不同的服务器发送和接收消息。", "simplex-chat-protocol": "SimpleX 聊天协议", "smp-protocol": "SMP协议", "chat-protocol": "聊天协议", - "copyright-label": "© 2020-2025 SimpleX Chat | 开源项目", + "copyright-label": "© 2020-2026 SimpleX Chat | 开源项目", "terminal-cli": "命令行程式", "simplex-explained-tab-1-p-1": "您可以创建联系人和群组,并进行双向对话,就像是任何其他即时通讯软件一样。", "hero-p-1": "其他应用——如Signal、Matrix、Session、Briar、Jami、Cwtch 等——都需要用户 ID。
而SimpleX 不需要用户ID,连随机生成的也不需要。
这从根本上改善了您的隐私。", @@ -283,6 +284,8 @@ "index-nextweb-p2": "没有任何单一实体控制网络 – 任何人都可以运行服务器。", "index-token-h2": "由用户资助", "index-token-p1": "为保持独立性,大型频道和社区将为其服务器付费。", + "index-token-p2-cf": "用户现在可以投资 SimpleX Chat — 众筹已上线!", + "index-token-cta-cf": "投资 SimpleX Chat", "index-token-p2": "这会承担基础设施、软件开发和网络治理费用。", "index-token-cta": "了解更多关于 Community Credits 的信息", "index-roadmap-h2": "SimpleX 通往自由互联网的路线图", diff --git a/website/langs/zh_Hant.json b/website/langs/zh_Hant.json index d1f25cc1e9..ced46c6b69 100644 --- a/website/langs/zh_Hant.json +++ b/website/langs/zh_Hant.json @@ -19,8 +19,13 @@ "simplex-explained-tab-2-p-2": "伺服器僅單向傳遞消息,無法全面瞭解使用者的對話記錄或連接。", "simplex-explained-tab-2-p-1": "對於每個連接,您可以使用兩個單獨的消息佇列通過不同的伺服器發送和接收消息。", "chat-protocol": "聊天協定", - "copyright-label": "© 2020-2025 SimpleX Chat |開源專案", + "copyright-label": "© 2020-2026 SimpleX Chat |開源專案", "donate": "捐助", + "invest": "投資", + "index-token-h2": "由用戶資助", + "index-token-p1": "為保持獨立性,大型頻道和社群將為其伺服器付費。", + "index-token-p2-cf": "用戶現在可以投資 SimpleX Chat — 眾籌已上線!", + "index-token-cta-cf": "投資 SimpleX Chat", "simplex-explained-tab-1-p-1": "你可以建立聯絡人和群組,並進行雙向對話,就像在任何其他即時通訊軟件中一樣。", "simplex-explained-tab-1-p-2": "它如何在沒有使用者個人檔案識別符的情況下使用單向佇列?", "simplex-explained-tab-3-p-1": "伺服器對每個佇列都有單獨的匿名憑證,並且不知道它們屬於哪些使用者。", diff --git a/website/src/_includes/blog_previews/20260820.html b/website/src/_includes/blog_previews/20260820.html new file mode 100644 index 0000000000..3fa62371d9 --- /dev/null +++ b/website/src/_includes/blog_previews/20260820.html @@ -0,0 +1,3 @@ +

SimpleX is the first and the only messaging network without user identifiers of any kind. Our equity crowdfunding round is now launched on Wefunder, so network users, and anybody else, can get a stake in SimpleX Chat.

+ +

Learn more and invest on Wefunder.

diff --git a/website/src/_includes/layouts/page.html b/website/src/_includes/layouts/page.html new file mode 100644 index 0000000000..c198f20d6a --- /dev/null +++ b/website/src/_includes/layouts/page.html @@ -0,0 +1,43 @@ + + + + + + {% include "dark-mode.html" %} + + + {{ title }} + + + + + + + + + + + + + + + + + + + + {% include "navbar.html" %} + +
+
{{ content | safe }}
+
+ + {% include "footer.html" %} + + + diff --git a/website/src/_includes/navbar.html b/website/src/_includes/navbar.html index cec2aa0a01..a3b0308802 100644 --- a/website/src/_includes/navbar.html +++ b/website/src/_includes/navbar.html @@ -110,11 +110,11 @@
- +