diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 72a7cf2b94..b396c9a289 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -5,14 +5,20 @@ on: pull_request_target: types: [opened, closed, synchronize] +permissions: + actions: write + contents: write + pull-requests: write + statuses: write + jobs: CLAssistant: runs-on: ubuntu-latest steps: - name: "CLA Assistant" - if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request' + if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target' # Beta Release - uses: cla-assistant/github-action@v2.1.3-beta + uses: cla-assistant/github-action@v2.3.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # the below token should have repo scope and must be manually added by you in the repository's secret @@ -33,4 +39,4 @@ jobs: #custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA' #custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.' #lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true) - #use-dco-flag: true - If you are using DCO instead of CLA \ No newline at end of file + #use-dco-flag: true - If you are using DCO instead of CLA diff --git a/README.md b/README.md index 28b40d0c1b..d1ac0b676d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) ![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social) -| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) | +| 30/03/2023 | EN, [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) | SimpleX logo diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 462699e407..a4651e1d42 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -103,6 +103,7 @@ final class ChatModel: ObservableObject { // tracks keyboard height via subscription in AppDelegate @Published var keyboardHeight: CGFloat = 0 @Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings + @Published var networkInfo = UserNetworkInfo(networkType: .other, online: true) var messageDelivery: Dictionary Void> = [:] @@ -778,6 +779,24 @@ final class Chat: ObservableObject, Identifiable { var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + func groupFeatureEnabled(_ feature: GroupFeature) -> Bool { + if case let .group(groupInfo) = self.chatInfo { + let p = groupInfo.fullGroupPreferences + return switch feature { + case .timedMessages: p.timedMessages.on + case .directMessages: p.directMessages.on(for: groupInfo.membership) + case .fullDelete: p.fullDelete.on + case .reactions: p.reactions.on + case .voice: p.voice.on(for: groupInfo.membership) + case .files: p.files.on(for: groupInfo.membership) + case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership) + case .history: p.history.on + } + } else { + return true + } + } + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Model/NetworkObserver.swift b/apps/ios/Shared/Model/NetworkObserver.swift new file mode 100644 index 0000000000..84c35afa07 --- /dev/null +++ b/apps/ios/Shared/Model/NetworkObserver.swift @@ -0,0 +1,73 @@ +// +// NetworkObserver.swift +// SimpleX (iOS) +// +// Created by Avently on 05.04.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import Foundation +import Network +import SimpleXChat + +class NetworkObserver { + static let shared = NetworkObserver() + private let queue: DispatchQueue = DispatchQueue(label: "chat.simplex.app.NetworkObserver") + private var prevInfo: UserNetworkInfo? = nil + private var monitor: NWPathMonitor? + private let monitorLock: DispatchQueue = DispatchQueue(label: "chat.simplex.app.monitorLock") + + func restartMonitor() { + monitorLock.sync { + monitor?.cancel() + let mon = NWPathMonitor() + mon.pathUpdateHandler = { [weak self] path in + self?.networkPathChanged(path: path) + } + mon.start(queue: queue) + monitor = mon + } + } + + private func networkPathChanged(path: NWPath) { + let info = UserNetworkInfo( + networkType: networkTypeFromPath(path), + online: path.status == .satisfied + ) + if (prevInfo != info) { + prevInfo = info + setNetworkInfo(info) + } + } + + private func networkTypeFromPath(_ path: NWPath) -> UserNetworkType { + if path.usesInterfaceType(.wiredEthernet) { + .ethernet + } else if path.usesInterfaceType(.wifi) { + .wifi + } else if path.usesInterfaceType(.cellular) { + .cellular + } else if path.usesInterfaceType(.other) { + .other + } else { + .none + } + } + + private static var networkObserver: NetworkObserver? = nil + + private func setNetworkInfo(_ info: UserNetworkInfo) { + logger.debug("setNetworkInfo Network changed: \(String(describing: info))") + DispatchQueue.main.sync { + ChatModel.shared.networkInfo = info + } + if !hasChatCtrl() { return } + self.monitorLock.sync { + do { + try apiSetNetworkInfo(info) + } catch let err { + logger.error("setNetworkInfo error: \(responseError(err))") + } + } + } +} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index a099069f77..65e24eeb25 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -353,11 +353,20 @@ func apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64) async throws - throw r } +func apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) async -> ChatItem? { + let cmd: ChatCommand = .apiForwardChatItem(toChatType: toChatType, toChatId: toChatId, fromChatType: fromChatType, fromChatId: fromChatId, itemId: itemId) + return await processSendMessageCmd(toChatType: toChatType, cmd: cmd) +} + func apiSendMessage(type: ChatType, id: Int64, file: CryptoFile?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { - let chatModel = ChatModel.shared let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) + return await processSendMessageCmd(toChatType: type, cmd: cmd) +} + +private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async -> ChatItem? { + let chatModel = ChatModel.shared let r: ChatResponse - if type == .direct { + if toChatType == .direct { var cItem: ChatItem? = nil let endTask = beginBGTask({ if let cItem = cItem { @@ -397,7 +406,7 @@ func apiCreateChatItem(noteFolderId: Int64, file: CryptoFile?, msg: MsgContent) } private func sendMessageErrorAlert(_ r: ChatResponse) { - logger.error("apiSendMessage error: \(String(describing: r))") + logger.error("send message error: \(String(describing: r))") AlertManager.shared.showAlertMsg( title: "Error sending message", message: "Error: \(String(describing: r))" @@ -534,6 +543,12 @@ func setNetworkConfig(_ cfg: NetCfg, ctrl: chat_ctrl? = nil) throws { throw r } +func apiSetNetworkInfo(_ networkInfo: UserNetworkInfo) throws { + let r = chatSendCmdSync(.apiSetNetworkInfo(networkInfo: networkInfo)) + if case .cmdOk = r { return } + throw r +} + func reconnectAllServers() async throws { try await sendCommandOkResp(.reconnectAllServers) } @@ -1297,6 +1312,7 @@ func initializeChat(start: Bool, confirmStart: Bool = false, dbKey: String? = ni defer { m.ctrlInitInProgress = false } (m.chatDbEncrypted, m.chatDbStatus) = chatMigrateInit(dbKey, confirmMigrations: confirmMigrations) if m.chatDbStatus != .ok { return } + NetworkObserver.shared.restartMonitor() // If we migrated successfully means previous re-encryption process on database level finished successfully too if encryptionStartedDefault.get() { encryptionStartedDefault.set(false) @@ -1462,6 +1478,8 @@ class ChatReceiver { private var receiveMessages = true private var _lastMsgTime = Date.now + var messagesChannel: ((ChatResponse) -> Void)? = nil + static let shared = ChatReceiver() var lastMsgTime: Date { get { _lastMsgTime } } @@ -1479,6 +1497,9 @@ class ChatReceiver { if let msg = await chatRecvMsg() { self._lastMsgTime = .now await processReceivedMsg(msg) + if let messagesChannel { + messagesChannel(msg) + } } _ = try? await Task.sleep(nanoseconds: 7_500_000) } @@ -1791,7 +1812,6 @@ func processReceivedMsg(_ res: ChatResponse) async { } case let .sndFileCompleteXFTP(user, aChatItem, _): await chatItemSimpleUpdate(user, aChatItem) - Task { cleanupFile(aChatItem) } case let .sndFileError(user, aChatItem, _): if let aChatItem = aChatItem { await chatItemSimpleUpdate(user, aChatItem) diff --git a/apps/ios/Shared/Views/Call/ActiveCallView.swift b/apps/ios/Shared/Views/Call/ActiveCallView.swift index 9f246f63f3..cffdefaaa2 100644 --- a/apps/ios/Shared/Views/Call/ActiveCallView.swift +++ b/apps/ios/Shared/Views/Call/ActiveCallView.swift @@ -9,6 +9,7 @@ import SwiftUI import WebKit import SimpleXChat +import AVFoundation struct ActiveCallView: View { @EnvironmentObject var m: ChatModel @@ -21,6 +22,7 @@ struct ActiveCallView: View { @Binding var canConnectCall: Bool @State var prevColorScheme: ColorScheme = .dark @State var pipShown = false + @State var wasConnected = false var body: some View { ZStack(alignment: .topLeading) { @@ -69,6 +71,11 @@ struct ActiveCallView: View { Task { await m.callCommand.setClient(nil) } AppDelegate.keepScreenOn(false) client?.endCall() + CallSoundsPlayer.shared.stop() + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) + if (wasConnected) { + CallSoundsPlayer.shared.vibrate(long: true) + } } .background(m.activeCallViewIsCollapsed ? .clear : .black) // Quite a big delay when opening/closing the view when a scheme changes (globally) this way. It's not needed when CallKit is used since status bar is green with white text on it @@ -103,6 +110,11 @@ struct ActiveCallView: View { call.callState = .invitationSent call.localCapabilities = capabilities } + if call.supportsVideo { + try? AVAudioSession.sharedInstance().setCategory(.playback, options: .defaultToSpeaker) + } + CallSoundsPlayer.shared.startConnectingCallSound() + activeCallWaitDeliveryReceipt() } case let .offer(offer, iceCandidates, capabilities): Task { @@ -126,6 +138,8 @@ struct ActiveCallView: View { } await MainActor.run { call.callState = .negotiated + CallSoundsPlayer.shared.stop() + try? AVAudioSession.sharedInstance().setCategory(.soloAmbient) } } case let .ice(iceCandidates): @@ -144,6 +158,10 @@ struct ActiveCallView: View { : CallController.shared.reportIncomingCall(call: call, connectedAt: nil) call.callState = .connected call.connectedAt = .now + if !wasConnected { + CallSoundsPlayer.shared.vibrate(long: false) + wasConnected = true + } } if state.connectionState == "closed" { closeCallView(client) @@ -161,6 +179,10 @@ struct ActiveCallView: View { call.callState = .connected call.connectionInfo = connectionInfo call.connectedAt = .now + if !wasConnected { + CallSoundsPlayer.shared.vibrate(long: false) + wasConnected = true + } case .ended: closeCallView(client) call.callState = .ended @@ -187,6 +209,22 @@ struct ActiveCallView: View { } } + private func activeCallWaitDeliveryReceipt() { + ChatReceiver.shared.messagesChannel = { msg in + guard let call = ChatModel.shared.activeCall, call.callState == .invitationSent else { + ChatReceiver.shared.messagesChannel = nil + return + } + if case let .chatItemStatusUpdated(_, msg) = msg, + msg.chatInfo.id == call.contact.id, + case .sndCall = msg.chatItem.content, + case .sndRcvd = msg.chatItem.meta.itemStatus { + CallSoundsPlayer.shared.startInCallSound() + ChatReceiver.shared.messagesChannel = nil + } + } + } + private func closeCallView(_ client: WebRTCClient) { if m.activeCall != nil { m.showCallView = false diff --git a/apps/ios/Shared/Views/Call/SoundPlayer.swift b/apps/ios/Shared/Views/Call/SoundPlayer.swift index 17c13ab403..c7803a0cb8 100644 --- a/apps/ios/Shared/Views/Call/SoundPlayer.swift +++ b/apps/ios/Shared/Views/Call/SoundPlayer.swift @@ -8,6 +8,7 @@ import Foundation import AVFoundation +import UIKit class SoundPlayer { static let shared = SoundPlayer() @@ -43,3 +44,63 @@ class SoundPlayer { audioPlayer = nil } } + +class CallSoundsPlayer { + static let shared = CallSoundsPlayer() + private var audioPlayer: AVAudioPlayer? + private var playerTask: Task = Task {} + + private func start(_ soundName: String, delayMs: Double) { + audioPlayer?.stop() + playerTask.cancel() + logger.debug("start \(soundName)") + guard let path = Bundle.main.path(forResource: soundName, ofType: "mp3", inDirectory: "sounds") else { + logger.debug("start: file not found") + return + } + do { + let player = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) + if player.prepareToPlay() { + audioPlayer = player + } + } catch { + logger.debug("start: AVAudioPlayer error \(error.localizedDescription)") + } + + playerTask = Task { + while let player = audioPlayer { + player.play() + do { + try await Task.sleep(nanoseconds: UInt64((player.duration * 1_000_000_000) + delayMs * 1_000_000)) + } catch { + break + } + } + } + } + + func startConnectingCallSound() { + start("connecting_call", delayMs: 0) + } + + func startInCallSound() { + // Taken from https://github.com/TelegramOrg/Telegram-Android + // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE + start("in_call", delayMs: 1000) + } + + func stop() { + playerTask.cancel() + audioPlayer?.stop() + audioPlayer = nil + } + + func vibrate(long: Bool) { + // iOS just don't want to vibrate more than once after a short period of time, and all 'styles' feel the same + if long { + AudioServicesPlayAlertSound(kSystemSoundID_Vibrate) + } else { + UIImpactFeedbackGenerator(style: .heavy).impactOccurred() + } + } +} diff --git a/apps/ios/Shared/Views/Call/WebRTC.swift b/apps/ios/Shared/Views/Call/WebRTC.swift index 919b1e14e7..333dc082d5 100644 --- a/apps/ios/Shared/Views/Call/WebRTC.swift +++ b/apps/ios/Shared/Views/Call/WebRTC.swift @@ -431,17 +431,18 @@ struct RTCIceServer: Codable, Equatable { } // the servers are expected in this format: -// stun:stun.simplex.im:443?transport=tcp -// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp +// stuns:stun.simplex.im:443?transport=tcp +// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp func parseRTCIceServer(_ str: String) -> RTCIceServer? { var s = replaceScheme(str, "stun:") + s = replaceScheme(s, "stuns:") s = replaceScheme(s, "turn:") s = replaceScheme(s, "turns:") if let u: URL = URL(string: s), let scheme = u.scheme, let host = u.host, let port = u.port, - u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns") { + u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns") { let query = u.query == nil || u.query == "" ? "" : "?" + (u.query ?? "") return RTCIceServer( urls: ["\(scheme):\(host):\(port)\(query)"], diff --git a/apps/ios/Shared/Views/Call/WebRTCClient.swift b/apps/ios/Shared/Views/Call/WebRTCClient.swift index 1806984d64..ff36241daf 100644 --- a/apps/ios/Shared/Views/Call/WebRTCClient.swift +++ b/apps/ios/Shared/Views/Call/WebRTCClient.swift @@ -49,7 +49,7 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg } private let rtcAudioSession = RTCAudioSession.sharedInstance() - private let audioQueue = DispatchQueue(label: "audio") + private let audioQueue = DispatchQueue(label: "chat.simplex.app.audio") private var sendCallResponse: (WVAPIMessage) async -> Void var activeCall: Binding private var localRendererAspectRatio: Binding @@ -65,14 +65,14 @@ final class WebRTCClient: NSObject, RTCVideoViewDelegate, RTCFrameEncryptorDeleg self.localRendererAspectRatio = localRendererAspectRatio rtcAudioSession.useManualAudio = CallController.useCallKit() rtcAudioSession.isAudioEnabled = !CallController.useCallKit() - logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)}") + logger.debug("WebRTCClient: rtcAudioSession has manual audio \(self.rtcAudioSession.useManualAudio) and audio enabled \(self.rtcAudioSession.isAudioEnabled)") super.init() } let defaultIceServers: [WebRTC.RTCIceServer] = [ - WebRTC.RTCIceServer(urlStrings: ["stun:stun.simplex.im:443"]), - WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), - WebRTC.RTCIceServer(urlStrings: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"), + WebRTC.RTCIceServer(urlStrings: ["stuns:stun.simplex.im:443"]), + //WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), + WebRTC.RTCIceServer(urlStrings: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"), ] func initializeCall(_ iceServers: [WebRTC.RTCIceServer]?, _ mediaType: CallMediaType, _ aesKey: String?, _ relay: Bool?) -> Call { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index 03afa30331..5c9ea0f6d8 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel + @ObservedObject var chat: Chat var chatItem: ChatItem @Binding var revealed: Bool var feature: Feature @@ -18,7 +19,7 @@ struct CIChatFeatureView: View { var iconColor: Color var body: some View { - if !revealed, let fs = mergedFeautures() { + if !revealed, let fs = mergedFeatures() { HStack { ForEach(fs, content: featureIconView) } @@ -47,7 +48,7 @@ struct CIChatFeatureView: View { } } - private func mergedFeautures() -> [FeatureInfo]? { + private func mergedFeatures() -> [FeatureInfo]? { var fs: [FeatureInfo] = [] var icons: Set = [] if var i = m.getChatItemIndex(chatItem) { @@ -67,8 +68,8 @@ struct CIChatFeatureView: View { switch ci.content { case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param) - case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) - case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param) + case let .rcvGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param) + case let .sndGroupFeature(feature, preference, param, role): FeatureInfo(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor, param) default: nil } } @@ -103,6 +104,6 @@ struct CIChatFeatureView: View { struct CIChatFeatureView_Previews: PreviewProvider { static var previews: some View { let enabled = FeatureEnabled(forUser: false, forContact: false) - CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) + CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift index c94ba3f830..ae9e09b138 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIFileView.swift @@ -69,19 +69,12 @@ struct CIFileView: View { return false } - private func fileSizeValid() -> Bool { - if let file = file { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - private func fileAction() { logger.debug("CIFileView fileAction") if let file = file { switch (file.fileStatus) { case .rcvInvitation: - if fileSizeValid() { + if fileSizeValid(file) { Task { logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") if let user = m.currentUser { @@ -143,7 +136,7 @@ struct CIFileView: View { case .sndCancelled: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10) case .rcvInvitation: - if fileSizeValid() { + if fileSizeValid(file) { fileIcon("arrow.down.doc.fill", color: .accentColor) } else { fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12) @@ -201,6 +194,13 @@ struct CIFileView: View { } } +func fileSizeValid(_ file: CIFile?) -> Bool { + if let file = file { + return file.fileSize <= getMaxFileSize(file.fileProtocol) + } + return false +} + func saveCryptoFile(_ fileSource: CryptoFile) { if let cfArgs = fileSource.cryptoArgs { let url = getAppFilePath(fileSource.filePath) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index a3918e17bc..b4b190a43a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -126,7 +126,7 @@ struct CIVideoView: View { if !decryptionInProgress { Button { decrypt(file: file) { - if let decrypted = urlDecrypted { + if urlDecrypted != nil { videoPlaying = true player?.play() } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 3475e7a8b6..ed724599be 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -65,6 +65,8 @@ 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) } ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: framedMsgContentView) @@ -163,7 +165,7 @@ struct FramedItemView: View { ) } - @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text) -> some View { + @ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View { let v = HStack(spacing: 6) { if let icon = icon { Image(systemName: icon) @@ -178,7 +180,7 @@ struct FramedItemView: View { .foregroundColor(.secondary) .padding(.horizontal, 12) .padding(.top, 6) - .padding(.bottom, chatItem.quotedItem == nil ? 6 : 0) // TODO think how to regroup + .padding(.bottom, pad || (chatItem.quotedItem == nil && chatItem.meta.itemForwarded == nil) ? 6 : 0) .overlay(DetermineWidth()) .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, colorScheme)) @@ -353,9 +355,9 @@ private struct MetaColorPreferenceKey: PreferenceKey { func onlyImageOrVideo(_ ci: ChatItem) -> Bool { if case let .image(text, _) = ci.content.msgContent { - return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == "" + return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == "" } else if case let .video(text, _, _) = ci.content.msgContent { - return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && text == "" + return ci.meta.itemDeleted == nil && !ci.meta.isLive && ci.quotedItem == nil && ci.meta.itemForwarded == nil && text == "" } return false } diff --git a/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift new file mode 100644 index 0000000000..f575b6f9a2 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItemForwardingView.swift @@ -0,0 +1,152 @@ +// +// ChatItemForwardingView.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 12.04.2024. +// Copyright © 2024 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct ChatItemForwardingView: View { + @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) var dismiss + + var ci: ChatItem + var fromChatInfo: ChatInfo + @Binding var composeState: ComposeState + + @State private var searchText: String = "" + @FocusState private var searchFocused + + var body: some View { + NavigationView { + forwardListView() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .principal) { + Text("Forward") + .bold() + } + } + } + } + + @ViewBuilder private func forwardListView() -> some View { + VStack(alignment: .leading) { + let chatsToForwardTo = filterChatsToForwardTo() + if !chatsToForwardTo.isEmpty { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + searchFieldView(text: $searchText, focussed: $searchFocused) + .padding(.leading, 2) + let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase + let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { filterChatSearched($0, s) } + ForEach(chats) { chat in + Divider() + forwardListNavLinkView(chat) + .disabled(chatModel.deletedChats.contains(chat.chatInfo.id)) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(uiColor: .systemBackground)) + .cornerRadius(12) + .padding(.horizontal) + } + .background(Color(.systemGroupedBackground)) + } else { + emptyList() + } + } + } + + private func filterChatsToForwardTo() -> [Chat] { + var filteredChats = chatModel.chats.filter({ canForwardToChat($0) }) + if let index = filteredChats.firstIndex(where: { $0.chatInfo.chatType == .local }) { + let privateNotes = filteredChats.remove(at: index) + filteredChats.insert(privateNotes, at: 0) + } + return filteredChats + } + + private func filterChatSearched(_ chat: Chat, _ searchStr: String) -> Bool { + let cInfo = chat.chatInfo + return switch cInfo { + case let .direct(contact): + viewNameContains(cInfo, searchStr) || + contact.profile.displayName.localizedLowercase.contains(searchStr) || + contact.fullName.localizedLowercase.contains(searchStr) + default: + viewNameContains(cInfo, searchStr) + } + + func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool { + cInfo.chatViewName.localizedLowercase.contains(s) + } + } + + private func canForwardToChat(_ chat: Chat) -> Bool { + switch chat.chatInfo { + case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv + case let .group(groupInfo): groupInfo.sendMsgEnabled + case let .local(noteFolder): noteFolder.sendMsgEnabled + case .contactRequest: false + case .contactConnection: false + case .invalidJSON: false + } + } + + private func emptyList() -> some View { + Text("No filtered chats") + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } + + @ViewBuilder private func forwardListNavLinkView(_ chat: Chat) -> some View { + Button { + dismiss() + if chat.id == fromChatInfo.id { + composeState = ComposeState( + message: composeState.message, + preview: composeState.linkPreview != nil ? composeState.preview : .noPreview, + contextItem: .forwardingItem(chatItem: ci, fromChatInfo: fromChatInfo) + ) + } else { + composeState = ComposeState.init(forwardingItem: ci, fromChatInfo: fromChatInfo) + chatModel.chatId = chat.id + } + } label: { + HStack { + ChatInfoImage(chat: chat) + .frame(width: 30, height: 30) + .padding(.trailing, 2) + Text(chat.chatInfo.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + if chat.chatInfo.incognito { + Spacer() + Image(systemName: "theatermasks") + .resizable() + .scaledToFit() + .frame(width: 22, height: 22) + .foregroundColor(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +#Preview { + ChatItemForwardingView( + ci: ChatItem.getSample(1, .directSnd, .now, "hello"), + fromChatInfo: .direct(contact: Contact.sampleData), + composeState: Binding.constant(ComposeState(message: "hello")) + ) +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 8dd43cc01b..0d1f99f3bd 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct ChatItemInfoView: View { @EnvironmentObject var chatModel: ChatModel + @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme var ci: ChatItem @Binding var chatItemInfo: ChatItemInfo? @@ -21,6 +22,7 @@ struct ChatItemInfoView: View { enum CIInfoTab { case history case quote + case forwarded case delivery } @@ -68,9 +70,20 @@ struct ChatItemInfoView: View { if ci.quotedItem != nil { numTabs += 1 } + if chatItemInfo?.forwardedFromChatItem != nil { + numTabs += 1 + } return numTabs } + private var local: Bool { + switch ci.chatDir { + case .localSnd: true + case .localRcv: true + default: false + } + } + @ViewBuilder private func itemInfoView() -> some View { if numTabs > 1 { TabView(selection: $selection) { @@ -93,6 +106,13 @@ struct ChatItemInfoView: View { } .tag(CIInfoTab.quote) } + if let forwardedFromItem = chatItemInfo?.forwardedFromChatItem { + forwardedFromTab(forwardedFromItem) + .tabItem { + Label(local ? "Saved" : "Forwarded", systemImage: "arrowshape.turn.up.forward") + } + .tag(CIInfoTab.forwarded) + } } .onAppear { if chatItemInfo?.memberDeliveryStatuses != nil { @@ -275,6 +295,75 @@ struct ChatItemInfoView: View { : Color(uiColor: .tertiarySystemGroupedBackground) } + @ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + details() + Divider().padding(.vertical) + Text(local ? "Saved from" : "Forwarded from") + .font(.title2) + .padding(.bottom, 4) + forwardedFromView(forwardedFromItem) + } + .padding() + } + .frame(maxHeight: .infinity, alignment: .top) + } + + private func forwardedFromView(_ forwardedFromItem: AChatItem) -> some View { + VStack(alignment: .leading, spacing: 8) { + Button { + Task { + await MainActor.run { + chatModel.chatId = forwardedFromItem.chatInfo.id + dismiss() + } + } + } label: { + forwardedFromSender(forwardedFromItem) + } + + if !local { + Divider().padding(.top, 32) + Text("Recipient(s) can't see who this message is from.") + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { + HStack { + ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo)) + .frame(width: 48, height: 48) + .padding(.trailing, 6) + + if forwardedFromItem.chatItem.chatDir.sent { + VStack(alignment: .leading) { + Text("you") + .italic() + .foregroundColor(.primary) + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.secondary) + .lineLimit(1) + } + } else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir { + VStack(alignment: .leading) { + Text(groupMember.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.secondary) + .lineLimit(1) + } + } else { + Text(forwardedFromItem.chatInfo.chatViewName) + .foregroundColor(.primary) + .lineLimit(1) + } + } + } + @ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { ScrollView { VStack(alignment: .leading, spacing: 16) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index da9dc523e1..c2adcacbfe 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -46,7 +46,7 @@ struct ChatItemView: View { let ci = chatItem if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) { MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed) - } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { + } else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chat: chat, chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { @@ -102,9 +102,9 @@ struct ChatItemContentView: View { case let .rcvChatPreference(feature, allowed, param): CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param) case let .sndChatPreference(feature, _, _): - CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) - case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) - case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) + CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary) + case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor) + case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvGroupFeatureRejected(feature): chatFeatureView(feature, .red) case .sndModerated: deletedItemView() @@ -149,7 +149,7 @@ struct ChatItemContentView: View { } private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { - CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) + CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor) } private var mergedGroupEventText: Text? { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index cd2aa55bc3..45819851f0 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -83,7 +83,11 @@ struct ChatView: View { initChatView() } .onChange(of: chatModel.chatId) { cId in - if cId != nil { + showChatInfoSheet = false + if let cId { + if let c = chatModel.getChat(cId) { + chat = c + } initChatView() } else { dismiss() @@ -179,7 +183,7 @@ struct ChatView: View { .disabled(!contact.ready || !contact.active) } searchButton() - toggleNtfsButton(chat) + ToggleNtfsButton(chat: chat) .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") @@ -208,7 +212,7 @@ struct ChatView: View { } Menu { searchButton() - toggleNtfsButton(chat) + ToggleNtfsButton(chat: chat) } label: { Image(systemName: "ellipsis") } @@ -251,7 +255,8 @@ struct ChatView: View { } } } - if chatModel.draftChatId == cInfo.id, let draft = chatModel.draft { + if chatModel.draftChatId == cInfo.id && !composeState.forwarding, + let draft = chatModel.draft { composeState = draft } if chat.chatStats.unreadChat { @@ -296,7 +301,7 @@ struct ChatView: View { } private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { - ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil + ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil && ci.meta.itemForwarded == nil } private func chatItemsList() -> some View { @@ -342,8 +347,8 @@ struct ChatView: View { .onChange(of: searchText) { _ in loadChat(chat: chat, search: searchText) } - .onChange(of: chatModel.chatId) { _ in - if let chatId = chatModel.chatId, let c = chatModel.getChat(chatId) { + .onChange(of: chatModel.chatId) { chatId in + if let chatId, let c = chatModel.getChat(chatId) { chat = c showChatInfoSheet = false loadChat(chat: c) @@ -539,6 +544,7 @@ struct ChatView: View { @State private var revealed = false @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? + @State private var showForwardingSheet: Bool = false @State private var allowMenu: Bool = true @@ -667,7 +673,7 @@ struct ChatView: View { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) } - if let di = deletingItem, di.meta.editable && !di.localNote { + if let di = deletingItem, di.meta.deletable && !di.localNote { Button(broadcastDeleteButtonText, role: .destructive) { deleteMessage(.cidmBroadcast) } @@ -693,6 +699,14 @@ struct ChatView: View { }) { ChatItemInfoView(ci: ci, chatItemInfo: $chatItemInfo) } + .sheet(isPresented: $showForwardingSheet) { + if #available(iOS 16.0, *) { + ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState) + .presentationDetents([.fraction(0.8)]) + } else { + ChatItemForwardingView(ci: ci, fromChatInfo: chat.chatInfo, composeState: $composeState) + } + } } private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { @@ -771,10 +785,17 @@ struct ChatView: View { } else { menu.append(saveFileAction(fileSource)) } + } else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) { + menu.append(downloadFileAction(file)) } if ci.meta.editable && !mc.isVoice && !live { menu.append(editAction(ci)) } + if ci.meta.itemDeleted == nil + && (ci.file == nil || (fileSource != nil && fileExists)) + && !ci.isLiveDummy && !live { + menu.append(forwardUIAction(ci)) + } if !ci.isLiveDummy { menu.append(viewInfoUIAction(ci)) } @@ -826,6 +847,15 @@ struct ChatView: View { } } + private func forwardUIAction(_ ci: ChatItem) -> UIAction { + UIAction( + title: NSLocalizedString("Forward", comment: "chat item action"), + image: UIImage(systemName: "arrowshape.turn.up.forward") + ) { _ in + showForwardingSheet = true + } + } + private func reactionUIMenuPreiOS16(_ rs: [UIAction]) -> UIMenu { UIMenu( title: NSLocalizedString("React…", comment: "chat item menu"), @@ -923,7 +953,21 @@ struct ChatView: View { saveCryptoFile(fileSource) } } - + + private func downloadFileAction(_ file: CIFile) -> UIAction { + UIAction( + title: NSLocalizedString("Download", comment: "chat item action"), + image: UIImage(systemName: "arrow.down.doc") + ) { _ in + Task { + logger.debug("ChatView downloadFileAction, in Task") + if let user = m.currentUser { + await receiveFile(user: user, fileId: file.fileId) + } + } + } + } + private func editAction(_ ci: ChatItem) -> UIAction { UIAction( title: NSLocalizedString("Edit", comment: "chat item action"), @@ -1172,14 +1216,18 @@ struct ChatView: View { } } -@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { - Button { - toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) - } label: { - if chat.chatInfo.ntfsEnabled { - Label("Mute", systemImage: "speaker.slash") - } else { - Label("Unmute", systemImage: "speaker.wave.2") +struct ToggleNtfsButton: View { + @ObservedObject var chat: Chat + + var body: some View { + Button { + toggleNotifications(chat, enableNtfs: !chat.chatInfo.ntfsEnabled) + } label: { + if chat.chatInfo.ntfsEnabled { + Label("Mute", systemImage: "speaker.slash") + } else { + Label("Unmute", systemImage: "speaker.wave.2") + } } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 604e0a276d..6cf9df782b 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -23,6 +23,7 @@ enum ComposeContextItem { case noContextItem case quotedItem(chatItem: ChatItem) case editingItem(chatItem: ChatItem) + case forwardingItem(chatItem: ChatItem, fromChatInfo: ChatInfo) } enum VoiceMessageRecordingState { @@ -72,6 +73,13 @@ struct ComposeState { } } + init(forwardingItem: ChatItem, fromChatInfo: ChatInfo) { + self.message = "" + self.preview = .noPreview + self.contextItem = .forwardingItem(chatItem: forwardingItem, fromChatInfo: fromChatInfo) + self.voiceMessageRecordingState = .noRecording + } + func copy( message: String? = nil, liveMessage: LiveMessage? = nil, @@ -102,12 +110,19 @@ struct ComposeState { } } + var forwarding: Bool { + switch contextItem { + case .forwardingItem: return true + default: return false + } + } + var sendEnabled: Bool { switch preview { case let .mediaPreviews(media): return !media.isEmpty case .voicePreview: return voiceMessageRecordingState == .finished case .filePreview: return true - default: return !message.isEmpty || liveMessage != nil + default: return !message.isEmpty || forwarding || liveMessage != nil } } @@ -153,7 +168,7 @@ struct ComposeState { } var attachmentDisabled: Bool { - if editing || liveMessage != nil || inProgress { return true } + if editing || forwarding || liveMessage != nil || inProgress { return true } switch preview { case .noPreview: return false case .linkPreview: return false @@ -161,6 +176,16 @@ struct ComposeState { } } + var attachmentPreview: Bool { + switch preview { + case .noPreview: false + case .linkPreview: false + case let .mediaPreviews(mediaPreviews): !mediaPreviews.isEmpty + case .voicePreview: false + case .filePreview: true + } + } + var empty: Bool { message == "" && noPreview } @@ -234,6 +259,7 @@ struct ComposeView: View { @Binding var keyboardVisible: Bool @State var linkUrl: URL? = nil + @State var hasSimplexLink: Bool = false @State var prevLinkUrl: URL? = nil @State var pendingLinkUrl: URL? = nil @State var cancelledLinks: Set = [] @@ -260,6 +286,16 @@ struct ComposeView: View { if chat.chatInfo.contact?.nextSendGrpInv ?? false { ContextInvitingContactMemberView() } + let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks) + let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files) + let voiceProhibited = composeState.voicePreview && !chat.chatInfo.featureEnabled(.voice) + if simplexLinkProhibited { + msgNotAllowedView("SimpleX links not allowed", icon: "link") + } else if fileProhibited { + msgNotAllowedView("Files and media not allowed", icon: "doc") + } else if voiceProhibited { + msgNotAllowedView("Voice messages not allowed", icon: "mic") + } contextItemView() switch (composeState.editing, composeState.preview) { case (true, .filePreview): EmptyView() @@ -278,7 +314,7 @@ struct ComposeView: View { .padding(.bottom, 12) .padding(.leading, 12) if case let .group(g) = chat.chatInfo, - !g.fullGroupPreferences.files.on { + !g.fullGroupPreferences.files.on(for: g.membership) { b.disabled(true).onTapGesture { AlertManager.shared.showAlertMsg( title: "Files and media prohibited!", @@ -303,6 +339,7 @@ struct ComposeView: View { }, nextSendGrpInv: chat.chatInfo.contact?.nextSendGrpInv ?? false, voiceMessageAllowed: chat.chatInfo.featureEnabled(.voice), + disableSendButton: simplexLinkProhibited || fileProhibited || voiceProhibited, showEnableVoiceMessagesAlert: chat.chatInfo.showEnableVoiceMessagesAlert, startVoiceMessageRecording: { Task { @@ -337,13 +374,18 @@ struct ComposeView: View { } } } - .onChange(of: composeState.message) { _ in + .onChange(of: composeState.message) { msg in if composeState.linkPreviewAllowed { - if composeState.message.count > 0 { - showLinkPreview(composeState.message) + if msg.count > 0 { + showLinkPreview(msg) } else { resetLinkPreview() + hasSimplexLink = false } + } else if msg.count > 0 && !chat.groupFeatureEnabled(.simplexLinks) { + (_, hasSimplexLink) = parseMessage(msg) + } else { + hasSimplexLink = false } } .onChange(of: chat.userCanSend) { canSend in @@ -610,6 +652,18 @@ struct ComposeView: View { } } + private func msgNotAllowedView(_ reason: LocalizedStringKey, icon: String) -> some View { + HStack { + Image(systemName: icon).foregroundColor(.secondary) + Text(reason).italic() + } + .padding(12) + .frame(minHeight: 50) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(uiColor: .tertiarySystemGroupedBackground)) + .padding(.top, 8) + } + @ViewBuilder private func contextItemView() -> some View { switch composeState.contextItem { case .noContextItem: @@ -628,6 +682,14 @@ struct ComposeView: View { contextIcon: "pencil", cancelContextItem: { clearState() } ) + case let .forwardingItem(chatItem: forwardedItem, _): + ContextItemView( + chat: chat, + contextItem: forwardedItem, + contextIcon: "arrowshape.turn.up.forward", + cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }, + showSender: false + ) } } @@ -649,6 +711,11 @@ struct ComposeView: View { } if chat.chatInfo.contact?.nextSendGrpInv ?? false { await sendMemberContactInvitation() + } else if case let .forwardingItem(ci, fromChatInfo) = composeState.contextItem { + sent = await forwardItem(ci, fromChatInfo) + if !composeState.message.isEmpty { + sent = await send(checkLinkPreview(), quoted: sent?.id, live: false, ttl: nil) + } } else if case let .editingItem(ci) = composeState.contextItem { sent = await updateMessage(ci, live: live) } else if let liveMessage = liveMessage, liveMessage.sentMsg != nil { @@ -694,7 +761,15 @@ struct ComposeView: View { } } } - await MainActor.run { clearState(live: live) } + await MainActor.run { + let wasForwarding = composeState.forwarding + clearState(live: live) + if wasForwarding, + chatModel.draftChatId == chat.chatInfo.id, + let draft = chatModel.draft { + composeState = draft + } + } return sent func sending() async { @@ -815,10 +890,26 @@ struct ComposeView: View { return nil } + func forwardItem(_ forwardedItem: ChatItem, _ fromChatInfo: ChatInfo) async -> ChatItem? { + if let chatItem = await apiForwardChatItem( + toChatType: chat.chatInfo.chatType, + toChatId: chat.chatInfo.apiId, + fromChatType: fromChatInfo.chatType, + fromChatId: fromChatInfo.apiId, + itemId: forwardedItem.id + ) { + await MainActor.run { + chatModel.addChatItem(chat.chatInfo, chatItem) + } + return chatItem + } + return nil + } + func checkLinkPreview() -> MsgContent { switch (composeState.preview) { case let .linkPreview(linkPreview: linkPreview): - if let url = parseMessage(msgText), + if let url = parseMessage(msgText).url, let linkPreview = linkPreview, url == linkPreview.uri { return .link(text: msgText, preview: linkPreview) @@ -947,7 +1038,7 @@ struct ComposeView: View { private func showLinkPreview(_ s: String) { prevLinkUrl = linkUrl - linkUrl = parseMessage(s) + (linkUrl, hasSimplexLink) = parseMessage(s) if let url = linkUrl { if url != composeState.linkPreview?.uri && url != pendingLinkUrl { pendingLinkUrl = url @@ -964,13 +1055,17 @@ struct ComposeView: View { } } - private func parseMessage(_ msg: String) -> URL? { - let parsedMsg = parseSimpleXMarkdown(msg) - let uri = parsedMsg?.first(where: { ft in + private func parseMessage(_ msg: String) -> (url: URL?, hasSimplexLink: Bool) { + guard let parsedMsg = parseSimpleXMarkdown(msg) else { return (nil, false) } + let url: URL? = if let uri = parsedMsg.first(where: { ft in ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) - }) - if let uri = uri { return URL(string: uri.text) } - else { return nil } + }) { + URL(string: uri.text) + } else { + nil + } + let simplexLink = parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false }) + return (url, simplexLink) } private func isSimplexLink(_ link: String) -> Bool { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift index 3eb128cded..2777d8321c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextItemView.swift @@ -15,6 +15,7 @@ struct ContextItemView: View { let contextItem: ChatItem let contextIcon: String let cancelContextItem: () -> Void + var showSender: Bool = true var body: some View { HStack { @@ -23,7 +24,7 @@ struct ContextItemView: View { .aspectRatio(contentMode: .fit) .frame(width: 16, height: 16) .foregroundColor(.secondary) - if let sender = contextItem.memberDisplayName { + if showSender, let sender = contextItem.memberDisplayName { VStack(alignment: .leading, spacing: 4) { Text(sender).font(.caption).foregroundColor(.secondary) msgContentView(lines: 2) @@ -48,14 +49,26 @@ struct ContextItemView: View { } private func msgContentView(lines: Int) -> some View { - MsgContentView( - chat: chat, - text: contextItem.text, - formattedText: contextItem.formattedText, - showSecrets: false - ) - .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) - .lineLimit(lines) + contextMsgPreview() + .multilineTextAlignment(isRightToLeft(contextItem.text) ? .trailing : .leading) + .lineLimit(lines) + } + + private func contextMsgPreview() -> Text { + return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, showSecrets: false) + + func attachment() -> Text { + switch contextItem.content.msgContent { + case .file: return image("doc.fill") + case .image: return image("photo") + case .voice: return image("play.fill") + default: return Text("") + } + } + + func image(_ s: String) -> Text { + Text(Image(systemName: s)).foregroundColor(Color(uiColor: .tertiaryLabel)) + Text(" ") + } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift index 3eead5b0af..f2c7221835 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/NativeTextEditor.swift @@ -16,7 +16,6 @@ struct NativeTextEditor: UIViewRepresentable { @Binding var disableEditing: Bool @Binding var height: CGFloat @Binding var focused: Bool - let alignment: TextAlignment let onImagesAdded: ([UploadContent]) -> Void private let minHeight: CGFloat = 37 @@ -30,13 +29,16 @@ struct NativeTextEditor: UIViewRepresentable { func makeUIView(context: Context) -> UITextView { let field = CustomUITextField(height: _height) field.text = text - field.textAlignment = alignment == .leading ? .left : .right + field.textAlignment = alignment(text) field.autocapitalizationType = .sentences field.setOnTextChangedListener { newText, images in if !disableEditing { - // Speed up the process of updating layout, reduce jumping content on screen - if !isShortEmoji(newText) { updateHeight(field) } text = newText + field.textAlignment = alignment(text) + updateFont(field) + // Speed up the process of updating layout, reduce jumping content on screen + updateHeight(field) + self.height = field.frame.size.height } else { field.text = text } @@ -53,10 +55,12 @@ struct NativeTextEditor: UIViewRepresentable { } func updateUIView(_ field: UITextView, context: Context) { - field.text = text - field.textAlignment = alignment == .leading ? .left : .right - updateFont(field) - updateHeight(field) + if field.markedTextRange == nil && field.text != text { + field.text = text + field.textAlignment = alignment(text) + updateFont(field) + updateHeight(field) + } } private func updateHeight(_ field: UITextView) { @@ -73,12 +77,19 @@ struct NativeTextEditor: UIViewRepresentable { } private func updateFont(_ field: UITextView) { - field.font = isShortEmoji(field.text) + let newFont = isShortEmoji(field.text) ? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont) : UIFont.preferredFont(forTextStyle: .body) + if field.font != newFont { + field.font = newFont + } } } +private func alignment(_ text: String) -> NSTextAlignment { + isRightToLeft(text) ? .right : .left +} + private class CustomUITextField: UITextView, UITextViewDelegate { var height: Binding var newHeight: CGFloat = 0 @@ -205,7 +216,6 @@ struct NativeTextEditor_Previews: PreviewProvider{ disableEditing: Binding.constant(false), height: Binding.constant(100), focused: Binding.constant(false), - alignment: TextAlignment.leading, onImagesAdded: { _ in } ) .fixedSize(horizontal: false, vertical: true) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 8f7b23c888..8b528a201c 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -20,6 +20,7 @@ struct SendMessageView: View { var nextSendGrpInv: Bool = false var showVoiceMessageButton: Bool = true var voiceMessageAllowed: Bool = true + var disableSendButton = false var showEnableVoiceMessagesAlert: ChatInfo.ShowEnableVoiceMessagesAlert = .other var startVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil @@ -53,13 +54,11 @@ struct SendMessageView: View { .padding(.vertical, 8) .frame(maxWidth: .infinity) } else { - let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading NativeTextEditor( text: $composeState.message, disableEditing: $composeState.inProgress, height: $teHeight, focused: $keyboardVisible, - alignment: alignment, onImagesAdded: onMediaAdded ) .allowsTightening(false) @@ -109,6 +108,7 @@ struct SendMessageView: View { } else if showVoiceMessageButton && composeState.message.isEmpty && !composeState.editing + && !composeState.forwarding && composeState.liveMessage == nil && ((composeState.noPreview && vmrs == .noRecording) || (vmrs == .recording && holdingVMR)) { @@ -184,7 +184,8 @@ struct SendMessageView: View { !composeState.sendEnabled || composeState.inProgress || (!voiceMessageAllowed && composeState.voicePreview) || - composeState.endLiveDisabled + composeState.endLiveDisabled || + disableSendButton ) .frame(width: 29, height: 29) .contextMenu{ @@ -223,7 +224,8 @@ struct SendMessageView: View { @ViewBuilder private func sendButtonContextMenuItems() -> some View { if composeState.liveMessage == nil, - !composeState.editing { + !composeState.editing, + !composeState.forwarding { if case .noContextItem = composeState.contextItem, !composeState.voicePreview, let send = sendLiveMessage, diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 999617dde7..766aaf6577 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -83,7 +83,7 @@ struct GroupMemberInfoView: View { Section { if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) { knownDirectChatButton(chat) - } else if groupInfo.fullGroupPreferences.directMessages.on { + } else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { if let contactId = member.memberContactId { newDirectChatButton(contactId) } else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false { @@ -110,7 +110,7 @@ struct GroupMemberInfoView: View { Label("Share address", systemImage: "square.and.arrow.up") } if let contactId = member.memberContactId { - if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on { + if knownDirectChat(contactId) == nil && !groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) { connectViaAddressButton(contactLink) } } else { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 7ab4bf4ece..b4e1992848 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -9,6 +9,12 @@ import SwiftUI import SimpleXChat +private let featureRoles: [(role: GroupMemberRole?, text: LocalizedStringKey)] = [ + (nil, "all members"), + (.admin, "admins"), + (.owner, "owners") +] + struct GroupPreferencesView: View { @Environment(\.dismiss) var dismiss: DismissAction @EnvironmentObject var chatModel: ChatModel @@ -24,10 +30,12 @@ struct GroupPreferencesView: View { List { featureSection(.timedMessages, $preferences.timedMessages.enable) featureSection(.fullDelete, $preferences.fullDelete.enable) - featureSection(.directMessages, $preferences.directMessages.enable) + featureSection(.directMessages, $preferences.directMessages.enable, $preferences.directMessages.role) featureSection(.reactions, $preferences.reactions.enable) - featureSection(.voice, $preferences.voice.enable) - featureSection(.files, $preferences.files.enable) + featureSection(.voice, $preferences.voice.enable, $preferences.voice.role) + featureSection(.files, $preferences.files.enable, $preferences.files.role) + // TODO enable simplexLinks preference in 5.8 + // featureSection(.simplexLinks, $preferences.simplexLinks.enable, $preferences.simplexLinks.role) featureSection(.history, $preferences.history.enable) if groupInfo.canEdit { @@ -64,7 +72,7 @@ struct GroupPreferencesView: View { } } - private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding) -> some View { + private func featureSection(_ feature: GroupFeature, _ enableFeature: Binding, _ enableForRole: Binding? = nil) -> some View { Section { let color: Color = enableFeature.wrappedValue == .on ? .green : .secondary let icon = enableFeature.wrappedValue == .on ? feature.iconFilled : feature.icon @@ -87,6 +95,16 @@ struct GroupPreferencesView: View { ) .frame(height: 36) } + if enableFeature.wrappedValue == .on, let enableForRole { + Picker("Enabled for", selection: enableForRole) { + ForEach(featureRoles, id: \.role) { fr in + Text(fr.text) + } + } + .frame(height: 36) + // remove in v5.8 + .disabled(true) + } } else { settingsRow(icon, color: color) { infoRow(Text(feature.text), enableFeature.wrappedValue.text) @@ -94,10 +112,26 @@ struct GroupPreferencesView: View { if timedOn { infoRow("Delete after", timeText(preferences.timedMessages.ttl)) } + if enableFeature.wrappedValue == .on, let enableForRole { + HStack { + Text("Enabled for").foregroundColor(.secondary) + Spacer() + Text( + featureRoles.first(where: { fr in fr.role == enableForRole.wrappedValue })?.text + ?? "all members" + ) + .foregroundColor(.secondary) + } + } } } footer: { Text(feature.enableDescription(enableFeature.wrappedValue, groupInfo.canEdit)) } + .onChange(of: enableFeature.wrappedValue) { enabled in + if case .off = enabled { + enableForRole?.wrappedValue = nil + } + } } private func savePreferences() { diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 7fbc1e4ac8..efe254323e 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -92,7 +92,7 @@ struct ChatListNavLink: View { .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() - toggleNtfsButton(chat) + ToggleNtfsButton(chat: chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !chat.chatItems.isEmpty { @@ -181,7 +181,7 @@ struct ChatListNavLink: View { .swipeActions(edge: .leading, allowsFullSwipe: true) { markReadButton() toggleFavoriteButton() - toggleNtfsButton(chat) + ToggleNtfsButton(chat: chat) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !chat.chatItems.isEmpty { diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 9afd0dd406..e290537b46 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -448,6 +448,9 @@ struct MigrateToDevice: View { case .rcvFileError: alert = .error(title: "Download failed", error: "File was deleted or link is invalid") migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) + case .chatError(_, .error(.noRcvFileUser)): + alert = .error(title: "Download failed", error: "File was deleted or link is invalid") + migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) default: logger.debug("unsupported event: \(msg.responseType)") } diff --git a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift index 9da3bac00b..9f03b95321 100644 --- a/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AdvancedNetworkSettings.swift @@ -53,7 +53,7 @@ struct AdvancedNetworkSettings: View { timeoutSettingPicker("TCP connection timeout", selection: $netCfg.tcpConnectTimeout, values: [7_500000, 10_000000, 15_000000, 20_000000, 30_000000, 45_000000], label: secondsLabel) timeoutSettingPicker("Protocol timeout", selection: $netCfg.tcpTimeout, values: [5_000000, 7_000000, 10_000000, 15_000000, 20_000000, 30_000000], label: secondsLabel) - timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [15_000, 30_000, 45_000, 60_000, 90_000, 120_000], label: secondsLabel) + timeoutSettingPicker("Protocol timeout per KB", selection: $netCfg.tcpTimeoutPerKb, values: [2_500, 5_000, 10_000, 15_000, 20_000, 30_000], label: secondsLabel) timeoutSettingPicker("PING interval", selection: $netCfg.smpPingInterval, values: [120_000000, 300_000000, 600_000000, 1200_000000, 2400_000000, 3600_000000], label: secondsLabel) intSettingPicker("PING count", selection: $netCfg.smpPingCount, values: [1, 2, 3, 5, 8], label: "") Toggle("Enable TCP keep-alive", isOn: $enableKeepAlive) diff --git a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift index d721cfad50..a6702b1821 100644 --- a/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift +++ b/apps/ios/Shared/Views/UserSettings/NetworkAndServers.swift @@ -24,6 +24,7 @@ private enum NetworkAlert: Identifiable { } struct NetworkAndServers: View { + @EnvironmentObject var m: ChatModel @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @State private var cfgLoaded = false @State private var currentNetCfg = NetCfg.defaults @@ -82,6 +83,14 @@ struct NetworkAndServers: View { Text("WebRTC ICE servers") } } + + Section("Network connection") { + HStack { + Text(m.networkInfo.networkType.text) + Spacer() + Image(systemName: "circle.fill").foregroundColor(m.networkInfo.online ? .green : .red) + } + } } } .onAppear { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index efd8f33dd8..faa7f4f44c 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -586,9 +586,6 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { cleanupDirectFile(aChatItem) } return nil - case let .sndFileCompleteXFTP(_, aChatItem, _): - cleanupFile(aChatItem) - return nil case let .callInvitation(invitation): // Do not post it without CallKit support, iOS will stop launching the app without showing CallKit return ( diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 7ab03bcca0..5c8308c3b6 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -110,12 +110,12 @@ 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; }; 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; }; + 5CC83D1A2BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC83D152BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a */; }; + 5CC83D1B2BCC504B00A0C558 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC83D162BCC504B00A0C558 /* libgmpxx.a */; }; + 5CC83D1C2BCC504B00A0C558 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC83D172BCC504B00A0C558 /* libgmp.a */; }; + 5CC83D1D2BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC83D182BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a */; }; + 5CC83D1E2BCC504B00A0C558 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC83D192BCC504B00A0C558 /* libffi.a */; }; 5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; }; - 5CC932E12BBC94DC008A1EB6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC932DC2BBC94DC008A1EB6 /* libgmp.a */; }; - 5CC932E22BBC94DC008A1EB6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC932DD2BBC94DC008A1EB6 /* libgmpxx.a */; }; - 5CC932E32BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC932DE2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a */; }; - 5CC932E42BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC932DF2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a */; }; - 5CC932E52BBC94DC008A1EB6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC932E02BBC94DC008A1EB6 /* libffi.a */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */ = {isa = PBXBuildFile; fileRef = 5CD67B8D2B0E858A00C510B1 /* hs_init.h */; settings = {ATTRIBUTES = (Public, ); }; }; 5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */ = {isa = PBXBuildFile; fileRef = 5CD67B8E2B0E858A00C510B1 /* hs_init.c */; }; @@ -177,6 +177,7 @@ 646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; }; 647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; }; 648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; }; + 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; }; 649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; }; 649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; @@ -192,6 +193,7 @@ 8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; }; 8C7D949A2B88952700B7B9E1 /* MigrateToDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */; }; 8C7DF3202B7CDB0A00C886D0 /* MigrateFromDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */; }; + 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; }; D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; }; D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; }; D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; }; @@ -406,12 +408,12 @@ 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; + 5CC83D152BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a"; sourceTree = ""; }; + 5CC83D162BCC504B00A0C558 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5CC83D172BCC504B00A0C558 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; + 5CC83D182BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a"; sourceTree = ""; }; + 5CC83D192BCC504B00A0C558 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = ""; }; - 5CC932DC2BBC94DC008A1EB6 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CC932DD2BBC94DC008A1EB6 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CC932DE2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a"; sourceTree = ""; }; - 5CC932DF2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a"; sourceTree = ""; }; - 5CC932E02BBC94DC008A1EB6 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = ""; }; 5CD67B8D2B0E858A00C510B1 /* hs_init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = hs_init.h; sourceTree = ""; }; 5CD67B8E2B0E858A00C510B1 /* hs_init.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = hs_init.c; sourceTree = ""; }; @@ -473,6 +475,7 @@ 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = ""; }; 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = ""; }; 648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = ""; }; + 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = ""; }; 6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; @@ -490,6 +493,7 @@ 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8C7D94992B88952700B7B9E1 /* MigrateToDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateToDevice.swift; sourceTree = ""; }; 8C7DF31F2B7CDB0A00C886D0 /* MigrateFromDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MigrateFromDevice.swift; sourceTree = ""; }; + 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = ""; }; D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = ""; }; D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; }; D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -531,12 +535,12 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CC932E32BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a in Frameworks */, - 5CC932E42BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a in Frameworks */, - 5CC932E22BBC94DC008A1EB6 /* libgmpxx.a in Frameworks */, + 5CC83D1D2BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a in Frameworks */, + 5CC83D1B2BCC504B00A0C558 /* libgmpxx.a in Frameworks */, + 5CC83D1C2BCC504B00A0C558 /* libgmp.a in Frameworks */, + 5CC83D1A2BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a in Frameworks */, + 5CC83D1E2BCC504B00A0C558 /* libffi.a in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, - 5CC932E52BBC94DC008A1EB6 /* libffi.a in Frameworks */, - 5CC932E12BBC94DC008A1EB6 /* libgmp.a in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -593,6 +597,7 @@ 5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */, 5CBE6C132944CC12002D9531 /* ScanCodeView.swift */, 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */, + 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */, ); path = Chat; sourceTree = ""; @@ -600,11 +605,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CC932E02BBC94DC008A1EB6 /* libffi.a */, - 5CC932DC2BBC94DC008A1EB6 /* libgmp.a */, - 5CC932DD2BBC94DC008A1EB6 /* libgmpxx.a */, - 5CC932DE2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr-ghc9.6.3.a */, - 5CC932DF2BBC94DC008A1EB6 /* libHSsimplex-chat-5.6.1.0-1MvnzcJ9TtTKuJWF1wc9Tr.a */, + 5CC83D192BCC504B00A0C558 /* libffi.a */, + 5CC83D172BCC504B00A0C558 /* libgmp.a */, + 5CC83D162BCC504B00A0C558 /* libgmpxx.a */, + 5CC83D182BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv-ghc9.6.4.a */, + 5CC83D152BCC504B00A0C558 /* libHSsimplex-chat-5.7.0.0-AhbVfRKDsEZ5w5ND1HSTLv.a */, ); path = Libraries; sourceTree = ""; @@ -633,6 +638,7 @@ 5CB346E82869E8BA001FD2EF /* PushEnvironment.swift */, 5C93293E2928E0FD0090FFF9 /* AudioRecPlay.swift */, 5CBD2859295711D700EC2CF4 /* ImageUtils.swift */, + 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */, ); path = Model; sourceTree = ""; @@ -1168,6 +1174,7 @@ 5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */, 5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */, 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, + 648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */, 64466DC829FC2B3B00E3D48D /* CreateSimpleXAddress.swift in Sources */, 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, @@ -1186,6 +1193,7 @@ 5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */, 5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */, 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */, + 8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */, 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */, 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */, 5CA7DFC329302AF000F7FDDE /* AppSheet.swift in Sources */, @@ -1546,7 +1554,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1571,7 +1579,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES_THIN; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1595,7 +1603,7 @@ CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; @@ -1620,7 +1628,7 @@ "@executable_path/Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_NAME = SimpleX; SDKROOT = iphoneos; @@ -1681,7 +1689,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; GCC_OPTIMIZATION_LEVEL = s; @@ -1696,7 +1704,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1718,7 +1726,7 @@ CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEVELOPMENT_TEAM = 5NN7GUYB6T; ENABLE_BITCODE = NO; ENABLE_CODE_COVERAGE = NO; @@ -1733,7 +1741,7 @@ "@executable_path/../../Frameworks", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1755,7 +1763,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1781,7 +1789,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; @@ -1806,7 +1814,7 @@ CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES; CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 205; + CURRENT_PROJECT_VERSION = 207; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = 5NN7GUYB6T; DYLIB_COMPATIBILITY_VERSION = 1; @@ -1832,7 +1840,7 @@ "$(PROJECT_DIR)/Libraries/sim", ); LLVM_LTO = YES; - MARKETING_VERSION = 5.6.1; + MARKETING_VERSION = 5.7; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SDKROOT = iphoneos; diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index cdd1008c13..e2f4adc60f 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -87,6 +87,11 @@ public func chatInitControllerRemovingDatabases() { public func chatCloseStore() { + // Prevent crash when exiting the app with already closed store (for example, after changing a database passpharase) + guard hasChatCtrl() else { + logger.error("chatCloseStore: already closed, chatCtrl is nil") + return + } let err = fromCString(chat_close_store(getChatCtrl())) if err != "" { logger.error("chatCloseStore error: \(err)") diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b18022cfec..f33bdfbdd8 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -50,6 +50,7 @@ public enum ChatCommand { case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction) + case apiForwardChatItem(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemId: Int64) case apiGetNtfToken case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode) case apiVerifyToken(token: DeviceToken, nonce: String, code: String) @@ -77,6 +78,7 @@ public enum ChatCommand { case apiGetChatItemTTL(userId: Int64) case apiSetNetworkConfig(networkConfig: NetCfg) case apiGetNetworkConfig + case apiSetNetworkInfo(networkInfo: UserNetworkInfo) case reconnectAllServers case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings) @@ -194,6 +196,7 @@ public enum ChatCommand { case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))" + case let .apiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId): return "/_forward \(ref(toChatType, toChatId)) \(ref(fromChatType, fromChatId)) \(itemId)" case .apiGetNtfToken: return "/_ntf get " case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" @@ -221,6 +224,7 @@ public enum ChatCommand { case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)" case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))" case .apiGetNetworkConfig: return "/network" + case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))" case .reconnectAllServers: return "/reconnect" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))" @@ -341,6 +345,7 @@ public enum ChatCommand { case .apiConnectContactViaAddress: return "apiConnectContactViaAddress" case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem" case .apiChatItemReaction: return "apiChatItemReaction" + case .apiForwardChatItem: return "apiForwardChatItem" case .apiGetNtfToken: return "apiGetNtfToken" case .apiRegisterToken: return "apiRegisterToken" case .apiVerifyToken: return "apiVerifyToken" @@ -368,6 +373,7 @@ public enum ChatCommand { case .apiGetChatItemTTL: return "apiGetChatItemTTL" case .apiSetNetworkConfig: return "apiSetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig" + case .apiSetNetworkInfo: return "apiSetNetworkInfo" case .reconnectAllServers: return "reconnectAllServers" case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetMemberSettings: return "apiSetMemberSettings" @@ -1261,7 +1267,7 @@ public struct NetCfg: Codable, Equatable { sessionMode: TransportSessionMode.user, tcpConnectTimeout: 20_000_000, tcpTimeout: 15_000_000, - tcpTimeoutPerKb: 45_000, + tcpTimeoutPerKb: 10_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, @@ -1273,7 +1279,7 @@ public struct NetCfg: Codable, Equatable { sessionMode: TransportSessionMode.user, tcpConnectTimeout: 30_000_000, tcpTimeout: 20_000_000, - tcpTimeoutPerKb: 60_000, + tcpTimeoutPerKb: 15_000, tcpKeepAlive: KeepAliveOpts.defaults, smpPingInterval: 1200_000_000, smpPingCount: 3, @@ -1714,6 +1720,8 @@ public enum ChatErrorType: Decodable { case fallbackToSMPProhibited(fileId: Int64) case inlineFileProhibited(fileId: Int64) case invalidQuote + case invalidForward + case forwardNoFile case invalidChatItemUpdate case invalidChatItemDelete case hasCurrentCall @@ -2086,3 +2094,31 @@ public enum AppSettingsLockScreenCalls: String, Codable { case show case accept } + +public struct UserNetworkInfo: Codable, Equatable { + public let networkType: UserNetworkType + public let online: Bool + + public init(networkType: UserNetworkType, online: Bool) { + self.networkType = networkType + self.online = online + } +} + +public enum UserNetworkType: String, Codable { + case none + case cellular + case wifi + case ethernet + case other + + public var text: LocalizedStringKey { + switch self { + case .none: "No network connection" + case .cellular: "Cellular" + case .wifi: "WiFi" + case .ethernet: "Wired ethernet" + case .other: "Other" + } + } +} diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index ed62b5c9ac..d1f1fc06d1 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -543,6 +543,7 @@ public protocol Feature { var iconFilled: String { get } var iconScale: CGFloat { get } var hasParam: Bool { get } + var hasRole: Bool { get } var text: String { get } } @@ -569,6 +570,8 @@ public enum ChatFeature: String, Decodable, Feature { } } + public var hasRole: Bool { false } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -694,6 +697,7 @@ public enum GroupFeature: String, Decodable, Feature { case reactions case voice case files + case simplexLinks case history public var id: Self { self } @@ -705,6 +709,19 @@ public enum GroupFeature: String, Decodable, Feature { } } + public var hasRole: Bool { + switch self { + case .timedMessages: false + case .directMessages: true + case .fullDelete: false + case .reactions: false + case .voice: true + case .files: true + case .simplexLinks: true + case .history: false + } + } + public var text: String { switch self { case .timedMessages: return NSLocalizedString("Disappearing messages", comment: "chat feature") @@ -713,6 +730,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return NSLocalizedString("Message reactions", comment: "chat feature") case .voice: return NSLocalizedString("Voice messages", comment: "chat feature") case .files: return NSLocalizedString("Files and media", comment: "chat feature") + case .simplexLinks: return NSLocalizedString("SimpleX links", comment: "chat feature") case .history: return NSLocalizedString("Visible history", comment: "chat feature") } } @@ -725,6 +743,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return "face.smiling" case .voice: return "mic" case .files: return "doc" + case .simplexLinks: return "link.circle" case .history: return "clock" } } @@ -737,6 +756,7 @@ public enum GroupFeature: String, Decodable, Feature { case .reactions: return "face.smiling.fill" case .voice: return "mic.fill" case .files: return "doc.fill" + case .simplexLinks: return "link.circle.fill" case .history: return "clock.fill" } } @@ -781,6 +801,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Allow to send files and media." case .off: return "Prohibit sending files and media." } + case .simplexLinks: + switch enabled { + case .on: return "Allow to send SimpleX links." + case .off: return "Prohibit sending SimpleX links." + } case .history: switch enabled { case .on: return "Send up to 100 last messages to new members." @@ -819,6 +844,11 @@ public enum GroupFeature: String, Decodable, Feature { case .on: return "Group members can send files and media." case .off: return "Files and media are prohibited in this group." } + case .simplexLinks: + switch enabled { + case .on: return "Group members can send SimpleX links." + case .off: return "SimpleX links are prohibited in this group." + } case .history: switch enabled { case .on: return "Up to 100 last messages are sent to new members." @@ -958,20 +988,22 @@ public enum FeatureAllowed: String, Codable, Identifiable { public struct FullGroupPreferences: Decodable, Equatable { public var timedMessages: TimedMessagesGroupPreference - public var directMessages: GroupPreference + public var directMessages: RoleGroupPreference public var fullDelete: GroupPreference public var reactions: GroupPreference - public var voice: GroupPreference - public var files: GroupPreference + public var voice: RoleGroupPreference + public var files: RoleGroupPreference + public var simplexLinks: RoleGroupPreference public var history: GroupPreference public init( timedMessages: TimedMessagesGroupPreference, - directMessages: GroupPreference, + directMessages: RoleGroupPreference, fullDelete: GroupPreference, reactions: GroupPreference, - voice: GroupPreference, - files: GroupPreference, + voice: RoleGroupPreference, + files: RoleGroupPreference, + simplexLinks: RoleGroupPreference, history: GroupPreference ) { self.timedMessages = timedMessages @@ -980,36 +1012,40 @@ public struct FullGroupPreferences: Decodable, Equatable { self.reactions = reactions self.voice = voice self.files = files + self.simplexLinks = simplexLinks self.history = history } public static let sampleData = FullGroupPreferences( timedMessages: TimedMessagesGroupPreference(enable: .off), - directMessages: GroupPreference(enable: .off), + directMessages: RoleGroupPreference(enable: .off, role: nil), fullDelete: GroupPreference(enable: .off), reactions: GroupPreference(enable: .on), - voice: GroupPreference(enable: .on), - files: GroupPreference(enable: .on), + voice: RoleGroupPreference(enable: .on, role: nil), + files: RoleGroupPreference(enable: .on, role: nil), + simplexLinks: RoleGroupPreference(enable: .on, role: nil), history: GroupPreference(enable: .on) ) } public struct GroupPreferences: Codable { public var timedMessages: TimedMessagesGroupPreference? - public var directMessages: GroupPreference? + public var directMessages: RoleGroupPreference? public var fullDelete: GroupPreference? public var reactions: GroupPreference? - public var voice: GroupPreference? - public var files: GroupPreference? + public var voice: RoleGroupPreference? + public var files: RoleGroupPreference? + public var simplexLinks: RoleGroupPreference? public var history: GroupPreference? public init( timedMessages: TimedMessagesGroupPreference? = nil, - directMessages: GroupPreference? = nil, + directMessages: RoleGroupPreference? = nil, fullDelete: GroupPreference? = nil, reactions: GroupPreference? = nil, - voice: GroupPreference? = nil, - files: GroupPreference? = nil, + voice: RoleGroupPreference? = nil, + files: RoleGroupPreference? = nil, + simplexLinks: RoleGroupPreference? = nil, history: GroupPreference? = nil ) { self.timedMessages = timedMessages @@ -1018,16 +1054,18 @@ public struct GroupPreferences: Codable { self.reactions = reactions self.voice = voice self.files = files + self.simplexLinks = simplexLinks self.history = history } public static let sampleData = GroupPreferences( timedMessages: TimedMessagesGroupPreference(enable: .off), - directMessages: GroupPreference(enable: .off), + directMessages: RoleGroupPreference(enable: .off, role: nil), fullDelete: GroupPreference(enable: .off), reactions: GroupPreference(enable: .on), - voice: GroupPreference(enable: .on), - files: GroupPreference(enable: .on), + voice: RoleGroupPreference(enable: .on, role: nil), + files: RoleGroupPreference(enable: .on, role: nil), + simplexLinks: RoleGroupPreference(enable: .on, role: nil), history: GroupPreference(enable: .on) ) } @@ -1040,6 +1078,7 @@ public func toGroupPreferences(_ fullPreferences: FullGroupPreferences) -> Group reactions: fullPreferences.reactions, voice: fullPreferences.voice, files: fullPreferences.files, + simplexLinks: fullPreferences.simplexLinks, history: fullPreferences.history ) } @@ -1051,11 +1090,37 @@ public struct GroupPreference: Codable, Equatable { enable == .on } + public func enabled(_ role: GroupMemberRole?, for m: GroupMember?) -> GroupFeatureEnabled { + switch enable { + case .off: .off + case .on: + if let role, let m { + m.memberRole >= role ? .on : .off + } else { + .on + } + } + } + public init(enable: GroupFeatureEnabled) { self.enable = enable } } +public struct RoleGroupPreference: Codable, Equatable { + public var enable: GroupFeatureEnabled + public var role: GroupMemberRole? + + public func on(for m: GroupMember) -> Bool { + enable == .on && m.memberRole >= (role ?? .observer) + } + + public init(enable: GroupFeatureEnabled, role: GroupMemberRole?) { + self.enable = enable + self.role = role + } +} + public struct TimedMessagesGroupPreference: Codable, Equatable { public var enable: GroupFeatureEnabled public var ttl: Int? @@ -1280,7 +1345,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { case .timedMessages: return prefs.timedMessages.on case .fullDelete: return prefs.fullDelete.on case .reactions: return prefs.reactions.on - case .voice: return prefs.voice.on + case .voice: return prefs.voice.on(for: groupInfo.membership) case .calls: return false } case .local: @@ -1323,7 +1388,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat { return .other } case let .group(groupInfo): - if !groupInfo.fullGroupPreferences.voice.on { + if !groupInfo.fullGroupPreferences.voice.on(for: groupInfo.membership) { return .groupOwnerCan } else { return .other @@ -1975,7 +2040,7 @@ public struct GroupMemberIds: Decodable { var groupId: Int64 } -public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { +public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable { case observer = "observer" case author = "author" case member = "member" @@ -2372,10 +2437,10 @@ public struct ChatItem: Identifiable, Decodable { } } - public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, editable: Bool = true) -> ChatItem { + public static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil, file: CIFile? = nil, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> ChatItem { ChatItem( chatDir: dir, - meta: CIMeta.getSample(id, ts, text, status, itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, editable: editable), + meta: CIMeta.getSample(id, ts, text, status, itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, deletable: deletable, editable: editable), content: .sndMsgContent(msgContent: .text(text)), quotedItem: quotedItem, file: file @@ -2466,6 +2531,7 @@ public struct ChatItem: Identifiable, Decodable { itemDeleted: nil, itemEdited: false, itemLive: false, + deletable: false, editable: false ), content: .rcvDeleted(deleteMode: .cidmBroadcast), @@ -2487,6 +2553,7 @@ public struct ChatItem: Identifiable, Decodable { itemDeleted: nil, itemEdited: false, itemLive: true, + deletable: false, editable: false ), content: .sndMsgContent(msgContent: .text("")), @@ -2546,10 +2613,12 @@ public struct CIMeta: Decodable { public var itemStatus: CIStatus public var createdAt: Date public var updatedAt: Date + public var itemForwarded: CIForwardedFrom? public var itemDeleted: CIDeleted? public var itemEdited: Bool public var itemTimed: CITimed? public var itemLive: Bool? + public var deletable: Bool public var editable: Bool public var timestampText: Text { get { formatTimestampText(itemTs) } } @@ -2566,7 +2635,7 @@ public struct CIMeta: Decodable { itemStatus.statusIcon(metaColor) } - public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, editable: Bool = true) -> CIMeta { + public static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, itemDeleted: CIDeleted? = nil, itemEdited: Bool = false, itemLive: Bool = false, deletable: Bool = true, editable: Bool = true) -> CIMeta { CIMeta( itemId: id, itemTs: ts, @@ -2577,6 +2646,7 @@ public struct CIMeta: Decodable { itemDeleted: itemDeleted, itemEdited: itemEdited, itemLive: itemLive, + deletable: deletable, editable: editable ) } @@ -2592,6 +2662,7 @@ public struct CIMeta: Decodable { itemDeleted: nil, itemEdited: false, itemLive: false, + deletable: false, editable: false ) } @@ -2713,6 +2784,31 @@ public enum CIDeleted: Decodable { } } +public enum MsgDirection: String, Decodable { + case rcv = "rcv" + case snd = "snd" +} + +public enum CIForwardedFrom: Decodable { + case unknown + case contact(chatName: String, msgDir: MsgDirection, contactId: Int64?, chatItemId: Int64?) + case group(chatName: String, msgDir: MsgDirection, groupId: Int64?, chatItemId: Int64?) + + var chatName: String { + switch self { + case .unknown: "" + case let .contact(chatName, _, _, _): chatName + case let .group(chatName, _, _, _): chatName + } + } + + public func text(_ chatType: ChatType) -> LocalizedStringKey { + chatType == .local + ? (chatName == "" ? "saved" : "saved from \(chatName)") + : "forwarded" + } +} + public enum CIDeleteMode: String, Decodable { case cidmBroadcast = "broadcast" case cidmInternal = "internal" @@ -2742,8 +2838,8 @@ public enum CIContent: Decodable, ItemContent { case sndChatFeature(feature: ChatFeature, enabled: FeatureEnabled, param: Int?) case rcvChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) case sndChatPreference(feature: ChatFeature, allowed: FeatureAllowed, param: Int?) - case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) - case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?) + case rcvGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?, memberRole_: GroupMemberRole?) + case sndGroupFeature(groupFeature: GroupFeature, preference: GroupPreference, param: Int?, memberRole_: GroupMemberRole?) case rcvChatFeatureRejected(feature: ChatFeature) case rcvGroupFeatureRejected(groupFeature: GroupFeature) case sndModerated @@ -2777,8 +2873,8 @@ public enum CIContent: Decodable, ItemContent { case let .sndChatFeature(feature, enabled, param): return CIContent.featureText(feature, enabled.text, param) case let .rcvChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) case let .sndChatPreference(feature, allowed, param): return CIContent.preferenceText(feature, allowed, param) - case let .rcvGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) - case let .sndGroupFeature(feature, preference, param): return CIContent.featureText(feature, preference.enable.text, param) + case let .rcvGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) + case let .sndGroupFeature(feature, preference, param, role): return CIContent.featureText(feature, preference.enable.text, param, role) case let .rcvChatFeatureRejected(feature): return String.localizedStringWithFormat("%@: received, prohibited", feature.text) case let .rcvGroupFeatureRejected(groupFeature): return String.localizedStringWithFormat("%@: received, prohibited", groupFeature.text) case .sndModerated: return NSLocalizedString("moderated", comment: "moderated chat item") @@ -2803,10 +2899,25 @@ public enum CIContent: Decodable, ItemContent { NSLocalizedString("This chat is protected by end-to-end encryption.", comment: "E2EE info chat item") } - static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { - feature.hasParam - ? "\(feature.text): \(timeText(param))" - : "\(feature.text): \(enabled)" + static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?, _ role: GroupMemberRole? = nil) -> String { + ( + feature.hasParam + ? "\(feature.text): \(timeText(param))" + : "\(feature.text): \(enabled)" + ) + + ( + feature.hasRole && role != nil + ? " (\(roleText(role)))" + : "" + ) + } + + private static func roleText(_ role: GroupMemberRole?) -> String { + switch role { + case .owner: NSLocalizedString("owners", comment: "feature role") + case .admin: NSLocalizedString("admins", comment: "feature role") + default: NSLocalizedString("all members", comment: "feature role") + } } public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String { @@ -3751,6 +3862,7 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable { public struct ChatItemInfo: Decodable { public var itemVersions: [ChatItemVersion] public var memberDeliveryStatuses: [MemberDeliveryStatus]? + public var forwardedFromChatItem: AChatItem? } public struct ChatItemVersion: Decodable { diff --git a/apps/ios/sounds/connecting_call.mp3 b/apps/ios/sounds/connecting_call.mp3 new file mode 100644 index 0000000000..fc425bab97 Binary files /dev/null and b/apps/ios/sounds/connecting_call.mp3 differ diff --git a/apps/ios/sounds/in_call.mp3 b/apps/ios/sounds/in_call.mp3 new file mode 100644 index 0000000000..1049be4462 Binary files /dev/null and b/apps/ios/sounds/in_call.mp3 differ diff --git a/apps/multiplatform/android/build.gradle.kts b/apps/multiplatform/android/build.gradle.kts index e89abc5d41..0a8b5ec91a 100644 --- a/apps/multiplatform/android/build.gradle.kts +++ b/apps/multiplatform/android/build.gradle.kts @@ -48,12 +48,7 @@ android { proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } kotlinOptions { - jvmTarget = "11" freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi" freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi" diff --git a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt index 7a1299c612..6ce582cad4 100644 --- a/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/multiplatform/android/src/main/java/chat/simplex/app/MainActivity.kt @@ -89,11 +89,12 @@ class MainActivity: FragmentActivity() { } override fun onBackPressed() { - if ( - onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack - || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above - || isTaskRoot // there are still other tasks after we reach the main (home) activity - ) { + val canFinishActivity = ( + onBackPressedDispatcher.hasEnabledCallbacks() // Has something to do in a backstack + || Build.VERSION.SDK_INT >= Build.VERSION_CODES.R // Android 11 or above + || isTaskRoot // there are still other tasks after we reach the main (home) activity + ) && SimplexApp.context.chatModel.sharedContent.value !is SharedContent.Forward + if (canFinishActivity) { // https://medium.com/mobile-app-development-publication/the-risk-of-android-strandhogg-security-issue-and-how-it-can-be-mitigated-80d2ddb4af06 super.onBackPressed() } @@ -104,9 +105,15 @@ class MainActivity: FragmentActivity() { AppLock.laFailed.value = true } if (!onBackPressedDispatcher.hasEnabledCallbacks()) { + val sharedContent = chatModel.sharedContent.value // Drop shared content - SimplexApp.context.chatModel.sharedContent.value = null - finish() + chatModel.sharedContent.value = null + if (sharedContent is SharedContent.Forward) { + chatModel.chatId.value = sharedContent.fromChatInfo.id + } + if (canFinishActivity) { + finish() + } } } } 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 f29aa39607..83105c678a 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 @@ -16,8 +16,7 @@ import androidx.work.* import chat.simplex.app.model.NtfManager import chat.simplex.app.model.NtfManager.AcceptCallAction import chat.simplex.app.views.call.CallActivity -import chat.simplex.common.helpers.APPLICATION_ID -import chat.simplex.common.helpers.requiresIgnoringBattery +import chat.simplex.common.helpers.* import chat.simplex.common.model.* import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatModel.updatingChatsMutex @@ -291,6 +290,10 @@ class SimplexApp: Application(), LifecycleEventObserver { activeCallDestroyWebView() } + override fun androidRestartNetworkObserver() { + NetworkObserver.shared.restartNetworkObserver() + } + @SuppressLint("SourceLockedOrientationActivity") @Composable override fun androidLockPortraitOrientation() { diff --git a/apps/multiplatform/build.gradle.kts b/apps/multiplatform/build.gradle.kts index 9c72456d98..a0cd35b3ca 100644 --- a/apps/multiplatform/build.gradle.kts +++ b/apps/multiplatform/build.gradle.kts @@ -83,6 +83,30 @@ plugins { id("org.jetbrains.kotlin.plugin.serialization") apply false } +// https://raymondctc.medium.com/configuring-your-sourcecompatibility-targetcompatibility-and-kotlinoptions-jvmtarget-all-at-once-66bf2198145f +val jvmVersion: Provider = providers.gradleProperty("kotlin.jvm.target") + +configure(subprojects) { + // Apply compileOptions to subprojects + plugins.withType().configureEach { + extensions.findByType()?.apply { + jvmVersion.map { JavaVersion.toVersion(it) }.orNull?.let { + compileOptions { + sourceCompatibility = it + targetCompatibility = it + } + } + } + } + + // Apply kotlinOptions.jvmTarget to subprojects + tasks.withType().configureEach { + kotlinOptions { + if (jvmVersion.isPresent) jvmTarget = jvmVersion.get() + } + } +} + tasks.register("clean", Delete::class) { delete(rootProject.buildDir) } diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index 4cc5ced0d1..42e4ac2591 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -12,9 +12,7 @@ version = extra["android.version_name"] as String kotlin { androidTarget() - jvm("desktop") { - jvmToolchain(11) - } + jvm("desktop") applyDefaultHierarchyTemplate() sourceSets { all { @@ -119,10 +117,6 @@ android { } testOptions.targetSdk = 33 lint.targetSdk = 33 - compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } val isAndroid = gradle.startParameter.taskNames.find { val lower = it.lowercase() lower.contains("release") || lower.startsWith("assemble") || lower.startsWith("install") diff --git a/apps/multiplatform/common/src/androidMain/AndroidManifest.xml b/apps/multiplatform/common/src/androidMain/AndroidManifest.xml index 74520465ae..eeb822503d 100644 --- a/apps/multiplatform/common/src/androidMain/AndroidManifest.xml +++ b/apps/multiplatform/common/src/androidMain/AndroidManifest.xml @@ -1,4 +1,5 @@ + diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt new file mode 100644 index 0000000000..825bc8b846 --- /dev/null +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/NetworkObserver.kt @@ -0,0 +1,104 @@ +package chat.simplex.common.helpers + +import android.net.* +import android.util.Log +import androidx.core.content.getSystemService +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.model.UserNetworkInfo +import chat.simplex.common.model.UserNetworkType +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.withBGApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay + +class NetworkObserver { + private var prevInfo: UserNetworkInfo? = null + + // When having both mobile and Wi-Fi networks enabled with Wi-Fi being active, then disabling Wi-Fi, network reports its offline (which is true) + // but since it will be online after switching to mobile, there is no need to inform backend about such temporary change. + // But if it will not be online after some seconds, report it and apply required measures + private var noNetworkJob = Job() as Job + private val networkCallback = object: ConnectivityManager.NetworkCallback() { + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) = networkCapabilitiesChanged(networkCapabilities) + override fun onLost(network: Network) = networkLost() + } + private val connectivityManager: ConnectivityManager? = androidAppContext.getSystemService() + + fun restartNetworkObserver() { + if (connectivityManager == null) { + Log.e(TAG, "Connectivity manager is unavailable, network observer is disabled") + val info = UserNetworkInfo( + networkType = UserNetworkType.OTHER, + online = true, + ) + prevInfo = info + setNetworkInfo(info) + return + } + try { + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (e: Exception) { + // do nothing + } + val initialCapabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork) + if (initialCapabilities != null) { + networkCapabilitiesChanged(initialCapabilities) + } else { + networkLost() + } + try { + connectivityManager.registerDefaultNetworkCallback(networkCallback) + } catch (e: Exception) { + Log.e(TAG, "Error registering network callback: ${e.stackTraceToString()}") + } + } + + private fun networkCapabilitiesChanged(capabilities: NetworkCapabilities) { + connectivityManager ?: return + val info = UserNetworkInfo( + networkType = networkTypeFromCapabilities(capabilities), + online = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED), + ) + if (prevInfo != info) { + prevInfo = info + setNetworkInfo(info) + } + } + + private fun networkLost() { + Log.d(TAG, "Network changed: lost") + val none = UserNetworkInfo(networkType = UserNetworkType.NONE, false) + prevInfo = none + setNetworkInfo(none) + } + + private fun setNetworkInfo(info: UserNetworkInfo) { + Log.d(TAG, "Network changed: $info") + noNetworkJob.cancel() + if (info.online) { + withBGApi { + if (controller.hasChatCtrl() && controller.apiSetNetworkInfo(info)) { + chatModel.networkInfo.value = info + } + } + } else { + noNetworkJob = withBGApi { + delay(3000) + if (controller.hasChatCtrl() && controller.apiSetNetworkInfo(info)) { + chatModel.networkInfo.value = info + } + } + } + } + + private fun networkTypeFromCapabilities(capabilities: NetworkCapabilities): UserNetworkType = when { + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> UserNetworkType.ETHERNET + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> UserNetworkType.WIFI + capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> UserNetworkType.CELLULAR + else -> UserNetworkType.OTHER + } + + companion object { + val shared = NetworkObserver() + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt index ff83c10df3..d567b3f7f9 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt @@ -2,17 +2,16 @@ package chat.simplex.common.helpers import android.media.* import android.net.Uri -import android.os.VibrationEffect -import android.os.Vibrator +import android.os.* import androidx.core.content.ContextCompat import chat.simplex.common.R -import chat.simplex.common.platform.SoundPlayerInterface -import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.withApi import kotlinx.coroutines.* object SoundPlayer: SoundPlayerInterface { private var player: MediaPlayer? = null - var playing = false + private var playing = false override fun start(scope: CoroutineScope, sound: Boolean) { player?.reset() @@ -32,7 +31,7 @@ object SoundPlayer: SoundPlayerInterface { scope.launch { while (playing) { if (sound) player?.start() - vibrator?.vibrate(effect) + vibrator?.vibrateApiVersionAware(effect) delay(3500) } } @@ -43,3 +42,82 @@ object SoundPlayer: SoundPlayerInterface { player?.stop() } } + +object CallSoundsPlayer: CallSoundsPlayerInterface { + private var player: MediaPlayer? = null + private var playingJob: Job? = null + + private fun start(soundPath: String, delay: Long, scope: CoroutineScope) { + playingJob?.cancel() + player?.reset() + player = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING) + .build() + ) + setDataSource(androidAppContext, Uri.parse(soundPath)) + prepare() + } + if (delay < 1000) { + player?.isLooping = true + player?.start() + return + } + playingJob = scope.launch { + while (isActive) { + player?.start() + delay(delay) + } + } + } + + override fun startConnectingCallSound(scope: CoroutineScope) { + // Taken from https://github.com/TelegramOrg/Telegram-Android + // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE + start("android.resource://" + androidAppContext.packageName + "/" + R.raw.connecting_call, 0, scope) + } + + override fun startInCallSound(scope: CoroutineScope) { + start("android.resource://" + androidAppContext.packageName + "/" + R.raw.in_call, 2000, scope) + } + + override fun vibrate(times: Int) { + val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java) + val effect = VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator?.vibrateApiVersionAware(effect) + repeat(times - 1) { + withApi { + delay(50) + vibrator?.vibrateApiVersionAware(effect) + } + } + } + + override fun stop() { + playingJob?.cancel() + player?.stop() + } +} + +private fun Vibrator.vibrateApiVersionAware(effect: VibrationEffect) { + if (Build.VERSION.SDK_INT >= 33) { + vibrate( + effect, + VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_ALARM) + .build() + ) + } else if (Build.VERSION.SDK_INT >= 29) { + vibrate( + effect, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + ) + } else { + vibrate(effect) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 5b0d3c778f..5cb10ff070 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -296,6 +296,7 @@ actual object AudioPlayer: AudioPlayerInterface { } actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer +actual typealias CallSoundsPlayer = chat.simplex.common.helpers.CallSoundsPlayer class CryptoMediaSource(val data: ByteArray) : MediaDataSource() { override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index b0c4beded0..af261e2a98 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -69,6 +69,8 @@ fun activeCallDestroyWebView() = withApi { @SuppressLint("SourceLockedOrientationActivity") @Composable actual fun ActiveCallView() { + val call = remember { chatModel.activeCall }.value + val scope = rememberCoroutineScope() val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val proximityLock = remember { val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager) @@ -78,6 +80,13 @@ actual fun ActiveCallView() { null } } + val wasConnected = rememberSaveable { mutableStateOf(false) } + LaunchedEffect(call) { + if (call?.callState == CallState.Connected && !wasConnected.value) { + CallSoundsPlayer.vibrate(2) + wasConnected.value = true + } + } DisposableEffect(Unit) { val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager var btDeviceCount = 0 @@ -107,6 +116,10 @@ actual fun ActiveCallView() { } am.registerAudioDeviceCallback(audioCallback, null) onDispose { + CallSoundsPlayer.stop() + if (wasConnected.value) { + CallSoundsPlayer.vibrate() + } dropAudioManagerOverrides() am.unregisterAudioDeviceCallback(audioCallback) if (proximityLock?.isHeld == true) { @@ -122,8 +135,6 @@ actual fun ActiveCallView() { if (proximityLock?.isHeld == false) proximityLock.acquire() } } - val scope = rememberCoroutineScope() - val call = chatModel.activeCall.value Box(Modifier.fillMaxSize()) { WebRTCView(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCView: $apiMsg") @@ -136,6 +147,9 @@ actual fun ActiveCallView() { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } + setCallSound(call.soundSpeaker, audioViaBluetooth) + CallSoundsPlayer.startConnectingCallSound(scope) + activeCallWaitDeliveryReceipt(scope) } is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) @@ -144,6 +158,7 @@ actual fun ActiveCallView() { is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) updateActiveCall(call) { it.copy(callState = CallState.Negotiated) } + CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) 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 28c00ec018..c606e9acb0 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 @@ -6,7 +6,6 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.platform.LocalContext -import chat.simplex.common.helpers.toUri import chat.simplex.common.model.CIFile import chat.simplex.common.platform.* import chat.simplex.common.views.helpers.ModalManager @@ -15,7 +14,6 @@ import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest -import java.net.URI @Composable actual fun SimpleAndAnimatedImageView( @@ -43,6 +41,7 @@ actual fun SimpleAndAnimatedImageView( } private val imageLoader = ImageLoader.Builder(androidAppContext) + .networkObserverEnabled(false) .components { if (SDK_INT >= 28) { add(ImageDecoderDecoder.Factory()) diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt index d4efdc3e59..dad8872012 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/chat/item/ImageFullScreenView.android.kt @@ -3,7 +3,7 @@ package chat.simplex.common.views.chat.item import android.os.Build import android.view.View import androidx.compose.foundation.Image -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter @@ -11,8 +11,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.core.view.isVisible -import chat.simplex.common.helpers.toUri import chat.simplex.common.platform.VideoPlayer +import chat.simplex.common.platform.androidAppContext import chat.simplex.res.MR import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -23,21 +23,11 @@ import coil.size.Size import com.google.android.exoplayer2.ui.AspectRatioFrameLayout import com.google.android.exoplayer2.ui.StyledPlayerView import dev.icerock.moko.resources.compose.stringResource -import java.net.URI @Composable actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap: ImageBitmap) { - // I'm making a new instance of imageLoader here because if I use one instance in multiple places + // I'm using a new private instance of imageLoader here because if I use one instance in multiple places // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want - val imageLoader = ImageLoader.Builder(LocalContext.current) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .build() Image( rememberAsyncImagePainter( ImageRequest.Builder(LocalContext.current).data(data = data).size(Size.ORIGINAL).build(), @@ -73,3 +63,14 @@ actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: ( modifier ) } + +private val imageLoader = ImageLoader.Builder(androidAppContext) + .networkObserverEnabled(false) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + .build() diff --git a/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 b/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 new file mode 100644 index 0000000000..fc425bab97 Binary files /dev/null and b/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 differ diff --git a/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 b/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 new file mode 100644 index 0000000000..1049be4462 Binary files /dev/null and b/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 differ 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 a8a5797d71..ca9056a058 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 @@ -111,13 +111,14 @@ object ChatModel { var draft = mutableStateOf(null as ComposeState?) var draftChatId = mutableStateOf(null as String?) - // working with external intents + // working with external intents or internal forwarding of chat items val sharedContent = mutableStateOf(null as SharedContent?) val filesToDelete = mutableSetOf() val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } val clipboardHasText = mutableStateOf(false) + val networkInfo = mutableStateOf(UserNetworkInfo(networkType = UserNetworkType.OTHER, online = true)) val updatingChatsMutex: Mutex = Mutex() val changingActiveUserMutex: Mutex = Mutex() @@ -804,6 +805,24 @@ data class Chat( val id: String get() = chatInfo.id + fun groupFeatureEnabled(feature: GroupFeature): Boolean = + if (chatInfo is ChatInfo.Group) { + val groupInfo = chatInfo.groupInfo + val p = groupInfo.fullGroupPreferences + when (feature) { + GroupFeature.TimedMessages -> p.timedMessages.on + GroupFeature.DirectMessages -> p.directMessages.on(groupInfo.membership) + GroupFeature.FullDelete -> p.fullDelete.on + GroupFeature.Reactions -> p.reactions.on + GroupFeature.Voice -> p.voice.on(groupInfo.membership) + GroupFeature.Files -> p.files.on(groupInfo.membership) + GroupFeature.SimplexLinks -> p.simplexLinks.on(groupInfo.membership) + GroupFeature.History -> p.history.on + } + } else { + true + } + @Serializable data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false) @@ -1239,7 +1258,7 @@ data class GroupInfo ( ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on ChatFeature.FullDelete -> fullGroupPreferences.fullDelete.on ChatFeature.Reactions -> fullGroupPreferences.reactions.on - ChatFeature.Voice -> fullGroupPreferences.voice.on + ChatFeature.Voice -> fullGroupPreferences.voice.on(membership) ChatFeature.Calls -> false } override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null } @@ -1734,7 +1753,7 @@ data class ChatItem ( val allowAddReaction: Boolean get() = meta.itemDeleted == null && !isLiveDummy && (reactions.count { it.userReacted } < 3) - private val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID + val isLiveDummy: Boolean get() = meta.itemId == TEMP_LIVE_CHAT_ITEM_ID val encryptedFile: Boolean? = if (file?.fileSource == null) null else file.fileSource.cryptoArgs != null @@ -1883,14 +1902,16 @@ data class ChatItem ( status: CIStatus = CIStatus.SndNew(), quotedItem: CIQuote? = null, file: CIFile? = null, + itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, + deletable: Boolean = true, editable: Boolean = true ) = ChatItem( chatDir = dir, - meta = CIMeta.getSample(id, ts, text, status, itemDeleted, itemEdited, itemTimed, editable), + meta = CIMeta.getSample(id, ts, text, status, itemForwarded, itemDeleted, itemEdited, itemTimed, deletable, editable), content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)), quotedItem = quotedItem, reactions = listOf(), @@ -1974,10 +1995,12 @@ data class ChatItem ( itemStatus = CIStatus.RcvRead(), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), + itemForwarded = null, itemDeleted = null, itemEdited = false, itemTimed = null, itemLive = false, + deletable = false, editable = false ), content = CIContent.RcvDeleted(deleteMode = CIDeleteMode.cidmBroadcast), @@ -1995,10 +2018,12 @@ data class ChatItem ( itemStatus = CIStatus.RcvRead(), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), + itemForwarded = null, itemDeleted = null, itemEdited = false, itemTimed = null, itemLive = true, + deletable = false, editable = false ), content = CIContent.SndMsgContent(MsgContent.MCText("")), @@ -2095,10 +2120,12 @@ data class CIMeta ( val itemStatus: CIStatus, val createdAt: Instant, val updatedAt: Instant, + val itemForwarded: CIForwardedFrom?, val itemDeleted: CIDeleted?, val itemEdited: Boolean, val itemTimed: CITimed?, val itemLive: Boolean?, + val deletable: Boolean, val editable: Boolean ) { val timestampText: String get() = getTimestampText(itemTs) @@ -2118,7 +2145,8 @@ data class CIMeta ( companion object { fun getSample( id: Long, ts: Instant, text: String, status: CIStatus = CIStatus.SndNew(), - itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, itemTimed: CITimed? = null, itemLive: Boolean = false, editable: Boolean = true + itemForwarded: CIForwardedFrom? = null, itemDeleted: CIDeleted? = null, itemEdited: Boolean = false, + itemTimed: CITimed? = null, itemLive: Boolean = false, deletable: Boolean = true, editable: Boolean = true ): CIMeta = CIMeta( itemId = id, @@ -2127,10 +2155,12 @@ data class CIMeta ( itemStatus = status, createdAt = ts, updatedAt = ts, + itemForwarded = itemForwarded, itemDeleted = itemDeleted, itemEdited = itemEdited, itemTimed = itemTimed, itemLive = itemLive, + deletable = deletable, editable = editable ) @@ -2143,10 +2173,12 @@ data class CIMeta ( itemStatus = CIStatus.SndNew(), createdAt = Clock.System.now(), updatedAt = Clock.System.now(), + itemForwarded = null, itemDeleted = null, itemEdited = false, itemTimed = null, itemLive = false, + deletable = false, editable = false ) } @@ -2257,6 +2289,37 @@ sealed class CIDeleted { @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted() } +@Serializable +enum class MsgDirection { + @SerialName("rcv") Rcv, + @SerialName("snd") Snd; +} + +@Serializable +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() + + open val chatName: String + get() = when (this) { + Unknown -> "" + is Contact -> chatName + is Group -> chatName + } + + 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) + } +} + @Serializable enum class CIDeleteMode(val deleteMode: String) { @SerialName("internal") cidmInternal("internal"), @@ -2292,8 +2355,8 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("sndChatFeature") class SndChatFeature(val feature: ChatFeature, val enabled: FeatureEnabled, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatPreference") class RcvChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndChatPreference") class SndChatPreference(val feature: ChatFeature, val allowed: FeatureAllowed, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } - @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupFeature") class RcvGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupFeature") class SndGroupFeature(val groupFeature: GroupFeature, val preference: GroupPreference, val param: Int? = null, val memberRole_: GroupMemberRole?): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvChatFeatureRejected") class RcvChatFeatureRejected(val feature: ChatFeature): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupFeatureRejected") class RcvGroupFeatureRejected(val groupFeature: GroupFeature): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndModerated") object SndModerated: CIContent() { override val msgContent: MsgContent? get() = null } @@ -2325,8 +2388,8 @@ sealed class CIContent: ItemContent { is SndChatFeature -> featureText(feature, enabled.text, param) is RcvChatPreference -> preferenceText(feature, allowed, param) is SndChatPreference -> preferenceText(feature, allowed, param) - is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param) - is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param) + is RcvGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_) + is SndGroupFeature -> featureText(groupFeature, preference.enable.text, param, memberRole_) is RcvChatFeatureRejected -> "${feature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}" is RcvGroupFeatureRejected -> "${groupFeature.text}: ${generalGetString(MR.strings.feature_received_prohibited)}" is SndModerated -> generalGetString(MR.strings.moderated_description) @@ -2363,11 +2426,23 @@ sealed class CIContent: ItemContent { private val e2eeInfoNoPQStr: String = generalGetString(MR.strings.e2ee_info_no_pq_short) - fun featureText(feature: Feature, enabled: String, param: Int?): String = - if (feature.hasParam) { + fun featureText(feature: Feature, enabled: String, param: Int?, role: GroupMemberRole? = null): String = + (if (feature.hasParam) { "${feature.text}: ${timeText(param)}" } else { "${feature.text}: $enabled" + }) + ( + if (feature.hasRole && role != null) + " (${roleText(role)})" + else + "" + ) + + private fun roleText(role: GroupMemberRole?): String = + when (role) { + GroupMemberRole.Owner -> generalGetString(MR.strings.feature_roles_owners) + GroupMemberRole.Admin -> generalGetString(MR.strings.feature_roles_admins) + else -> generalGetString(MR.strings.feature_roles_all_members) } fun preferenceText(feature: Feature, allowed: FeatureAllowed, param: Int?): String = when { @@ -3251,7 +3326,8 @@ sealed class ChatItemTTL: Comparable { @Serializable class ChatItemInfo( val itemVersions: List, - val memberDeliveryStatuses: List? + val memberDeliveryStatuses: List?, + val forwardedFromChatItem: AChatItem? ) @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 3811bca170..90d6995504 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 @@ -20,6 +20,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -350,11 +351,15 @@ object ChatController { var ctrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } + val messagesChannel: Channel = Channel() + val chatModel = ChatModel private var receiverStarted = false var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() private set + fun hasChatCtrl() = ctrl != -1L && ctrl != null + private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock { val userId = chatModel.currentUser.value?.userId if (userId == null) { @@ -481,6 +486,7 @@ object ChatController { if (msg != null) { val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { processReceivedMsg(msg) + messagesChannel.trySend(msg) } if (finishedWithoutTimeout == null) { Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) @@ -732,12 +738,16 @@ object ChatController { suspend fun apiSendMessage(rh: Long?, type: ChatType, id: Long, file: CryptoFile? = null, quotedItemId: Long? = null, mc: MsgContent, live: Boolean = false, ttl: Int? = null): AChatItem? { val cmd = CC.ApiSendMessage(type, id, file, quotedItemId, mc, live, ttl) + return processSendMessageCmd(rh, cmd) + } + + private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): AChatItem? { val r = sendCmd(rh, cmd) return when (r) { is CR.NewChatItem -> r.chatItem else -> { if (!(networkErrorAlert(r))) { - apiErrorAlert("apiSendMessage", generalGetString(MR.strings.error_sending_message), r) + apiErrorAlert("processSendMessageCmd", generalGetString(MR.strings.error_sending_message), r) } null } @@ -765,6 +775,13 @@ object ChatController { } } + suspend fun apiForwardChatItem(rh: Long?, toChatType: ChatType, toChatId: Long, fromChatType: ChatType, fromChatId: Long, itemId: Long): ChatItem? { + val cmd = CC.ApiForwardChatItem(toChatType, toChatId, fromChatType, fromChatId, itemId) + return processSendMessageCmd(rh, cmd)?.chatItem + } + + + suspend fun apiUpdateChatItem(rh: Long?, type: ChatType, id: Long, itemId: Long, mc: MsgContent, live: Boolean = false): AChatItem? { val r = sendCmd(rh, CC.ApiUpdateChatItem(type, id, itemId, mc, live)) if (r is CR.ChatItemUpdated) return r.chatItem @@ -875,6 +892,9 @@ object ChatController { } } + suspend fun apiSetNetworkInfo(networkInfo: UserNetworkInfo): Boolean = + sendCommandOkResp(null, CC.APISetNetworkInfo(networkInfo)) + suspend fun apiSetMemberSettings(rh: Long?, groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean = sendCommandOkResp(rh, CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings)) @@ -1962,7 +1982,6 @@ object ChatController { } is CR.SndFileCompleteXFTP -> { chatItemSimpleUpdate(rhId, r.user, r.chatItem) - cleanupFile(r.chatItem) } is CR.SndFileError -> { if (r.chatItem_ != null) { @@ -2119,7 +2138,7 @@ object ChatController { if (active(r.user)) { chatModel.updateContact(rhId, r.contact) } - is CR.ChatCmdError -> when { + is CR.ChatRespError -> when { r.chatError is ChatError.ChatErrorAgent && r.chatError.agentError is AgentErrorType.CRITICAL -> { chatModel.processedCriticalError.newError(r.chatError.agentError, r.chatError.agentError.offerRestart) } @@ -2387,6 +2406,7 @@ sealed class CC { class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() + class ApiForwardChatItem(val toChatType: ChatType, val toChatId: Long, val fromChatType: ChatType, val fromChatId: Long, val itemId: Long): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiJoinGroup(val groupId: Long): CC() @@ -2409,6 +2429,7 @@ sealed class CC { class APIGetChatItemTTL(val userId: Long): CC() class APISetNetworkConfig(val networkConfig: NetCfg): CC() class APIGetNetworkConfig: CC() + class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC() class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC() class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC() class APIContactInfo(val contactId: Long): CC() @@ -2529,6 +2550,7 @@ sealed class CC { is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" + is ApiForwardChatItem -> "/_forward ${chatRef(toChatType, toChatId)} ${chatRef(fromChatType, fromChatId)} $itemId" is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiJoinGroup -> "/_join #$groupId" @@ -2551,6 +2573,7 @@ sealed class CC { is APIGetChatItemTTL -> "/_ttl $userId" is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}" is APIGetNetworkConfig -> "/network" + is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}" is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}" is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}" is APIContactInfo -> "/_info @$contactId" @@ -2666,6 +2689,7 @@ sealed class CC { is ApiDeleteChatItem -> "apiDeleteChatItem" is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem" is ApiChatItemReaction -> "apiChatItemReaction" + is ApiForwardChatItem -> "apiForwardChatItem" is ApiNewGroup -> "apiNewGroup" is ApiAddMember -> "apiAddMember" is ApiJoinGroup -> "apiJoinGroup" @@ -2688,6 +2712,7 @@ sealed class CC { is APIGetChatItemTTL -> "apiGetChatItemTTL" is APISetNetworkConfig -> "apiSetNetworkConfig" is APIGetNetworkConfig -> "apiGetNetworkConfig" + is APISetNetworkInfo -> "apiSetNetworkInfo" is APISetChatSettings -> "apiSetChatSettings" is ApiSetMemberSettings -> "apiSetMemberSettings" is APIContactInfo -> "apiContactInfo" @@ -3035,7 +3060,7 @@ data class NetCfg( sessionMode = TransportSessionMode.User, tcpConnectTimeout = 20_000_000, tcpTimeout = 15_000_000, - tcpTimeoutPerKb = 45_000, + tcpTimeoutPerKb = 10_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 @@ -3049,7 +3074,7 @@ data class NetCfg( sessionMode = TransportSessionMode.User, tcpConnectTimeout = 30_000_000, tcpTimeout = 20_000_000, - tcpTimeoutPerKb = 60_000, + tcpTimeoutPerKb = 15_000, tcpKeepAlive = KeepAliveOpts.defaults, smpPingInterval = 1200_000_000, smpPingCount = 3 @@ -3418,6 +3443,7 @@ interface Feature { @Composable fun iconFilled(): Painter val hasParam: Boolean + val hasRole: Boolean } @Serializable @@ -3437,6 +3463,7 @@ enum class ChatFeature: Feature { TimedMessages -> true else -> false } + override val hasRole: Boolean = false override val text: String get() = when(this) { @@ -3537,6 +3564,7 @@ enum class GroupFeature: Feature { @SerialName("reactions") Reactions, @SerialName("voice") Voice, @SerialName("files") Files, + @SerialName("simplexLinks") SimplexLinks, @SerialName("history") History; override val hasParam: Boolean get() = when(this) { @@ -3544,6 +3572,18 @@ enum class GroupFeature: Feature { else -> false } + override val hasRole: Boolean + get() = when (this) { + TimedMessages -> false + DirectMessages -> true + FullDelete -> false + Reactions -> false + Voice -> true + Files -> true + SimplexLinks -> true + History -> false + } + override val text: String get() = when(this) { TimedMessages -> generalGetString(MR.strings.timed_messages) @@ -3552,6 +3592,7 @@ enum class GroupFeature: Feature { Reactions -> generalGetString(MR.strings.message_reactions) Voice -> generalGetString(MR.strings.voice_messages) Files -> generalGetString(MR.strings.files_and_media) + SimplexLinks -> generalGetString(MR.strings.simplex_links) History -> generalGetString(MR.strings.recent_history) } @@ -3563,6 +3604,7 @@ enum class GroupFeature: Feature { Reactions -> painterResource(MR.images.ic_add_reaction) Voice -> painterResource(MR.images.ic_keyboard_voice) Files -> painterResource(MR.images.ic_draft) + SimplexLinks -> painterResource(MR.images.ic_link) History -> painterResource(MR.images.ic_schedule) } @@ -3574,6 +3616,7 @@ enum class GroupFeature: Feature { Reactions -> painterResource(MR.images.ic_add_reaction_filled) Voice -> painterResource(MR.images.ic_keyboard_voice_filled) Files -> painterResource(MR.images.ic_draft_filled) + SimplexLinks -> painterResource(MR.images.ic_link) History -> painterResource(MR.images.ic_schedule_filled) } @@ -3604,6 +3647,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_files) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_files) } + SimplexLinks -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.allow_to_send_simplex_links) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.prohibit_sending_simplex_links) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.enable_sending_recent_history) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.disable_sending_recent_history) @@ -3635,6 +3682,10 @@ enum class GroupFeature: Feature { GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_files) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.files_are_prohibited_in_group) } + SimplexLinks -> when(enabled) { + GroupFeatureEnabled.ON -> generalGetString(MR.strings.group_members_can_send_simplex_links) + GroupFeatureEnabled.OFF -> generalGetString(MR.strings.simplex_links_are_prohibited_in_group) + } History -> when(enabled) { GroupFeatureEnabled.ON -> generalGetString(MR.strings.recent_history_is_sent_to_new_members) GroupFeatureEnabled.OFF -> generalGetString(MR.strings.recent_history_is_not_sent_to_new_members) @@ -3748,11 +3799,12 @@ enum class FeatureAllowed { @Serializable data class FullGroupPreferences( val timedMessages: TimedMessagesGroupPreference, - val directMessages: GroupPreference, + val directMessages: RoleGroupPreference, val fullDelete: GroupPreference, val reactions: GroupPreference, - val voice: GroupPreference, - val files: GroupPreference, + val voice: RoleGroupPreference, + val files: RoleGroupPreference, + val simplexLinks: RoleGroupPreference, val history: GroupPreference, ) { fun toGroupPreferences(): GroupPreferences = @@ -3763,17 +3815,19 @@ data class FullGroupPreferences( reactions = reactions, voice = voice, files = files, + simplexLinks = simplexLinks, history = history ) companion object { val sampleData = FullGroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), - directMessages = GroupPreference(GroupFeatureEnabled.OFF), + directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), reactions = GroupPreference(GroupFeatureEnabled.ON), - voice = GroupPreference(GroupFeatureEnabled.ON), - files = GroupPreference(GroupFeatureEnabled.ON), + voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -3782,21 +3836,23 @@ data class FullGroupPreferences( @Serializable data class GroupPreferences( val timedMessages: TimedMessagesGroupPreference? = null, - val directMessages: GroupPreference? = null, + val directMessages: RoleGroupPreference? = null, val fullDelete: GroupPreference? = null, val reactions: GroupPreference? = null, - val voice: GroupPreference? = null, - val files: GroupPreference? = null, + val voice: RoleGroupPreference? = null, + val files: RoleGroupPreference? = null, + val simplexLinks: RoleGroupPreference? = null, val history: GroupPreference? = null, ) { companion object { val sampleData = GroupPreferences( timedMessages = TimedMessagesGroupPreference(GroupFeatureEnabled.OFF), - directMessages = GroupPreference(GroupFeatureEnabled.OFF), + directMessages = RoleGroupPreference(GroupFeatureEnabled.OFF, role = null), fullDelete = GroupPreference(GroupFeatureEnabled.OFF), reactions = GroupPreference(GroupFeatureEnabled.ON), - voice = GroupPreference(GroupFeatureEnabled.ON), - files = GroupPreference(GroupFeatureEnabled.ON), + voice = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + files = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), + simplexLinks = RoleGroupPreference(GroupFeatureEnabled.ON, role = null), history = GroupPreference(GroupFeatureEnabled.ON), ) } @@ -3807,6 +3863,26 @@ data class GroupPreference( val enable: GroupFeatureEnabled ) { val on: Boolean get() = enable == GroupFeatureEnabled.ON + + fun enabled(role: GroupMemberRole?, m: GroupMember?): GroupFeatureEnabled = + when (enable) { + GroupFeatureEnabled.OFF -> GroupFeatureEnabled.OFF + GroupFeatureEnabled.ON -> + if (role != null && m != null) { + if (m.memberRole >= role) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF + } else { + GroupFeatureEnabled.ON + } + } +} + +@Serializable +data class RoleGroupPreference( + val enable: GroupFeatureEnabled, + val role: GroupMemberRole? = null, +) { + fun on(m: GroupMember): Boolean = + enable == GroupFeatureEnabled.ON && m.memberRole >= (role ?: GroupMemberRole.Observer) } @Serializable @@ -4760,6 +4836,7 @@ sealed class ChatErrorType { is FallbackToSMPProhibited -> "fallbackToSMPProhibited" is InlineFileProhibited -> "inlineFileProhibited" is InvalidQuote -> "invalidQuote" + is InvalidForward -> "invalidForward" is InvalidChatItemUpdate -> "invalidChatItemUpdate" is InvalidChatItemDelete -> "invalidChatItemDelete" is HasCurrentCall -> "hasCurrentCall" @@ -4838,6 +4915,7 @@ sealed class ChatErrorType { @Serializable @SerialName("fallbackToSMPProhibited") class FallbackToSMPProhibited(val fileId: Long): ChatErrorType() @Serializable @SerialName("inlineFileProhibited") class InlineFileProhibited(val fileId: Long): ChatErrorType() @Serializable @SerialName("invalidQuote") object InvalidQuote: ChatErrorType() + @Serializable @SerialName("invalidForward") object InvalidForward: ChatErrorType() @Serializable @SerialName("invalidChatItemUpdate") object InvalidChatItemUpdate: ChatErrorType() @Serializable @SerialName("invalidChatItemDelete") object InvalidChatItemDelete: ChatErrorType() @Serializable @SerialName("hasCurrentCall") object HasCurrentCall: ChatErrorType() @@ -5527,3 +5605,26 @@ enum class AppSettingsLockScreenCalls { } } } + +@Serializable +data class UserNetworkInfo( + val networkType: UserNetworkType, + val online: Boolean, +) + +enum class UserNetworkType { + @SerialName("none") NONE, + @SerialName("cellular") CELLULAR, + @SerialName("wifi") WIFI, + @SerialName("ethernet") ETHERNET, + @SerialName("other") OTHER; + + val text: String + get() = when (this) { + NONE -> generalGetString(MR.strings.network_type_no_network_connection) + CELLULAR -> generalGetString(MR.strings.network_type_cellular) + WIFI -> generalGetString(MR.strings.network_type_network_wifi) + ETHERNET -> generalGetString(MR.strings.network_type_ethernet) + OTHER -> generalGetString(MR.strings.network_type_other) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt index f1a6d35e45..00370f5231 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/Core.kt @@ -9,7 +9,6 @@ import chat.simplex.common.views.helpers.DatabaseUtils.randomDatabasePassword import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.res.MR import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString import java.io.File import java.nio.ByteBuffer @@ -88,6 +87,7 @@ suspend fun initChatController(useKey: String? = null, confirmMigrations: Migrat Log.d(TAG, "Unable to migrate successfully: $res") return } + platform.androidRestartNetworkObserver() controller.apiSetTempFolder(coreTmpDir.absolutePath) controller.apiSetFilesFolder(appFilesDir.absolutePath) if (appPlatform.isDesktop) { 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 5ca6dbdeb3..f61c5bc83e 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 @@ -23,6 +23,7 @@ interface PlatformInterface { fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {} fun androidPictureInPictureAllowed(): Boolean = true fun androidCallEnded() {} + fun androidRestartNetworkObserver() {} @Composable fun androidLockPortraitOrientation() {} suspend fun androidAskToAllowBackgroundCalls(): Boolean = true @Composable fun desktopScrollBarComponents(): Triple, Modifier, MutableState> = remember { Triple(Animatable(0f), Modifier, mutableStateOf(Job())) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index 0e0f769487..1e902b5d88 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -39,4 +39,13 @@ interface SoundPlayerInterface { fun stop() } +interface CallSoundsPlayerInterface { + fun startConnectingCallSound(scope: CoroutineScope) + fun startInCallSound(scope: CoroutineScope) + fun stop() + fun vibrate(times: Int = 1) +} + expect object SoundPlayer: SoundPlayerInterface + +expect object CallSoundsPlayer: CallSoundsPlayerInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt index 1cd85ae7f9..1f49a98728 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/TerminalView.kt @@ -85,6 +85,7 @@ fun TerminalLayout( isDirectChat = false, liveMessageAlertShown = SharedPreference(get = { false }, set = {}), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = false, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt index 2f4ffbb836..e4a6691d49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -1,6 +1,26 @@ package chat.simplex.common.views.call import androidx.compose.runtime.Composable +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import kotlinx.coroutines.* @Composable expect fun ActiveCallView() + +fun activeCallWaitDeliveryReceipt(scope: CoroutineScope) = scope.launch(Dispatchers.Default) { + for (apiResp in controller.messagesChannel) { + val call = chatModel.activeCall.value + if (call == null || call.callState > CallState.InvitationSent) break + val msg = apiResp.resp + if (apiResp.remoteHostId == call.remoteHostId && + msg is CR.ChatItemStatusUpdated && + msg.chatItem.chatInfo.id == call.contact.id && + msg.chatItem.chatItem.content is CIContent.SndCall && + msg.chatItem.chatItem.meta.itemStatus is CIStatus.SndRcvd) { + CallSoundsPlayer.startInCallSound(scope) + break + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt index 754878e9fb..4991cf13bc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/WebRTC.kt @@ -187,10 +187,11 @@ data class ConnectionState( ) // the servers are expected in this format: -// stun:stun.simplex.im:443?transport=tcp -// turn:private:yleob6AVkiNI87hpR94Z@turn.simplex.im:443?transport=tcp +// stuns:stun.simplex.im:443?transport=tcp +// turns:private2:Hxuq2QxUjnhj96Zq2r4HjqHRj@turn.simplex.im:443?transport=tcp fun parseRTCIceServer(str: String): RTCIceServer? { var s = replaceScheme(str, "stun:") + s = replaceScheme(s, "stuns:") s = replaceScheme(s, "turn:") s = replaceScheme(s, "turns:") val u = runCatching { URI(s) }.getOrNull() @@ -198,7 +199,7 @@ fun parseRTCIceServer(str: String): RTCIceServer? { val scheme = u.scheme val host = u.host val port = u.port - if (u.path == "" && (scheme == "stun" || scheme == "turn" || scheme == "turns")) { + if (u.path == "" && (scheme == "stun" || scheme == "stuns" || scheme == "turn" || scheme == "turns")) { val userInfo = u.userInfo?.split(":") val query = if (u.query == null || u.query == "") "" else "?${u.query}" return RTCIceServer( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt index 14bb3543c3..df8e535f82 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemInfoView.kt @@ -13,9 +13,8 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.* import androidx.compose.ui.text.AnnotatedString import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource @@ -29,6 +28,7 @@ import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.helpers.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chatlist.* import chat.simplex.res.MR import dev.icerock.moko.resources.ImageResource @@ -36,10 +36,11 @@ sealed class CIInfoTab { class Delivery(val memberDeliveryStatuses: List): CIInfoTab() object History: CIInfoTab() class Quote(val quotedItem: CIQuote): CIInfoTab() + class Forwarded(val forwardedFromChatItem: AChatItem): CIInfoTab() } @Composable -fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { +fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools: Boolean) { val sent = ci.chatDir.sent val appColors = CurrentColors.collectAsState().value.appColors val uriHandler = LocalUriHandler.current @@ -151,6 +152,70 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d } } + val local = when (ci.chatDir) { + is CIDirection.LocalSnd -> true + is CIDirection.LocalRcv -> true + else -> false + } + + @Composable + fun ForwardedFromSender(forwardedFromItem: AChatItem) { + @Composable + fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) { + Text( + text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body1, + fontStyle = fontStyle, + color = color, + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + ChatInfoImage(forwardedFromItem.chatInfo, size = 57.dp) + Column( + modifier = Modifier + .padding(start = 15.dp) + .weight(1F) + ) { + if (forwardedFromItem.chatItem.chatDir.sent) { + ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic) + Spacer(Modifier.height(7.dp)) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + } else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) { + ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName) + Spacer(Modifier.height(7.dp)) + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary) + } else { + ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground) + } + } + } + } + + @Composable + fun ForwardedFromView(forwardedFromItem: AChatItem) { + Column { + SectionItemView( + click = { + withBGApi { + openChat(chatRh, forwardedFromItem.chatInfo, chatModel) + ModalManager.end.closeModals() + } + }, + padding = PaddingValues(start = 17.dp, end = DEFAULT_PADDING) + ) { + ForwardedFromSender(forwardedFromItem) + } + + if (!local) { + Divider(Modifier.padding(start = DEFAULT_PADDING_HALF, top = 41.dp, end = DEFAULT_PADDING_HALF, bottom = DEFAULT_PADDING_HALF)) + Text(stringResource(MR.strings.recipients_can_not_see_who_message_from), Modifier.padding(horizontal = DEFAULT_PADDING), fontSize = 12.sp, color = MaterialTheme.colors.secondary) + } + } + } + @Composable fun Details() { AppBarTitle(stringResource(if (ci.localNote) MR.strings.saved_message_title else if (sent) MR.strings.sent_message else MR.strings.received_message)) @@ -188,7 +253,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) val versions = ciInfo.itemVersions if (versions.isNotEmpty()) { SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -213,7 +278,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { Text(stringResource(MR.strings.in_reply_to), style = MaterialTheme.typography.h2, modifier = Modifier.padding(bottom = DEFAULT_PADDING)) QuotedMsgView(qi) @@ -222,6 +287,22 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d } } + @Composable + fun ForwardedFromTab(forwardedFromItem: AChatItem) { + // LALAL SCROLLBAR DOESN'T WORK + ColumnWithScrollBar(Modifier.fillMaxWidth()) { + Details() + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) + SectionView { + Text(stringResource(if (local) MR.strings.saved_from_chat_item_info_title else MR.strings.forwarded_from_chat_item_info_title), + style = MaterialTheme.typography.h2, + modifier = Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)) + ForwardedFromView(forwardedFromItem) + } + SectionBottomSpacer() + } + } + @Composable fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus) { SectionItemView( @@ -271,7 +352,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d // LALAL SCROLLBAR DOESN'T WORK ColumnWithScrollBar(Modifier.fillMaxWidth()) { Details() - SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = false) + SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true) val mss = membersStatuses(chatModel, memberDeliveryStatuses) if (mss.isNotEmpty()) { SectionView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) { @@ -297,6 +378,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Delivery -> stringResource(MR.strings.delivery) is CIInfoTab.History -> stringResource(MR.strings.edit_history) is CIInfoTab.Quote -> stringResource(MR.strings.in_reply_to) + is CIInfoTab.Forwarded -> stringResource(if (local) MR.strings.saved_chat_item_info_tab else MR.strings.forwarded_chat_item_info_tab) } } @@ -305,6 +387,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Delivery -> MR.images.ic_double_check is CIInfoTab.History -> MR.images.ic_history is CIInfoTab.Quote -> MR.images.ic_reply + is CIInfoTab.Forwarded -> MR.images.ic_forward } } @@ -316,6 +399,9 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d if (ci.quotedItem != null) { numTabs += 1 } + if (ciInfo.forwardedFromChatItem != null) { + numTabs += 1 + } return numTabs } @@ -326,11 +412,6 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d .fillMaxHeight(), verticalArrangement = Arrangement.SpaceBetween ) { - LaunchedEffect(ciInfo) { - if (ciInfo.memberDeliveryStatuses != null) { - selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) - } - } Column(Modifier.weight(1f)) { when (val sel = selection.value) { is CIInfoTab.Delivery -> { @@ -344,6 +425,10 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d is CIInfoTab.Quote -> { QuoteTab(sel.quotedItem) } + + is CIInfoTab.Forwarded -> { + ForwardedFromTab(sel.forwardedFromChatItem) + } } } val availableTabs = mutableListOf() @@ -354,6 +439,19 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d if (ci.quotedItem != null) { availableTabs.add(CIInfoTab.Quote(ci.quotedItem)) } + if (ciInfo.forwardedFromChatItem != null) { + availableTabs.add(CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem)) + } + if (availableTabs.none { it.javaClass == selection.value.javaClass }) { + selection.value = availableTabs.first() + } + LaunchedEffect(ciInfo) { + if (ciInfo.forwardedFromChatItem != null && selection.value is CIInfoTab.Forwarded) { + selection.value = CIInfoTab.Forwarded(ciInfo.forwardedFromChatItem) + } else if (ciInfo.memberDeliveryStatuses != null) { + selection.value = CIInfoTab.Delivery(ciInfo.memberDeliveryStatuses) + } + } TabRow( selectedTabIndex = availableTabs.indexOfFirst { it::class == selection.value::class }, backgroundColor = Color.Transparent, 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 c70511f847..0c9a973a69 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 @@ -52,9 +52,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: val user = chatModel.currentUser.value val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val composeState = rememberSaveable(saver = ComposeState.saver()) { + val draft = chatModel.draft.value + val sharedContent = chatModel.sharedContent.value mutableStateOf( - if (chatModel.draftChatId.value == chatId && chatModel.draft.value != null) { - chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews) + if (chatModel.draftChatId.value == chatId && draft != null && (sharedContent !is SharedContent.Forward || sharedContent.fromChatInfo.id == chatId)) { + draft } else { ComposeState(useLinkPreviews = useLinkPreviews) } @@ -408,7 +410,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId: clipboard.shareText(itemInfoShareText(chatModel, cItem, ciInfo, chatModel.controller.appPrefs.developerTools.get())) } }) { close -> - ChatItemInfoView(chatModel, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) + ChatItemInfoView(chatRh, cItem, ciInfo, devTools = chatModel.controller.appPrefs.developerTools.get()) KeyChangeEffect(chatModel.chatId.value) { close() } @@ -956,13 +958,13 @@ fun BoxWithConstraintsScope.ChatItemsList( tryOrShowError("${cItem.id}ChatItem", error = { CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) }) { - ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) + ChatItemView(chat.remoteHostId, chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools) } } @Composable fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) { - val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null + val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null if (chat.chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { val member = cItem.chatDir.groupMember @@ -1090,9 +1092,9 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems: .collect { try { if (listState.firstVisibleItemIndex == 0 || (listState.firstVisibleItemIndex == 1 && listState.layoutInfo.totalItemsCount == chatItems.size)) { - listState.animateScrollToItem(0) + if (appPlatform.isAndroid) listState.animateScrollToItem(0) else listState.scrollToItem(0) } else { - listState.animateScrollBy(scrollDistance) + if (appPlatform.isAndroid) listState.animateScrollBy(scrollDistance) else listState.scrollBy(scrollDistance) } } catch (e: CancellationException) { /** @@ -1392,11 +1394,12 @@ private fun providerForGallery( return null } - var initialIndex = Int.MAX_VALUE / 2 + // Pager has a bug with overflowing when total pages is around Int.MAX_VALUE. Using smaller value + var initialIndex = 10000 / 2 var initialChatId = cItemId return object: ImageGalleryProvider { override val initialIndex: Int = initialIndex - override val totalMediaSize = mutableStateOf(Int.MAX_VALUE) + override val totalMediaSize = mutableStateOf(10000) override fun getMedia(index: Int): ProviderMedia? { val internalIndex = initialIndex - index val item = item(internalIndex, initialChatId)?.second ?: return null 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 a438ea2bfc..9ab2b47702 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 @@ -1,6 +1,7 @@ @file:UseSerializers(UriSerializer::class) package chat.simplex.common.views.chat +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* @@ -11,6 +12,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.font.FontStyle import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.unit.dp @@ -18,8 +21,7 @@ import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.model.ChatModel.filesToDelete import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.Indigo -import chat.simplex.common.ui.theme.isSystemInDarkTheme +import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.views.helpers.* import chat.simplex.res.MR @@ -44,6 +46,7 @@ sealed class ComposeContextItem { @Serializable object NoContextItem: ComposeContextItem() @Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem() @Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem() + @Serializable class ForwardingItem(val chatItem: ChatItem, val fromChatInfo: ChatInfo): ComposeContextItem() } @Serializable @@ -77,13 +80,18 @@ data class ComposeState( is ComposeContextItem.EditingItem -> true else -> false } + val forwarding: Boolean + get() = when (contextItem) { + is ComposeContextItem.ForwardingItem -> true + else -> false + } val sendEnabled: () -> Boolean get() = { val hasContent = when (preview) { is ComposePreview.MediaPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true - else -> message.isNotEmpty() || liveMessage != null + else -> message.isNotEmpty() || forwarding || liveMessage != null } hasContent && !inProgress } @@ -107,7 +115,7 @@ data class ComposeState( val attachmentDisabled: Boolean get() { - if (editing || liveMessage != null || inProgress) return true + if (editing || forwarding || liveMessage != null || inProgress) return true return when (preview) { ComposePreview.NoPreview -> false is ComposePreview.CLinkPreview -> false @@ -115,6 +123,15 @@ data class ComposeState( } } + val attachmentPreview: Boolean + get() = when (preview) { + ComposePreview.NoPreview -> false + is ComposePreview.CLinkPreview -> false + is ComposePreview.MediaPreview -> preview.content.isNotEmpty() + is ComposePreview.VoicePreview -> false + is ComposePreview.FilePreview -> true + } + val empty: Boolean get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem @@ -243,10 +260,23 @@ fun ComposeView( attachmentOption: MutableState, showChooseAttachment: () -> Unit ) { + val cancelledLinks = rememberSaveable { mutableSetOf() } + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): Pair { + if (msg.isBlank()) return null to false + val parsedMsg = parseToMarkdown(msg) ?: return null to false + val link = parsedMsg.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + val simplexLink = parsedMsg.any { ft -> ft.format is Format.SimplexLink } + return link?.text to simplexLink + } + val linkUrl = rememberSaveable { mutableStateOf(null) } + // default value parsed because of draft + val hasSimplexLink = rememberSaveable { mutableStateOf(parseMessage(composeState.value.message).second) } val prevLinkUrl = rememberSaveable { mutableStateOf(null) } val pendingLinkUrl = rememberSaveable { mutableStateOf(null) } - val cancelledLinks = rememberSaveable { mutableSetOf() } val useLinkPreviews = chatModel.controller.appPrefs.privacyLinkPreviews.get() val saveLastDraft = chatModel.controller.appPrefs.privacySaveLastDraft.get() val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) @@ -255,15 +285,6 @@ fun ComposeView( AttachmentSelection(composeState, attachmentOption, composeState::processPickedFile) { uris, text -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(uris, text) } } - fun isSimplexLink(link: String): Boolean = - link.startsWith("https://simplex.chat", true) || link.startsWith("http://simplex.chat", true) - - fun parseMessage(msg: String): String? { - val parsedMsg = parseToMarkdown(msg) - val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } - return link?.text - } - fun loadLinkPreview(url: String, wait: Long? = null) { if (pendingLinkUrl.value == url) { composeState.value = composeState.value.copy(preview = ComposePreview.CLinkPreview(null)) @@ -283,7 +304,9 @@ fun ComposeView( fun showLinkPreview(s: String) { prevLinkUrl.value = linkUrl.value - linkUrl.value = parseMessage(s) + val parsed = parseMessage(s) + linkUrl.value = parsed.first + hasSimplexLink.value = parsed.second val url = linkUrl.value if (url != null) { if (url != composeState.value.linkPreview?.uri && url != pendingLinkUrl.value) { @@ -338,6 +361,7 @@ fun ComposeView( is SharedContent.Media -> shared.uris.map { it.toString() } is SharedContent.File -> listOf(shared.uri.toString()) is SharedContent.Text -> emptyList() + is SharedContent.Forward -> emptyList() } // When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing chatModel.filesToDelete.removeAll { file -> @@ -384,10 +408,25 @@ fun ComposeView( composeState.value = composeState.value.copy(inProgress = true) } + suspend fun forwardItem(rhId: Long?, forwardedItem: ChatItem, fromChatInfo: ChatInfo): ChatItem? { + val chatItem = controller.apiForwardChatItem( + rh = rhId, + toChatType = chat.chatInfo.chatType, + toChatId = chat.chatInfo.apiId, + fromChatType = fromChatInfo.chatType, + fromChatId = fromChatInfo.apiId, + itemId = forwardedItem.id + ) + if (chatItem != null) { + chatModel.addChatItem(rhId, chat.chatInfo, chatItem) + } + return chatItem + } + fun checkLinkPreview(): MsgContent { return when (val composePreview = cs.preview) { is ComposePreview.CLinkPreview -> { - val url = parseMessage(msgText) + val url = parseMessage(msgText).first val lp = composePreview.linkPreview if (lp != null && url == lp.uri) { MsgContent.MCLink(msgText, preview = lp) @@ -443,11 +482,18 @@ fun ComposeView( if (liveMessage != null) composeState.value = cs.copy(liveMessage = null) sending() } - clearCurrentDraft() + if (!cs.forwarding || chatModel.draft.value?.forwarding == true) { + clearCurrentDraft() + } if (chat.nextSendGrpInv) { sendMemberContactInvitation() sent = null + } else if (cs.contextItem is ComposeContextItem.ForwardingItem) { + sent = forwardItem(chat.remoteHostId, cs.contextItem.chatItem, cs.contextItem.fromChatInfo) + if (cs.message.isNotEmpty()) { + sent = send(chat, checkLinkPreview(), quoted = sent?.id, live = false, ttl = null) + } } else if (cs.contextItem is ComposeContextItem.EditingItem) { val ei = cs.contextItem.chatItem sent = updateMessage(ei, chat, live) @@ -546,7 +592,15 @@ fun ComposeView( sent = send(chat, MsgContent.MCText(msgText), quotedItemId, null, live, ttl) } } + val wasForwarding = cs.forwarding + val forwardingFromChatId = (cs.contextItem as? ComposeContextItem.ForwardingItem)?.fromChatInfo?.id clearState(live) + val draft = chatModel.draft.value + if (wasForwarding && chatModel.draftChatId.value == chat.chatInfo.id && forwardingFromChatId != chat.chatInfo.id && draft != null) { + composeState.value = draft + } else { + clearCurrentDraft() + } return sent } @@ -563,8 +617,16 @@ fun ComposeView( } else { textStyle.value = smallFont if (composeState.value.linkPreviewAllowed) { - if (s.isNotEmpty()) showLinkPreview(s) - else resetLinkPreview() + if (s.isNotEmpty()) { + showLinkPreview(s) + } else { + resetLinkPreview() + hasSimplexLink.value = false + } + } else if (s.isNotEmpty() && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)) { + hasSimplexLink.value = parseMessage(s).second + } else { + hasSimplexLink.value = false } } } @@ -700,6 +762,16 @@ fun ComposeView( } } + @Composable + fun MsgNotAllowedView(reason: String, icon: Painter) { + val color = CurrentColors.collectAsState().value.appColors.receivedMessage + Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) { + Icon(icon, null, tint = MaterialTheme.colors.secondary) + Spacer(Modifier.width(DEFAULT_PADDING_HALF)) + Text(reason, fontStyle = FontStyle.Italic) + } + } + @Composable fun contextItemView() { when (val contextItem = composeState.value.contextItem) { @@ -710,6 +782,9 @@ fun ComposeView( is ComposeContextItem.EditingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_edit_filled)) { clearState() } + is ComposeContextItem.ForwardingItem -> ContextItemView(contextItem.chatItem, painterResource(MR.images.ic_forward), showSender = false) { + composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem) + } } } @@ -729,6 +804,10 @@ fun ComposeView( is SharedContent.Text -> onMessageChange(shared.text) is SharedContent.Media -> composeState.processPickedMedia(shared.uris, shared.text) is SharedContent.File -> composeState.processPickedFile(shared.uri, shared.text) + is SharedContent.Forward -> composeState.value = composeState.value.copy( + contextItem = ComposeContextItem.ForwardingItem(shared.chatItem, shared.fromChatInfo), + preview = if (composeState.value.preview is ComposePreview.CLinkPreview) composeState.value.preview else ComposePreview.NoPreview + ) null -> {} } chatModel.sharedContent.value = null @@ -743,7 +822,17 @@ fun ComposeView( if (nextSendGrpInv.value) { ComposeContextInvitingContactMemberView() } + val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks) + val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files) + val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice) if (composeState.value.preview !is ComposePreview.VoicePreview || composeState.value.editing) { + if (simplexLinkProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.simplex_links_not_allowed), icon = painterResource(MR.images.ic_link)) + } else if (fileProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.files_and_media_not_allowed), icon = painterResource(MR.images.ic_draft)) + } else if (voiceProhibited) { + MsgNotAllowedView(generalGetString(MR.strings.voice_messages_not_allowed), icon = painterResource(MR.images.ic_mic)) + } contextItemView() when { composeState.value.editing && composeState.value.preview is ComposePreview.VoicePreview -> {} @@ -764,7 +853,7 @@ fun ComposeView( modifier = Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom, ) { - val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on + val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership) val attachmentClicked = if (isGroupAndProhibitedFiles) { { AlertManager.shared.showAlertMsg( @@ -858,6 +947,17 @@ fun ComposeView( chatModel.removeLiveDummy() CIFile.cachedRemoteFileRequests.clear() } + if (appPlatform.isDesktop) { + // Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)` + DisposableEffect(Unit) { + onDispose { + if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) { + chatModel.draft.value = composeState.value + chatModel.draftChatId.value = chat.id + } + } + } + } val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) } val sendButtonColor = @@ -871,6 +971,7 @@ fun ComposeView( chat.chatInfo is ChatInfo.Direct, liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown, sendMsgEnabled = sendMsgEnabled.value, + sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited), nextSendGrpInv = nextSendGrpInv.value, needToAllowVoiceToContact, allowedVoiceByPrefs, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt index b53574cfa4..ce34ecf0c3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ContextItemView.kt @@ -4,26 +4,29 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.material.* import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource import androidx.compose.desktop.ui.tooling.preview.Preview -import androidx.compose.ui.text.TextStyle +import androidx.compose.foundation.text.InlineTextContent +import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.runtime.* +import androidx.compose.ui.text.* import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.* import chat.simplex.common.model.* import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Clock @Composable fun ContextItemView( contextItem: ChatItem, contextIcon: Painter, + showSender: Boolean = true, cancelContextItem: () -> Unit ) { val sent = contextItem.chatDir.sent @@ -31,16 +34,47 @@ fun ContextItemView( val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage @Composable - fun msgContentView(lines: Int) { + fun MessageText(attachment: ImageResource?, lines: Int) { + val inlineContent: Pair Unit, Map>? = if (attachment != null) { + remember(contextItem.id) { + val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { + appendInlineContent(id = "attachmentIcon") + append(" ") + } + val inlineContent = mapOf( + "attachmentIcon" to InlineTextContent( + Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter) + ) { + Icon(painterResource(attachment), null, tint = MaterialTheme.colors.secondary) + } + ) + inlineContentBuilder to inlineContent + } + } else null MarkdownText( contextItem.text, contextItem.formattedText, + sender = null, toggleSecrets = false, maxLines = lines, + inlineContent = inlineContent, linkMode = SimplexLinkMode.DESCRIPTION, modifier = Modifier.fillMaxWidth(), ) } + fun attachment(): ImageResource? = + when (contextItem.content.msgContent) { + is MsgContent.MCFile -> MR.images.ic_draft_filled + is MsgContent.MCImage -> MR.images.ic_image + is MsgContent.MCVoice -> MR.images.ic_play_arrow_filled + else -> null + } + + @Composable + fun ContextMsgPreview(lines: Int) { + MessageText(remember(contextItem.id) { attachment() }, lines) + } + Row( Modifier .padding(top = 8.dp) @@ -64,7 +98,7 @@ fun ContextItemView( tint = MaterialTheme.colors.secondary, ) val sender = contextItem.memberDisplayName - if (sender != null) { + if (showSender && sender != null) { Column( horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(4.dp), @@ -73,10 +107,10 @@ fun ContextItemView( sender, style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary) ) - msgContentView(lines = 2) + ContextMsgPreview(lines = 2) } } else { - msgContentView(lines = 3) + ContextMsgPreview(lines = 3) } } IconButton(onClick = cancelContextItem) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt index 456e2a538b..58705bd00a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt @@ -39,6 +39,7 @@ fun SendMsgView( isDirectChat: Boolean, liveMessageAlertShown: SharedPreference, sendMsgEnabled: Boolean, + sendButtonEnabled: Boolean, nextSendGrpInv: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, @@ -71,11 +72,12 @@ fun SendMsgView( } } val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && - cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) + !composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() || (!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) || - cs.endLiveDisabled + cs.endLiveDisabled || + !sendButtonEnabled PlatformTextField(composeState, sendMsgEnabled, sendMsgButtonDisabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) { if (!cs.inProgress) { sendMessage(null) @@ -155,7 +157,7 @@ fun SendMsgView( fun MenuItems(): List<@Composable () -> Unit> { val menuItems = mutableListOf<@Composable () -> Unit>() - if (cs.liveMessage == null && !cs.editing && !nextSendGrpInv || sendMsgEnabled) { + if (cs.liveMessage == null && !cs.editing && !cs.forwarding && !nextSendGrpInv || sendMsgEnabled) { if ( cs.preview !is ComposePreview.VoicePreview && cs.contextItem is ComposeContextItem.NoContextItem && @@ -430,7 +432,7 @@ private fun SendMsgButton( .padding(4.dp) .alpha(alpha.value) .clip(CircleShape) - .background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary) + .background(if (enabled) sendButtonColor else MaterialTheme.colors.secondary.copy(alpha = 0.75f)) .padding(3.dp) ) } @@ -552,6 +554,7 @@ fun PreviewSendMsgView() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, @@ -586,6 +589,7 @@ fun PreviewSendMsgViewEditing() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, @@ -620,6 +624,7 @@ fun PreviewSendMsgViewInProgress() { isDirectChat = true, liveMessageAlertShown = SharedPreference(get = { true }, set = { }), sendMsgEnabled = true, + sendButtonEnabled = true, nextSendGrpInv = false, needToAllowVoiceToContact = false, allowedVoiceByPrefs = true, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index ebb35a4468..e90efa7d1b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -309,7 +309,7 @@ fun GroupMemberInfoLayout( SectionView { if (contactId != null && knownDirectChat(contactId) != null) { OpenChatButton(onClick = { openDirectChat(contactId) }) - } else if (groupInfo.fullGroupPreferences.directMessages.on) { + } else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { if (contactId != null) { OpenChatButton(onClick = { openDirectChat(contactId) }) } else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) { @@ -335,7 +335,7 @@ fun GroupMemberInfoLayout( val clipboard = LocalClipboardManager.current ShareAddressButton { clipboard.shareText(simplexChatLink(member.contactLink)) } if (contactId != null) { - if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on) { + if (knownDirectChat(contactId) == null && !groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) { ConnectViaAddressButton(onClick = { connectViaAddress(member.contactLink) }) } } else { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt index 607efb9a3b..265d0cdeae 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupPreferences.kt @@ -6,7 +6,6 @@ import SectionDividerSpaced import SectionItemView import SectionTextFooter import SectionView -import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -20,13 +19,20 @@ import chat.simplex.common.views.usersettings.PreferenceToggleWithIcon import chat.simplex.common.model.* import chat.simplex.common.platform.ColumnWithScrollBar import chat.simplex.res.MR +import dev.icerock.moko.resources.compose.painterResource + +private val featureRoles: List> = listOf( + null to generalGetString(MR.strings.feature_roles_all_members), + GroupMemberRole.Admin to generalGetString(MR.strings.feature_roles_admins), + GroupMemberRole.Owner to generalGetString(MR.strings.feature_roles_owners) +) @Composable -fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit,) { +fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () -> Unit) { val groupInfo = remember { derivedStateOf { val ch = m.getChat(chatId) val g = (ch?.chatInfo as? ChatInfo.Group)?.groupInfo - if (g == null || ch?.remoteHostId != rhId) null else g + if (g == null || ch.remoteHostId != rhId) null else g }} val gInfo = groupInfo.value ?: return var preferences by rememberSaveable(gInfo, stateSaver = serializableSaver()) { mutableStateOf(gInfo.fullGroupPreferences) } @@ -81,7 +87,7 @@ private fun GroupPreferencesLayout( val onTTLUpdated = { ttl: Int? -> applyPrefs(preferences.copy(timedMessages = preferences.timedMessages.copy(ttl = ttl))) } - FeatureSection(GroupFeature.TimedMessages, timedMessages, groupInfo, preferences, onTTLUpdated) { enable -> + FeatureSection(GroupFeature.TimedMessages, timedMessages, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> if (enable == GroupFeatureEnabled.ON) { applyPrefs(preferences.copy(timedMessages = TimedMessagesGroupPreference(enable = enable, ttl = preferences.timedMessages.ttl ?: 86400))) } else { @@ -90,33 +96,45 @@ private fun GroupPreferencesLayout( } SectionDividerSpaced(true, maxBottomPadding = false) val allowDirectMessages = remember(preferences) { mutableStateOf(preferences.directMessages.enable) } - FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(directMessages = GroupPreference(enable = it))) + val directMessagesRole = remember(preferences) { mutableStateOf(preferences.directMessages.role) } + FeatureSection(GroupFeature.DirectMessages, allowDirectMessages, directMessagesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(directMessages = RoleGroupPreference(enable = enable, role))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowFullDeletion = remember(preferences) { mutableStateOf(preferences.fullDelete.enable) } - FeatureSection(GroupFeature.FullDelete, allowFullDeletion, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = it))) + FeatureSection(GroupFeature.FullDelete, allowFullDeletion, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(fullDelete = GroupPreference(enable = enable))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowReactions = remember(preferences) { mutableStateOf(preferences.reactions.enable) } - FeatureSection(GroupFeature.Reactions, allowReactions, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(reactions = GroupPreference(enable = it))) + FeatureSection(GroupFeature.Reactions, allowReactions, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(reactions = GroupPreference(enable = enable))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowVoice = remember(preferences) { mutableStateOf(preferences.voice.enable) } - FeatureSection(GroupFeature.Voice, allowVoice, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(voice = GroupPreference(enable = it))) + val voiceRole = remember(preferences) { mutableStateOf(preferences.voice.role) } + FeatureSection(GroupFeature.Voice, allowVoice, voiceRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(voice = RoleGroupPreference(enable = enable, role))) } SectionDividerSpaced(true, maxBottomPadding = false) val allowFiles = remember(preferences) { mutableStateOf(preferences.files.enable) } - FeatureSection(GroupFeature.Files, allowFiles, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(files = GroupPreference(enable = it))) + val filesRole = remember(preferences) { mutableStateOf(preferences.files.role) } + FeatureSection(GroupFeature.Files, allowFiles, filesRole, groupInfo, preferences, onTTLUpdated) { enable, role -> + applyPrefs(preferences.copy(files = RoleGroupPreference(enable = enable, role))) } + + // TODO enable simplexLinks preference in 5.8 +// SectionDividerSpaced(true, maxBottomPadding = false) +// val allowSimplexLinks = remember(preferences) { mutableStateOf(preferences.simplexLinks.enable) } +// val simplexLinksRole = remember(preferences) { mutableStateOf(preferences.simplexLinks.role) } +// FeatureSection(GroupFeature.SimplexLinks, allowSimplexLinks, simplexLinksRole, groupInfo, preferences, onTTLUpdated) { enable, role -> +// applyPrefs(preferences.copy(simplexLinks = RoleGroupPreference(enable = enable, role))) +// } + SectionDividerSpaced(true, maxBottomPadding = false) val enableHistory = remember(preferences) { mutableStateOf(preferences.history.enable) } - FeatureSection(GroupFeature.History, enableHistory, groupInfo, preferences, onTTLUpdated) { - applyPrefs(preferences.copy(history = GroupPreference(enable = it))) + FeatureSection(GroupFeature.History, enableHistory, null, groupInfo, preferences, onTTLUpdated) { enable, _ -> + applyPrefs(preferences.copy(history = GroupPreference(enable = enable))) } if (groupInfo.canEdit) { SectionDividerSpaced(maxTopPadding = true, maxBottomPadding = false) @@ -134,10 +152,11 @@ private fun GroupPreferencesLayout( private fun FeatureSection( feature: GroupFeature, enableFeature: State, + enableForRole: State? = null, groupInfo: GroupInfo, preferences: FullGroupPreferences, onTTLUpdated: (Int?) -> Unit, - onSelected: (GroupFeatureEnabled) -> Unit + onSelected: (GroupFeatureEnabled, GroupMemberRole?) -> Unit ) { SectionView { val on = enableFeature.value == GroupFeatureEnabled.ON @@ -151,7 +170,7 @@ private fun FeatureSection( iconTint, enableFeature.value == GroupFeatureEnabled.ON, ) { checked -> - onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF) + onSelected(if (checked) GroupFeatureEnabled.ON else GroupFeatureEnabled.OFF, enableForRole?.value) } if (timedOn) { val ttl = rememberSaveable(preferences.timedMessages) { mutableStateOf(preferences.timedMessages.ttl) } @@ -165,6 +184,18 @@ private fun FeatureSection( onSelected = onTTLUpdated ) } + if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.feature_enabled_for), + featureRoles, + enableForRole, + // remove in v5.8 + enabled = remember { mutableStateOf(false) }, + onSelected = { value -> + onSelected(enableFeature.value, value) + } + ) + } } else { InfoRow( feature.text, @@ -175,6 +206,14 @@ private fun FeatureSection( if (timedOn) { InfoRow(generalGetString(MR.strings.delete_after), timeText(preferences.timedMessages.ttl)) } + if (enableFeature.value == GroupFeatureEnabled.ON && enableForRole != null) { + InfoRow(generalGetString(MR.strings.feature_enabled_for), featureRoles.firstOrNull { it.first == enableForRole.value }?.second ?: generalGetString(MR.strings.feature_roles_all_members), textColor = MaterialTheme.colors.secondary) + } + } + } + KeyChangeEffect(enableFeature.value) { + if (enableFeature.value == GroupFeatureEnabled.OFF) { + onSelected(enableFeature.value, null) } } SectionTextFooter(feature.enableDescription(enableFeature.value, groupInfo.canEdit)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index a9a4963c96..19cc949543 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -16,6 +16,7 @@ import chat.simplex.common.platform.onRightClick @Composable fun CIChatFeatureView( + chatInfo: ChatInfo, chatItem: ChatItem, feature: Feature, iconColor: Color, @@ -23,7 +24,7 @@ fun CIChatFeatureView( revealed: MutableState, showMenu: MutableState, ) { - val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList() + val merged = if (!revealed.value) mergedFeatures(chatItem, chatInfo) else emptyList() Box( Modifier .combinedClickable( @@ -70,7 +71,7 @@ private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): Feat ) @Composable -private fun mergedFeatures(chatItem: ChatItem): List? { +private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List? { val m = ChatModel val fs: ArrayList = arrayListOf() val icons: MutableSet = mutableSetOf() @@ -78,7 +79,7 @@ private fun mergedFeatures(chatItem: ChatItem): List? { if (i != null) { val reversedChatItems = m.chatItems.asReversed() while (i < reversedChatItems.size) { - val f = featureInfo(reversedChatItems[i]) ?: break + val f = featureInfo(reversedChatItems[i], chatInfo) ?: break if (!icons.contains(f.icon)) { fs.add(0, f) icons.add(f.icon) @@ -90,12 +91,12 @@ private fun mergedFeatures(chatItem: ChatItem): List? { } @Composable -private fun featureInfo(ci: ChatItem): FeatureInfo? = +private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? = when (ci.content) { is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name) - is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) - is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name) + is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enabled(ci.content.memberRole_, (chatInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, ci.content.param, ci.content.groupFeature.name) else -> null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index f7909eed12..da766d920e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -59,18 +59,11 @@ fun CIFileView( } } - fun fileSizeValid(): Boolean { - if (file != null) { - return file.fileSize <= getMaxFileSize(file.fileProtocol) - } - return false - } - fun fileAction() { if (file != null) { when { file.fileStatus is CIFileStatus.RcvInvitation -> { - if (fileSizeValid()) { + if (fileSizeValid(file)) { receiveFile(file.fileId) } else { AlertManager.shared.showAlertMsg( @@ -165,7 +158,7 @@ fun CIFileView( is CIFileStatus.SndCancelled -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close)) is CIFileStatus.RcvInvitation -> - if (fileSizeValid()) + if (fileSizeValid(file)) fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary) else fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange) @@ -216,6 +209,8 @@ fun CIFileView( } } +fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol) + @Composable fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher = rememberFileChooserLauncher(false, ciFile) { to: URI? -> diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 64741f7466..427f34b2e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -19,6 +19,7 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.* @@ -40,6 +41,7 @@ fun chatEventText(eventText: String, ts: String): AnnotatedString = @Composable fun ChatItemView( + rhId: Long?, cInfo: ChatInfo, cItem: ChatItem, composeState: MutableState, @@ -195,12 +197,16 @@ fun ChatItemView( } val clipboard = LocalClipboardManager.current val cachedRemoteReqs = remember { CIFile.cachedRemoteFileRequests } - val copyAndShareAllowed = when { - cItem.content.text.isNotEmpty() -> true + fun fileForwardingAllowed() = when { cItem.file != null && chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded -> true getLoadedFilePath(cItem.file) != null -> true else -> false } + val copyAndShareAllowed = when { + cItem.content.text.isNotEmpty() -> true + fileForwardingAllowed() -> true + else -> false + } if (copyAndShareAllowed) { ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = { @@ -227,8 +233,19 @@ fun ChatItemView( showMenu.value = false }) } - if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file?.fileSource] != false && cItem.file?.loaded == true))) { + if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) { SaveContentItemAction(cItem, saveFileLauncher, showMenu) + } else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) { + ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = { + withBGApi { + Log.d(TAG, "ChatItemView downloadFileAction") + val user = chatModel.currentUser.value + if (user != null) { + controller.receiveFile(rhId, user, cItem.file.fileId) + } + } + showMenu.value = false + }) } if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) { ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = { @@ -236,6 +253,16 @@ fun ChatItemView( showMenu.value = false }) } + if (cItem.meta.itemDeleted == null && + (cItem.file == null || fileForwardingAllowed()) && + !cItem.isLiveDummy && !live + ) { + ItemAction(stringResource(MR.strings.forward_chat_item), painterResource(MR.images.ic_forward), onClick = { + chatModel.chatId.value = null + chatModel.sharedContent.value = SharedContent.Forward(cItem, cInfo) + showMenu.value = false + }) + } ItemInfoAction(cInfo, cItem, showItemDetails, showMenu) if (revealed.value) { HideItemAction(revealed, showMenu) @@ -304,7 +331,7 @@ fun ChatItemView( MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed) MarkedDeletedItemDropdownMenu() } else { - if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem, cInfo.timedMessagesTTL) } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { @@ -442,11 +469,11 @@ fun ChatItemView( MsgContentItemDropdownMenu() } is CIContent.RcvChatFeature -> { - CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndChatFeature -> { - CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatPreference -> { @@ -454,23 +481,23 @@ fun ChatItemView( CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature) } is CIContent.SndChatPreference -> { - CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeature -> { - CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndGroupFeature -> { - CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, c.preference.enabled(c.memberRole_, (cInfo as? ChatInfo.Group)?.groupInfo?.membership).iconColor, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvChatFeatureRejected -> { - CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.RcvGroupFeatureRejected -> { - CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) + CIChatFeatureView(cInfo, cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu) MsgContentItemDropdownMenu() } is CIContent.SndModerated -> DeletedItem() @@ -724,7 +751,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() }) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) } - if (chatItem.meta.editable && !chatItem.localNote) { + if (chatItem.meta.deletable && !chatItem.localNote) { Spacer(Modifier.padding(horizontal = 4.dp)) TextButton(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) @@ -782,6 +809,7 @@ expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) fun PreviewChatItemView() { SimpleXTheme { ChatItemView( + rhId = null, ChatInfo.Direct.sampleData, ChatItem.getSampleData( 1, CIDirection.DirectSnd(), Clock.System.now(), "hello" @@ -818,6 +846,7 @@ fun PreviewChatItemView() { fun PreviewChatItemViewDeletedContent() { SimpleXTheme { ChatItemView( + rhId = null, ChatInfo.Direct.sampleData, ChatItem.getDeletedContentSampleData(), useLinkPreviews = true, 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 641e6affab..a3b70e65ec 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 @@ -87,15 +87,16 @@ fun FramedItemView( } @Composable - fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null) { + fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) { val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Row( Modifier .background(if (sent) sentColor.toQuote() else receivedColor.toQuote()) .fillMaxWidth() - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (ci.quotedItem == null) 6.dp else 0.dp), + .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), horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically ) { if (icon != null) { Icon( @@ -184,7 +185,7 @@ fun FramedItemView( } val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && - !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null + !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage @@ -219,7 +220,11 @@ fun FramedItemView( } else if (ci.meta.isLive) { FramedItemHeader(stringResource(MR.strings.live), false) } - ci.quotedItem?.let { ciQuoteView(it) } + if (ci.quotedItem != null) { + ciQuoteView(ci.quotedItem) + } else if (ci.meta.itemForwarded != null) { + FramedItemHeader(ci.meta.itemForwarded.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)) { Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index 5169d944c8..66061767e5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -68,7 +68,7 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier, linkMode: SimplexLinkMode, - inlineContent: Map? = null, + inlineContent: Pair Unit, Map>? = null, onLinkLongClick: (link: String) -> Unit = {} ) { val textLayoutDirection = remember (text) { @@ -119,6 +119,7 @@ fun MarkdownText ( } if (formattedText == null) { val annotatedText = buildAnnotatedString { + inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) if (text is String) append(text) else if (text is AnnotatedString) append(text) @@ -127,10 +128,11 @@ fun MarkdownText ( } if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } } - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent ?: mapOf()) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } else { var hasAnnotations = false val annotatedText = buildAnnotatedString { + inlineContent?.first?.invoke(this) appendSender(this, sender, senderBold) for ((i, ft) in formattedText.withIndex()) { if (ft.format == null) append(ft.text) @@ -210,7 +212,7 @@ fun MarkdownText ( } ) } else { - Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow) + Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow, inlineContent = inlineContent?.second ?: mapOf()) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 1bb5a78996..336d104d2d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -90,7 +90,7 @@ fun ChatPreviewView( Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary) } - fun messageDraft(draft: ComposeState): Pair> { + fun messageDraft(draft: ComposeState): Pair Unit, Map> { fun attachment(): Pair? = when (draft.preview) { is ComposePreview.FilePreview -> MR.images.ic_draft_filled to draft.preview.fileName @@ -100,7 +100,7 @@ fun ChatPreviewView( } val attachment = attachment() - val text = buildAnnotatedString { + val inlineContentBuilder: AnnotatedString.Builder.() -> Unit = { appendInlineContent(id = "editIcon") append(" ") if (attachment != null) { @@ -110,7 +110,6 @@ fun ChatPreviewView( } append(" ") } - append(draft.message) } val inlineContent: Map = mapOf( "editIcon" to InlineTextContent( @@ -124,7 +123,7 @@ fun ChatPreviewView( Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.secondary) } ) - return text to inlineContent + return inlineContentBuilder to inlineContent } @Composable @@ -169,7 +168,7 @@ fun ChatPreviewView( if (ci != null) { if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) { val (text: CharSequence, inlineTextContent) = when { - chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { messageDraft(chatModelDraft) } + chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft) } ci.meta.itemDeleted == null -> ci.text to null else -> markedDeletedText(ci.meta) to null } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt index 04fef25ac2..a36930f5ce 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ShareListView.kt @@ -13,9 +13,8 @@ import dev.icerock.moko.resources.compose.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.common.SettingsViewState +import chat.simplex.common.model.* import chat.simplex.common.views.helpers.* -import chat.simplex.common.model.Chat -import chat.simplex.common.model.ChatModel import chat.simplex.common.platform.* import chat.simplex.res.MR import kotlinx.coroutines.flow.MutableStateFlow @@ -74,7 +73,7 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState val navButton: @Composable RowScope.() -> Unit = { when { showSearch -> NavigationButtonBack(hideSearchOnBack) - users.size > 1 || chatModel.remoteHosts.isNotEmpty() -> { + (users.size > 1 || chatModel.remoteHosts.isNotEmpty()) && remember { chatModel.sharedContent }.value !is SharedContent.Forward -> { val allRead = users .filter { u -> !u.user.activeUser && !u.user.hidden } .all { u -> u.unreadCount == 0 } @@ -82,7 +81,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState userPickerState.value = AnimatedViewState.VISIBLE } } - else -> NavigationButtonBack(onButtonClicked = { chatModel.sharedContent.value = null }) + else -> NavigationButtonBack(onButtonClicked = { + val sharedContent = chatModel.sharedContent.value + // Drop shared content + chatModel.sharedContent.value = null + if (sharedContent is SharedContent.Forward) { + chatModel.chatId.value = sharedContent.fromChatInfo.id + } + }) } } if (chatModel.chats.size >= 8) { @@ -118,7 +124,8 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState is SharedContent.Text -> stringResource(MR.strings.share_message) is SharedContent.Media -> stringResource(MR.strings.share_image) is SharedContent.File -> stringResource(MR.strings.share_file) - else -> stringResource(MR.strings.share_message) + is SharedContent.Forward -> stringResource(MR.strings.forward_message) + null -> stringResource(MR.strings.share_message) }, color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, @@ -135,12 +142,14 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState @Composable private fun ShareList(chatModel: ChatModel, search: String) { - val filter: (Chat) -> Boolean = { chat: Chat -> - chat.chatInfo.chatViewName.lowercase().contains(search.lowercase()) - } val chats by remember(search) { derivedStateOf { - if (search.isEmpty()) chatModel.chats.toList().filter { it.chatInfo.ready } else chatModel.chats.toList().filter { it.chatInfo.ready }.filter(filter) + val sorted = chatModel.chats.toList().sortedByDescending { it.chatInfo is ChatInfo.Local } + if (search.isEmpty()) { + sorted.filter { it.chatInfo.ready } + } else { + sorted.filter { it.chatInfo.ready && it.chatInfo.chatViewName.lowercase().contains(search.lowercase()) } + } } } LazyColumnWithScrollBar( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt index 67f82e5279..ee4638445b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Enums.kt @@ -2,6 +2,8 @@ package chat.simplex.common.views.helpers import androidx.compose.runtime.saveable.Saver +import chat.simplex.common.model.ChatInfo +import chat.simplex.common.model.ChatItem import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -13,6 +15,7 @@ sealed class SharedContent { data class Text(val text: String): SharedContent() data class Media(val text: String, val uris: List): SharedContent() data class File(val text: String, val uri: URI): SharedContent() + data class Forward(val chatItem: ChatItem, val fromChatInfo: ChatInfo): SharedContent() } enum class AnimatedViewState { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt index d930a2841e..c96a277fb8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/Section.kt @@ -254,12 +254,12 @@ fun TextIconSpaced(extraPadding: Boolean = false) { } @Composable -fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null) { +fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground) { SectionItemViewSpaceBetween { Row { val iconSize = with(LocalDensity.current) { 21.sp.toDp() } if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary) - Text(title) + Text(title, color = textColor) } Text(value, color = MaterialTheme.colors.secondary) } 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 ae09163591..182861c0d6 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 @@ -15,7 +15,6 @@ import chat.simplex.res.MR import com.charleskorn.kaml.decodeFromStream import dev.icerock.moko.resources.StringResource import kotlinx.coroutines.* -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import java.io.* import java.net.URI diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt index bd1be525be..e13b86258d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/remote/ConnectMobileView.kt @@ -38,9 +38,7 @@ import chat.simplex.common.views.usersettings.* import chat.simplex.res.MR import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.runBlocking @Composable fun ConnectMobileView() { @@ -269,12 +267,20 @@ fun AddingMobileDevice(showTitle: Boolean, staleQrCode: MutableState, c var cachedR by remember { mutableStateOf(null) } val customAddress = rememberSaveable { mutableStateOf(null) } val customPort = rememberSaveable { mutableStateOf(null) } + var userChangedAddress by rememberSaveable { mutableStateOf(false) } + var userChangedPort by rememberSaveable { mutableStateOf(false) } val startRemoteHost = suspend { + if (customAddress.value != cachedR.address && cachedR != null) { + userChangedAddress = true + } + if (customPort.value != cachedR.port && cachedR != null) { + userChangedPort = true + } val r = chatModel.controller.startRemoteHost( rhId = null, multicast = controller.appPrefs.offerRemoteMulticast.get(), - address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_, - port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ + address = if (customAddress.value != null && userChangedAddress) customAddress.value else cachedR.rh?.bindAddress_, + port = if (customPort.value != null && userChangedPort) customPort.value else cachedR.rh?.bindPort_ ) if (r != null) { cachedR = r @@ -343,12 +349,20 @@ private fun showConnectMobileDevice(rh: RemoteHostInfo, connecting: MutableState var cachedR by remember { mutableStateOf(null) } val customAddress = rememberSaveable { mutableStateOf(null) } val customPort = rememberSaveable { mutableStateOf(null) } + var userChangedAddress by rememberSaveable { mutableStateOf(false) } + var userChangedPort by rememberSaveable { mutableStateOf(false) } val startRemoteHost = suspend { + if (customAddress.value != cachedR.address && cachedR != null) { + userChangedAddress = true + } + if (customPort.value != cachedR.port && cachedR != null) { + userChangedPort = true + } val r = chatModel.controller.startRemoteHost( rhId = rh.remoteHostId, multicast = controller.appPrefs.offerRemoteMulticast.get(), - address = if (customAddress.value?.address != cachedR.address?.address) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_, - port = if (customPort.value != cachedR.port) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_ + address = if (customAddress.value != null && userChangedAddress) customAddress.value else cachedR.rh?.bindAddress_ ?: rh.bindAddress_, + port = if (customPort.value != null && userChangedPort) customPort.value else cachedR.rh?.bindPort_ ?: rh.bindPort_ ) if (r != null) { cachedR = r diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt index a2ea5959c3..c031a0fcb7 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/AdvancedNetworkSettings.kt @@ -167,7 +167,7 @@ fun AdvancedNetworkSettingsView(chatModel: ChatModel) { // can't be higher than 130ms to avoid overflow on 32bit systems TimeoutSettingRow( stringResource(MR.strings.network_option_protocol_timeout_per_kb), networkTCPTimeoutPerKb, - listOf(15_000, 30_000, 45_000, 60_000, 90_000, 120_000), secondsLabel + listOf(2_500, 5_000, 10_000, 15_000, 20_000, 30_000), secondsLabel ) } SectionItemView { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt index e1f050b0b6..4d33040e29 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/NetworkAndServers.kt @@ -2,6 +2,7 @@ package chat.simplex.common.views.usersettings import SectionBottomSpacer import SectionCustomFooter +import SectionDividerSpaced import SectionItemView import SectionItemWithValue import SectionView @@ -21,12 +22,12 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.* import androidx.compose.ui.text.input.* import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* -import chat.simplex.common.platform.ColumnWithScrollBar -import chat.simplex.common.platform.chatModel +import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ClickableText import chat.simplex.common.views.helpers.* @@ -190,6 +191,16 @@ fun NetworkAndServersView() { SectionView(generalGetString(MR.strings.settings_section_title_calls)) { SettingsActionItem(painterResource(MR.images.ic_electrical_services), stringResource(MR.strings.webrtc_ice_servers), { ModalManager.start.showModal { RTCServersView(m) } }) } + + if (appPlatform.isAndroid) { + SectionDividerSpaced() + SectionView(generalGetString(MR.strings.settings_section_title_network_connection).uppercase()) { + val info = remember { chatModel.networkInfo }.value + SettingsActionItemWithContent(icon = null, info.networkType.text) { + Icon(painterResource(MR.images.ic_circle_filled), stringResource(MR.strings.icon_descr_server_status_connected), tint = if (info.online) Color.Green else MaterialTheme.colors.error) + } + } + } SectionBottomSpacer() } } 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 5595f319ea..7c7cb2c384 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -46,6 +46,9 @@ invalid message format LIVE moderated + forwarded + saved + saved from %s invalid chat invalid data error showing message @@ -267,6 +270,11 @@ History No history In reply to + Saved + Forwarded + Saved from + Forwarded from + Recipient(s) can\'t see who this message is from. Delivery No delivery information Delete @@ -294,6 +302,8 @@ Revoke file? File will be deleted from servers. Revoke + Forward + Download edited @@ -328,6 +338,7 @@ Share message… Share media… Share file… + Forward message… Attach @@ -347,6 +358,9 @@ Files and media prohibited! Only group owners can enable files and media. Send direct message to connect + SimpleX links not allowed + Files and media not allowed + Voice messages not allowed Image @@ -1017,6 +1031,7 @@ THEMES MESSAGES AND FILES CALLS + Network connection Incognito mode EXPERIMENTAL Use from desktop @@ -1530,6 +1545,7 @@ Message reactions Voice messages Files and media + SimpleX links Visible history Audio/video calls \nAvailable in v5.1 @@ -1587,6 +1603,8 @@ Prohibit messages reactions. Allow to send files and media. Prohibit sending files and media. + Allow to send SimpleX links. + Prohibit sending SimpleX links Send up to 100 last messages to new members. Do not send history to new members. Group members can send disappearing messages. @@ -1601,6 +1619,8 @@ Message reactions are prohibited in this group. Group members can send files and media. Files and media are prohibited in this group. + Group members can send SimpleX links. + SimpleX links are prohibited in this group. Up to 100 last messages are sent to new members. History is not sent to new members. Delete after @@ -1623,6 +1643,10 @@ offered %s offered %s: %2s cancelled %s + all members + admins + owners + Enabled for What\'s new @@ -1925,4 +1949,11 @@ Check your internet connection and try again Warning: the archive will be deleted.]]> Error verifying passphrase: + + + No network connection + Cellular + WiFi + Wired ethernet + Other \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg new file mode 100644 index 0000000000..7ad0b14b70 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/resources/MR/images/ic_forward.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js index 5ea2f062b0..218fe0c660 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/call.js @@ -36,9 +36,10 @@ var localizedState = ""; var localizedDescription = ""; const processCommand = (function () { const defaultIceServers = [ + { urls: ["stuns:stun.simplex.im:443"] }, { urls: ["stun:stun.simplex.im:443"] }, - { urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, - { urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, + //{urls: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"}, + { urls: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj" }, ]; function getCallConfig(encodedInsertableStreams, iceServers, relay) { return { @@ -110,7 +111,17 @@ const processCommand = (function () { }); } async function initializeCall(config, mediaType, aesKey) { - const pc = new RTCPeerConnection(config.peerConnectionConfig); + var _a; + let pc; + try { + pc = new RTCPeerConnection(config.peerConnectionConfig); + } + catch (e) { + console.log("Error while constructing RTCPeerConnection, will try without 'stuns' specified: " + e); + const withoutStuns = (_a = config.peerConnectionConfig.iceServers) === null || _a === void 0 ? void 0 : _a.filter((elem) => typeof elem.urls === "string" ? !elem.urls.startsWith("stuns:") : !elem.urls.some((url) => url.startsWith("stuns:"))); + config.peerConnectionConfig.iceServers = withoutStuns; + pc = new RTCPeerConnection(config.peerConnectionConfig); + } const remoteStream = new MediaStream(); const localCamera = VideoCamera.User; const localStream = await getLocalMediaStream(mediaType, localCamera); @@ -375,6 +386,9 @@ const processCommand = (function () { // setupVideoElement(videos.remote) videos.local.srcObject = call.localStream; videos.remote.srcObject = call.remoteStream; + // Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture + videos.local.play(); + videos.remote.play(); } async function setupEncryptionWorker(call) { if (call.aesKey) { @@ -447,7 +461,9 @@ const processCommand = (function () { codecs.splice(selectedCodecIndex, 1); codecs.unshift(selectedCodec); for (const t of call.connection.getTransceivers()) { - if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") { + // Firefox doesn't have this function implemented: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1396922 + if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video" && t.setCodecPreferences) { t.setCodecPreferences(codecs); } } @@ -470,8 +486,22 @@ const processCommand = (function () { } return; } - for (const t of call.localStream.getTracks()) - t.stop(); + if (!call.screenShareEnabled) { + for (const t of call.localStream.getTracks()) + t.stop(); + } + else { + // Don't stop audio track if switching to screenshare + for (const t of call.localStream.getVideoTracks()) + t.stop(); + // Replace new track from screenshare with old track from recording device + for (const t of localStream.getAudioTracks()) { + t.stop(); + localStream.removeTrack(t); + } + for (const t of call.localStream.getAudioTracks()) + localStream.addTrack(t); + } call.localCamera = camera; const audioTracks = localStream.getAudioTracks(); const videoTracks = localStream.getVideoTracks(); @@ -485,6 +515,7 @@ const processCommand = (function () { replaceTracks(pc, videoTracks); call.localStream = localStream; videos.local.srcObject = localStream; + videos.local.play(); } function replaceTracks(pc, tracks) { if (!tracks.length) @@ -530,7 +561,9 @@ const processCommand = (function () { //}, //aspectRatio: 1.33, }, - audio: true, + audio: false, + // This works with Chrome, Edge, Opera, but not with Firefox and Safari + // systemAudio: "include" }; return navigator.mediaDevices.getDisplayMedia(constraints); } diff --git a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html index 59ca2b58b0..2600062b02 100644 --- a/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html +++ b/apps/multiplatform/common/src/commonMain/resources/assets/www/desktop/call.html @@ -10,7 +10,6 @@ diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 50d143aaf5..21a2ccd196 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -213,7 +213,7 @@ actual object SoundPlayer: SoundPlayerInterface { override fun start(scope: CoroutineScope, sound: Boolean) { val tmpFile = File(tmpDir, UUID.randomUUID().toString()) tmpFile.deleteOnExit() - SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + SoundPlayer::class.java.getResource("/media/ring_once.mp3")!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) } playing = true scope.launch { while (playing && sound) { @@ -228,3 +228,37 @@ actual object SoundPlayer: SoundPlayerInterface { AudioPlayer.stop() } } + +actual object CallSoundsPlayer: CallSoundsPlayerInterface { + private var playingJob: Job? = null + + private fun start(soundPath: String, delay: Long, scope: CoroutineScope) { + playingJob?.cancel() + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource(soundPath)!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playingJob = scope.launch { + while (isActive) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + delay(delay) + } + } + } + + override fun startConnectingCallSound(scope: CoroutineScope) { + // Taken from https://github.com/TelegramOrg/Telegram-Android + // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE + start("/media/connecting_call.mp3", 3000, scope) + } + + override fun startInCallSound(scope: CoroutineScope) { + start("/media/in_call.mp3", 5000, scope) + } + + override fun vibrate(times: Int) {} + + override fun stop() { + playingJob?.cancel() + AudioPlayer.stop() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 87c2d3e8f2..d3bf1bf01e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,30 +1,15 @@ package chat.simplex.common.views.call -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.nanohttpd.protocols.http.IHTTPSession import org.nanohttpd.protocols.http.response.Response @@ -45,6 +30,7 @@ actual fun ActiveCallView() { if (call != null) withBGApi { chatModel.callManager.endCall(call) } } BackHandler(onBack = endCall) + val scope = rememberCoroutineScope() WebRTCController(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCController: $apiMsg") val call = chatModel.activeCall.value @@ -56,6 +42,8 @@ actual fun ActiveCallView() { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + CallSoundsPlayer.startConnectingCallSound(scope) + activeCallWaitDeliveryReceipt(scope) } is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) @@ -64,6 +52,7 @@ actual fun ActiveCallView() { is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) @@ -121,6 +110,7 @@ actual fun ActiveCallView() { // After the first call, End command gets added to the list which prevents making another calls chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { + CallSoundsPlayer.stop() chatModel.activeCallViewIsVisible.value = false chatModel.callCommand.clear() } diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 new file mode 100644 index 0000000000..fc425bab97 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 differ diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 new file mode 100644 index 0000000000..1049be4462 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 differ diff --git a/apps/multiplatform/desktop/build.gradle.kts b/apps/multiplatform/desktop/build.gradle.kts index c3dd9bb9b0..401c2938d5 100644 --- a/apps/multiplatform/desktop/build.gradle.kts +++ b/apps/multiplatform/desktop/build.gradle.kts @@ -12,10 +12,7 @@ version = extra["desktop.version_name"] as String kotlin { - jvm { - jvmToolchain(11) - withJava() - } + jvm() sourceSets { val jvmMain by getting { dependencies { @@ -151,7 +148,7 @@ cmake { tasks.named("clean") { dependsOn("cmakeClean") } -tasks.named("compileJava") { +tasks.named("compileKotlinJvm") { dependsOn("cmakeBuildAndCopy") } afterEvaluate { diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 1bfac36a28..74629c8b9b 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -24,12 +24,13 @@ android.nonTransitiveRClass=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true kotlin.mpp.androidSourceSetLayoutVersion=2 +kotlin.jvm.target=11 -android.version_name=5.6 -android.version_code=191 +android.version_name=5.7-beta.0 +android.version_code=196 -desktop.version_name=5.6 -desktop.version_code=35 +desktop.version_name=5.7-beta.0 +desktop.version_code=37 kotlin.version=1.9.23 gradle.plugin.version=8.2.0 diff --git a/apps/simplex-directory-service/src/Directory/Events.hs b/apps/simplex-directory-service/src/Directory/Events.hs index 1d7a866051..76f57585a8 100644 --- a/apps/simplex-directory-service/src/Directory/Events.hs +++ b/apps/simplex-directory-service/src/Directory/Events.hs @@ -31,6 +31,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Messaging.Encoding.String import Simplex.Messaging.Util ((<$?>)) import Data.Char (isSpace) diff --git a/apps/simplex-directory-service/src/Directory/Service.hs b/apps/simplex-directory-service/src/Directory/Service.hs index c1428881b9..d158b57e22 100644 --- a/apps/simplex-directory-service/src/Directory/Service.hs +++ b/apps/simplex-directory-service/src/Directory/Service.hs @@ -36,6 +36,7 @@ import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Protocol (MsgContent (..)) import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Chat.View (serializeChatResponse, simplexChatContact) import Simplex.Messaging.Encoding.String import Simplex.Messaging.TMap (TMap) diff --git a/blog/20240404-why-i-joined-simplex-chat-esraa-al-shafei.md b/blog/20240404-why-i-joined-simplex-chat-esraa-al-shafei.md new file mode 100644 index 0000000000..e554db5102 --- /dev/null +++ b/blog/20240404-why-i-joined-simplex-chat-esraa-al-shafei.md @@ -0,0 +1,38 @@ +--- +layout: layouts/article.html +title: "Why I joined SimpleX Chat - by Esra'a al Shafei" +date: 2024-04-04 +previewBody: blog_previews/20240404.html +image: images/20240404-esraa.png +permalink: "/blog/20240404-why-i-joined-simplex-chat-esraa-al-shafei.html" +--- + +# Why I joined SimpleX Chat + +**Published:** Apr 4, 2024 + +_By [Esra'a al Shafei](https://mastodon.social/@alshafei)_ + +Transitioning from a lifelong career dedicated to nonprofits, including Board roles at organizations like the Wikimedia Foundation, Access Now and Tor, my decision to join SimpleX Chat may come as a surprise to some. But, as I step into this new chapter, I want to share the insights and convictions that have guided me here, shedding light on what I think sets SimpleX Chat apart and why this move feels like an essential learning opportunity. + +The nonprofit world has been my primary focus for decades. My team and I ran the platforms at Majal.org with an extremely limited budget. We had to navigate many complexities and challenges that shadow the nonprofit model. And because we worked primarily in creating applications and tools, a recurring theme has been financial sustainability. Being a Bahrain-based entity for most of these years meant that the many communities we served were not in a position to provide contributions and we were not eligible for most foundation grants. This drastically limited our growth and the reliability of our apps. When we failed to raise sufficient funds or meet our target budgets, we often had to shutter certain applications, sometimes after spending more than 10 years building them. + +With secure and private messaging, the stakes are even graver. Any failure to commit and resource/fund ongoing development, security patches, etc means lives can be at risk. I still believe in nonprofit models, and it’s why I continue to serve them through various volunteer roles. I do also believe that there is room for a mixture of models that, in the case of something as unique as SimpleX Chat, can serve as a fully open and transparent public interest technology while also having a profitable values-aligned company that can keep the lights on to continue developing, expanding, and improving the protocol, network and their reach. + +I’m no stranger to writing about some VC models being [corrupt](https://mastodon.social/@alshafei/112125959080515656). Frankly, I also hold the view that some tech VCs are amongst the [most complicit](https://responsiblestatecraft.org/defense-tech/) in egregious war crimes worldwide, or enabling the [intrusive surveillance](https://mastodon.social/@alshafei/112140566088322925) we’re fighting against. So being part of a VC-funded venture is not a decision I take lightly. However, I have been following SimpleX Chat’s growth since early 2022 when I first met Evgeny at the Mozilla Festival. I appreciated the drive and Evgeny’s firm refusal to settle for the current models of private messaging. We share the belief that messaging is something we need to keep improving and that we must continue pushing its boundaries to make it even more private, secure, usable for groups, and, most importantly - fully decentralized. This is a major undertaking, and it requires funding to achieve. Candidly, I did worry about funding and sustainability because, at the time, SimpleX was still primarily funded by user contributions. + +But even knowing this, I scrutinized SimpleX Chat for taking VC funding ($350K) from Village Global and questioned the individuals featured on its frontpage. I had to speak with Evgeny directly to learn who exactly from this fund was involved, how much power they wielded, if any, and if this changes the ethos of the company - all of which he is already making public. It was only after these discussions that I was comfortable to take a leap of faith and continue to use the app and vouch for its current and future offerings. It required me to question my own views on whether a VC-funded company can actually have major positive contributions to privacy as well as the open ecosystem. + + + +The web has a long history of [trading privacy](https://www.engadget.com/from-its-start-gmail-conditioned-us-to-trade-privacy-for-free-services-120009741.html) for “free” services. Traditionally, these services have also been centralized, closed-source, non-transparent, and profit-oriented. The companies behind these apps and services became prolific because of their disregard of privacy rights, which normalized lucrative surveillance capitalism. There is such an extensive global monopoly that in Africa, only 1 of the 5 biggest messaging apps in Africa isn't owned by Meta, notoriously known for spying not just through its own apps but even through [its competitors](https://qz.com/project-ghostbusters-facebook-meta-wiretap-snapchat-1851366814), – relentless, massive data harvesting that stretches far beyond its own walled gardens: + +Some of the world’s top engineers often go to these companies because of the benefits and financial opportunities. We can question their ethics all day long, but we also need to question if the web would look significantly different if there were as many opportunities at privacy-first companies with purpose and strong, proven moral boundaries, set up in a way that can guarantee operational independence from any shareholders and VCs. + +SimpleX could have taken the route of other companies in the privacy space, whether it’s Skiff which rushed to take a large amount of [VC money](https://techcrunch.com/2022/03/30/skiff-series-a-encrypted-workspaces/) only to [shutter its doors](https://www.techradar.com/computing/cyber-security/skiff-gets-bought-by-notion-raising-privacy-concerns) after an acquisition, leaving its users hanging with many unanswered questions, or giving up control of the company, which would puts its future solely in the hands of VCs with majority ownership. SimpleX aims to prevent this, and in fact has left money on the table to ensure that it does not occur. Had it not been for this information, I would not have joined, and I would have remained a user of the product, albeit a very cautious one, constantly wondering whether it will be sold or corrupted. + +It’s worth noting that some private foundations operate on the VC model in supporting nonprofits, either by requiring Board seats or requesting that their funding be used towards very specific objectives not always in alignment with the organization’s values and mission. It’s also worth noting that [some nonprofits](https://www.engadget.com/2019-05-31-sex-lies-and-surveillance-fosta-privacy.html) actually operate on the models of surveillance and censorship. Therefore, whether an organization or company is VC-backed or a nonprofit should not be the sole factor in deciding whether or not it is trustworthy. Actions are important, with full transparency being one of the most critical factors, and being fully open source being another to attract valid criticisms and audits to ensure any product or protocol lives up to its privacy and security promise. SimpleX Chat prides itself on being both transparent and open, on top of also being fully decentralized. If you’re new to it and eager to know more, you can start with [this overview](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md). + +Another important consideration is that the SimpleX network does have a plan that would rely on users' payments for specific or tailored services, and not on some other sources of revenue or funds (ads, etc.). Building anything that users would be willing to pay for requires substantially more time and resources, hence the VC route to establish a business model that doesn’t translate to the user being the product. But any business services need to be separate from SimpleX as a public interest technology. As outlined in this [recent post](./20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md), I’ll be using my background in nonprofit governance structures to ensure that the SimpleX network protocols evolve under the stewardship of nonprofit entities in various jurisdictions, so that its continued evolution aligns more closely with the vision of community-driven, independent and decentralized governance. This would help create a necessary balance between different structures, in the same way many tech nonprofits also have for-profit subsidiaries to attract fee-for-service agreements to sustain their operations. + +In summary: My decision to join Simplex Chat, despite my deep-rooted beliefs and skepticism towards VC funding, reflects a broader realization: that the fight for privacy, security, and decentralization in today’s web is multifaceted and sometimes requires us to depart from our comfort zones to explore sustainable paths for continuous growth and impact so that open source privacy tools and protocols are no longer “niche”, but universally accessible standards. As long as nothing in this journey compromises our moral principles and integrity, this will remain a very worthwhile goal to pursue. diff --git a/blog/20240416-dangers-of-metadata-in-messengers.md b/blog/20240416-dangers-of-metadata-in-messengers.md new file mode 100644 index 0000000000..3b30003798 --- /dev/null +++ b/blog/20240416-dangers-of-metadata-in-messengers.md @@ -0,0 +1,52 @@ +--- +layout: layouts/article.html +title: "The dangers of metadata in messengers" +date: 2024-04-16 +previewBody: blog_previews/20240416.html +image: images/20240416-metadata.png +imageWide: true +permalink: "/blog/20240416-dangers-of-metadata-in-messengers.html" +--- + +# The dangers of metadata in messengers + +**Published:** Apr 16, 2024 + +_By [Esra'a al Shafei](https://mastodon.social/@alshafei)_ + +In many countries around the world, phone numbers are attached to biometrics data and personal IDs. Telecommunications companies are either government owned or are heavily regulated, privately owned monopolies who comply with most government requests for backdoors or user data. The idea that today, we still need to give out our phone numbers as primary identifiers to be able to use the leading messaging apps should be frowned upon and actively challenged. It’s necessary to advocate for private alternatives in messaging that do not rely on user IDs of any kind - and yes, it’s possible. + +Messaging is still not where it needs to be. Privacy is confused with security, when both are not synonymous, and there are major gaps in helping users understand the fundamental differences. + + + +For example, while WhatsApp messages are [end-to-end encrypted](https://faq.whatsapp.com/820124435853543), let’s consider what you give up when you use it, per its own listings in app stores: + +- App activity (app interactions, in-app search history, and other user-generated content) +- Location +- Financial information (user payment info and payment history) +- Contacts and their phone numbers +- Groups you’re a member of +- When you use the app and how often you use it +- Device and other IDs +- Personal info (email address, user IDs, phone number) + +This is called [metadata](https://en.wikipedia.org/wiki/Metadata). It reveals a wealth of information about you and your connections, and in the hands of a centralized monopoly, this can and does get misused in incredibly dangerous ways. Once such metadata is logged, it can create very detailed profiles about who you are, everywhere you’ve been, and everyone you’ve ever spoken to. In settling for apps that normalize this while giving you the illusion of privacy in their marketing, we are doing ourselves a disservice by accepting this as the default. Collectively, we aren’t doing enough to protect ourselves and our social graph from this invasive overreach. + +When stored, aggregated and analyzed, this metadata provides ample information that could potentially incriminate someone or be submitted to authorities. When WhatsApp and Facebook Messenger enabled end-to-end encryption for messages, of course it was a welcome and widely celebrated change. But it’s important to remember that not all end-to-end encryption utilizes the same standards, [some implementations are more secure](https://simplex.chat/blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.html#how-secure-is-end-to-end-encryption-in-different-messengers) than others, so it’s something that shouldn’t necessarily be accepted at face value. More importantly: collecting and storing an obscene amount of metadata should invite global scrutiny, considering this data is often combined with whatever other information companies like Meta harvest about your identity (which is [a lot](https://www.vox.com/recode/23172691/meta-tracking-privacy-hospitals).) + + + +This is one of the many reasons why we need to resist giving out our phone numbers just to access an app, especially to do something as personal and intimate as private messaging. Even though users can sometimes mask their numbers with a username, their identity on the app is still fundamentally tied to their phone number. App operators have access to this, as well as user contacts. Additionally, with a simple modification to the app's source code, the contacts may also gain access in some cases. This should raise more concerns about privacy, and it makes the need for anonymity difficult to achieve. + +Everyone has a different threat model (and if you don’t yet, now is a good time to [create one](https://www.privacyguides.org/en/basics/threat-modeling/#creating-your-threat-model)). For many users today, WhatsApp and other apps may be sufficient for their specific needs, especially in connecting with families and friends who are already on the app and unlikely to migrate elsewhere. If that suits your life and needs, and if you’re aware and consciously accept the risks, great. + +But we also need to acknowledge that the world is becoming increasingly dangerous in the way AI is being used to [supercharge surveillance](https://www.forbes.com/sites/forbestechcouncil/2024/02/02/artificial-intelligence-the-new-eyes-of-surveillance/?sh=cd57bc214f27), and we need to be educated and aware of the risks this is already having on our lives and what it subjects others in your network to when you choose metadata-heavy apps as your primary form of communication. Having alternatives will always be important, even if it’s not what you default to for everyday messaging. Recognize who in your social circles might require the extra privacy, anonymity and security, so that you can play a role in protecting vulnerable individuals who need it most. The messaging app you choose implicates others as well, not just yourself, and while you personally may not require complete privacy, others might have their lives depend on it. + +End-to-end encryption is a solid start, but it's just the beginning of our pursuit for true privacy and security. True privacy means that even when legal demands come knocking, there's no useful metadata to hand over. It's not enough to just protect the content of messages; we need consistent innovation in protecting metadata too. + +Changing ingrained habits is tough, but your privacy is always worth the fight. Although giants like WhatsApp and Telegram may dominate global messaging for now, increasing concerns about data harvesting and AI-driven surveillance are fueling demand for alternatives. SimpleX Chat aims to be one of those strong alternatives, hence its radical focus on a decentralized framework with no user identifiers (in other words, nothing that uniquely identifies users on the protocol level to their contacts or to the relays) and extra optionality (self-hosting an [SMP server](https://simplex.chat/docs/server.html) or [XFTP server](https://simplex.chat/docs/xftp-server.html), access via Tor, [chat profiles](https://simplex.chat/docs/guide/chat-profiles.html) with incognito mode, etc.) + +As of today, most messaging alternatives, including SimpleX, will have some limitations. But with the limited resources we have, we are committed to daily progress towards creating a truly private messenger that anyone can use while maintaining the features that users have come to know and love in messaging interfaces. We want to be the prime example of a messenger that achieves genuine privacy without compromising it for convenience. We need to be able to reliably move away from small and niche use cases to endorsing and enforcing global standards for privacy and making it accessible for all users regardless of their technical expertise. + +We’re grateful for the users and [donors](https://github.com/simplex-chat/simplex-chat#help-us-with-donations) who have been following along on this journey thus far and helping with feedback, anything from bug reports to identifying potential risks. Building in the open has always been a necessity for transparency and ongoing [auditability](https://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html), because we don’t want anyone to just take our word for it. [See for yourself](https://github.com/simplex-chat) and engage in the discussions. We fully expect you to hold us accountable to our word. diff --git a/blog/README.md b/blog/README.md index 7f27c46c76..78cd0709ad 100644 --- a/blog/README.md +++ b/blog/README.md @@ -1,5 +1,21 @@ # Blog +Apr 16. 2024 [The dangers of metadata in messengers](./20240416-dangers-of-metadata-in-messengers.md) + +_By [Esra'a al Shafei](https://mastodon.social/@alshafei)_ + +It's important not to be complacent with the current standards of messaging, where metadata aggregation is still normalized in apps falsely and dangerously marketed as "private". This is a post exploring the fundamental differences between privacy and security. + +--- + +Apr 4. 2024 [Why I joined SimpleX Chat](./20240404-why-i-joined-simplex-chat-esraa-al-shafei.md) + +_By [Esra'a al Shafei](https://mastodon.social/@alshafei)_ + +Transitioning from a lifelong career dedicated to nonprofits, including Board roles at organizations like the Wikimedia Foundation, Access Now and Tor, my decision to join SimpleX Chat may come as a surprise to some. But, as I step into this new chapter, I want to share the insights and convictions that have guided me here, shedding light on what I think sets SimpleX Chat apart and why this move feels like an essential learning opportunity. + +--- + Mar 23, 2024 [SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration](./20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) SimpleX network: deliver real privacy via a profitable business and non-profit protocol governance: diff --git a/blog/images/20240404-esraa.png b/blog/images/20240404-esraa.png new file mode 100644 index 0000000000..baee242023 Binary files /dev/null and b/blog/images/20240404-esraa.png differ diff --git a/blog/images/20240404-messsaging-apps.png b/blog/images/20240404-messsaging-apps.png new file mode 100644 index 0000000000..6081a468a1 Binary files /dev/null and b/blog/images/20240404-messsaging-apps.png differ diff --git a/blog/images/20240416-metadata.png b/blog/images/20240416-metadata.png new file mode 100644 index 0000000000..743930bf15 Binary files /dev/null and b/blog/images/20240416-metadata.png differ diff --git a/blog/images/20240416-whatsapp.jpg b/blog/images/20240416-whatsapp.jpg new file mode 100644 index 0000000000..399235347a Binary files /dev/null and b/blog/images/20240416-whatsapp.jpg differ diff --git a/cabal.project b/cabal.project index 62115c136b..02deb0dbfb 100644 --- a/cabal.project +++ b/cabal.project @@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 6bc4f6c94e11f59604b0d9c576e62e01bc08b4cd + tag: c00c223f3bb295a62d8507e453bbeac61d102e3a source-repository-package type: git diff --git a/docs/ANDROID.md b/docs/ANDROID.md index fa8921c827..61f81d1a40 100644 --- a/docs/ANDROID.md +++ b/docs/ANDROID.md @@ -3,7 +3,7 @@ title: Accessing files in Android app revision: 07.02.2023 --- -| 07.02.2023 | EN, [CZ](/docs/lang/cs/ANDROID.md), [FR](/docs/lang/fr/ANDROID.md) | +| 07.02.2023 | EN, [CZ](/docs/lang/cs/ANDROID.md), [FR](/docs/lang/fr/ANDROID.md), [PL](/docs/lang/pl/ANDROID.md) | # Accessing files in Android app diff --git a/docs/CLI.md b/docs/CLI.md index baf79bb3bc..d4f799c7af 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -3,7 +3,7 @@ title: Terminal CLI revision: 31.01.2023 --- -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/CLI.md), [CZ](/docs/lang/cs/CLI.md) | +| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/CLI.md), [CZ](/docs/lang/cs/CLI.md), [PL](/docs/lang/pl/CLI.md) | # SimpleX Chat terminal (console) app for Linux/MacOS/Windows diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index aaf452af00..bc013cd7eb 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -3,7 +3,7 @@ title: Contributing guide revision: 31.01.2023 --- -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md) | +| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md), [PL](/docs/lang/pl/CONTRIBUTING.md) | # Contributing guide diff --git a/docs/DOWNLOADS.md b/docs/DOWNLOADS.md index b394c9dd27..0432b0f92e 100644 --- a/docs/DOWNLOADS.md +++ b/docs/DOWNLOADS.md @@ -19,7 +19,7 @@ You can get the latest beta releases from [GitHub](https://github.com/simplex-ch desktop app -Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps. +You can link your mobile device with desktop to use the same profile remotely, but this is only possible when both devices are connected to the same local network. **Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex-desktop-ubuntu-22_04-x86_64.deb). diff --git a/docs/SERVER.md b/docs/SERVER.md index e476c7250c..c29a805452 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -3,7 +3,7 @@ title: Hosting your own SMP Server revision: 31.07.2023 --- -| Updated 05.06.2023 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | +| Updated 05.06.2023 | Languages: EN, [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | # Hosting your own SMP Server diff --git a/docs/SIMPLEX.md b/docs/SIMPLEX.md index 7ed01efa3c..ec25afaf88 100644 --- a/docs/SIMPLEX.md +++ b/docs/SIMPLEX.md @@ -3,7 +3,7 @@ title: SimpleX platform revision: 07.02.2023 --- -| Updated 07.02.2023 | Languages: EN, [FR](/docs/lang/fr/SIMPLEX.md), [CZ](/docs/lang/cs/SIMPLEX.md) | +| Updated 07.02.2023 | Languages: EN, [FR](/docs/lang/fr/SIMPLEX.md), [CZ](/docs/lang/cs/SIMPLEX.md), [PL](/docs/lang/pl/SIMPLEX.md) | # SimpleX platform - motivation and comparison ## Problems diff --git a/docs/TRANSLATIONS.md b/docs/TRANSLATIONS.md index 85b24b368e..d5c1cdef0b 100644 --- a/docs/TRANSLATIONS.md +++ b/docs/TRANSLATIONS.md @@ -3,7 +3,7 @@ title: Contributing translations to SimpleX Chat revision: 19.03.2023 --- -| 19.03.2023 | EN, [CZ](/docs/lang/cs/TRANSLATIONS.md), [FR](/docs/lang/fr/TRANSLATIONS.md) | +| 19.03.2023 | EN, [CZ](/docs/lang/cs/TRANSLATIONS.md), [FR](/docs/lang/fr/TRANSLATIONS.md), [PL](/docs/lang/pl/TRANSLATIONS.md) | # Contributing translations to SimpleX Chat diff --git a/docs/TRANSPARENCY.md b/docs/TRANSPARENCY.md new file mode 100644 index 0000000000..55808c83c8 --- /dev/null +++ b/docs/TRANSPARENCY.md @@ -0,0 +1,29 @@ +--- +title: Transparency Reports +permalink: /transparency/index.html +revision: 09.04.2024 +--- + +# Transparency Reports + +**Updated**: Apr 9, 2024 + +SimpleX Chat Ltd. is a company registered in the UK – it develops communication software enabling users to operate and communicate via SimpleX network, without user profile identifiers of any kind, and without having their data hosted by any network infrastructure operators. + +This page will include any and all reports on requests for user data. + +*To date, we received none*. + +Our objective is to consistently ensure that no user data and absolute minimum of the metadata required for the network to function is available for disclosure by any infrastructure operators, under any circumstances. + +**Helpful resources**: +- [Privacy policy](https://github.com/simplex-chat/simplex-chat/blob/stable/PRIVACY.md) +- [Privacy and security: technical details and limitations](https://github.com/simplex-chat/simplex-chat?tab=readme-ov-file#privacy-and-security-technical-details-and-limitations) +- Whitepaper: + - [Trust in servers](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#trust-in-servers) + - [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#encryption-primitives-used) + - [Threat model](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model) + +Have a more specific question? Reach out to us via [SimpleX Chat](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion) or via email [chat@simplex.chat](mailto:chat@simplex.chat). + +For any sensitive questions please use SimpleX Chat or encrypted email messages using the key for this address from [keys.openpgp.org](https://keys.openpgp.org/search?q=chat%40simplex.chat) (its fingerprint is `FB44 AF81 A45B DE32 7319 797C 8510 7E35 7D4A 17FC`) and make your key available for a secure reply. diff --git a/docs/WEBRTC.md b/docs/WEBRTC.md index 7978d21ec7..8ce31bf959 100644 --- a/docs/WEBRTC.md +++ b/docs/WEBRTC.md @@ -3,7 +3,7 @@ title: Using custom WebRTC ICE servers in SimpleX Chat revision: 31.01.2023 --- -| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md) | +| Updated 31.01.2023 | Languages: EN, [FR](/docs/lang/fr/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md), [PL](/docs/lang/pl/WEBRTC.md) | # Using custom WebRTC ICE servers in SimpleX Chat diff --git a/docs/lang/cs/ANDROID.md b/docs/lang/cs/ANDROID.md index 4edfc3018c..3c401f1d1b 100644 --- a/docs/lang/cs/ANDROID.md +++ b/docs/lang/cs/ANDROID.md @@ -2,7 +2,7 @@ title: Přístup k souborům v aplikaci Android revision: 07.02.2023 --- -| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/ANDROID.md) | +| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/ANDROID.md), [PL](/docs/lang/pl/ANDROID.md) | # Přístup k souborům v aplikaci Android diff --git a/docs/lang/cs/CLI.md b/docs/lang/cs/CLI.md index aa5a2ba281..9cbce8e6fe 100644 --- a/docs/lang/cs/CLI.md +++ b/docs/lang/cs/CLI.md @@ -2,7 +2,7 @@ title: SimpleX Chat terminálová revision: 31.01.2023 --- -| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/CLI.md), [FR](/docs/lang/fr/CLI.md) | +| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/CLI.md), [FR](/docs/lang/fr/CLI.md), [PL](/docs/lang/pl/CLI.md) | # SimpleX Chat terminálová (konzolová) aplikace pro Linux/MacOS/Windows diff --git a/docs/lang/cs/CONTRIBUTING.md b/docs/lang/cs/CONTRIBUTING.md index 26c746e7d2..17574bed4a 100644 --- a/docs/lang/cs/CONTRIBUTING.md +++ b/docs/lang/cs/CONTRIBUTING.md @@ -2,7 +2,7 @@ title: Průvodce přispíváním revision: 31.01.2023 --- -| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/CONTRIBUTING.md), [FR](/docs/lang/fr/CONTRIBUTING.md) | +| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/CONTRIBUTING.md), [FR](/docs/lang/fr/CONTRIBUTING.md), [PL](/docs/lang/pl/CONTRIBUTING.md) | # Průvodce přispíváním diff --git a/docs/lang/cs/README.md b/docs/lang/cs/README.md index 9423cc96b8..764499cdae 100644 --- a/docs/lang/cs/README.md +++ b/docs/lang/cs/README.md @@ -1,4 +1,4 @@ -| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/README.md), [FR](/docs/lang/fr/README.md) | +| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/README.md), [FR](/docs/lang/fr/README.md), [PL](/docs/lang/pl/README.md) | SimpleX logo diff --git a/docs/lang/cs/SERVER.md b/docs/lang/cs/SERVER.md index f8258909bc..3dd2f3780c 100644 --- a/docs/lang/cs/SERVER.md +++ b/docs/lang/cs/SERVER.md @@ -2,7 +2,7 @@ title: Hostování vlastního serveru SMP revision: 05.06.2023 --- -| Aktualizováno 05.06.2023 | Jazyky: CZ, [EN](/docs/SERVER.md), [FR](/docs/lang/fr/SERVER.md) | +| Aktualizováno 05.06.2023 | Jazyky: CZ, [EN](/docs/SERVER.md), [FR](/docs/lang/fr/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | # Hostování vlastního serveru SMP diff --git a/docs/lang/cs/SIMPLEX.md b/docs/lang/cs/SIMPLEX.md index 33d45cec65..0d32fd06d9 100644 --- a/docs/lang/cs/SIMPLEX.md +++ b/docs/lang/cs/SIMPLEX.md @@ -2,7 +2,7 @@ title: Platforma SimpleX revision: 07.02.2023 --- -| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/SIMPLEX.md), [FR](/docs/lang/fr/SIMPLEX.md) | +| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/SIMPLEX.md), [FR](/docs/lang/fr/SIMPLEX.md), [PL](/docs/lang/pl/SIMPLEX.md) | # Platforma SimpleX - motivace a srovnání diff --git a/docs/lang/cs/TRANSLATIONS.md b/docs/lang/cs/TRANSLATIONS.md index b260bd12a6..c22979bdfc 100644 --- a/docs/lang/cs/TRANSLATIONS.md +++ b/docs/lang/cs/TRANSLATIONS.md @@ -2,7 +2,7 @@ title: Přispívání překladů do SimpleX Chat revision: 07.02.2023 --- -| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/TRANSLATIONS.md) | +| Aktualizováno 07.02.2023 | Jazyky: CZ, [EN](/docs/TRANSLATIONS.md), [PL](/docs/lang/pl/TRANSLATIONS.md) | # Přispívání překladů do SimpleX Chat diff --git a/docs/lang/cs/WEBRTC.md b/docs/lang/cs/WEBRTC.md index 77df920255..df63c3205e 100644 --- a/docs/lang/cs/WEBRTC.md +++ b/docs/lang/cs/WEBRTC.md @@ -2,7 +2,7 @@ title: Použití vlastních serverů WebRTC ICE v SimpleX Chat revision: 31.01.2023 --- -| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/WEBRTC.md), [FR](/docs/lang/fr/WEBRTC.md) | +| Aktualizováno 31.01.2023 | Jazyky: CZ, [EN](/docs/WEBRTC.md), [FR](/docs/lang/fr/WEBRTC.md), [PL](/docs/lang/pl/WEBRTC.md) | # Použití vlastních serverů WebRTC ICE v SimpleX Chat diff --git a/docs/lang/fr/ANDROID.md b/docs/lang/fr/ANDROID.md index 0f710542a8..1d100c5c04 100644 --- a/docs/lang/fr/ANDROID.md +++ b/docs/lang/fr/ANDROID.md @@ -2,7 +2,7 @@ title: Accès aux fichiers dans l'application Android revision: 07.02.2023 --- -| 07.02.2023 | FR, [EN](/docs/ANDROID.md), [CZ](/docs/lang/cs/ANDROID.md) | +| 07.02.2023 | FR, [EN](/docs/ANDROID.md), [CZ](/docs/lang/cs/ANDROID.md), [PL](/docs/lang/pl/ANDROID.md) | # Accès aux fichiers dans l'application Android diff --git a/docs/lang/fr/CLI.md b/docs/lang/fr/CLI.md index 6fe9c86d75..bb596491f1 100644 --- a/docs/lang/fr/CLI.md +++ b/docs/lang/fr/CLI.md @@ -2,7 +2,7 @@ title: Application de terminal revision: 31.01.2023 --- -| 31.01.2023 | FR, [EN](/docs/CLI.md), [CZ](/docs/lang/cs/CLI.md) | +| 31.01.2023 | FR, [EN](/docs/CLI.md), [CZ](/docs/lang/cs/CLI.md), [PL](/docs/lang/pl/CLI.md) | # Application de terminal (console) SimpleX Chat pour Linux/MacOS/Windows diff --git a/docs/lang/fr/CONTRIBUTING.md b/docs/lang/fr/CONTRIBUTING.md index 81515b09b2..ea6dcb5ca3 100644 --- a/docs/lang/fr/CONTRIBUTING.md +++ b/docs/lang/fr/CONTRIBUTING.md @@ -2,7 +2,7 @@ title: Guide pour contribuer revision: 31.01.2023 --- -| 31.01.2023 | FR, [EN](/docs/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md) | +| 31.01.2023 | FR, [EN](/docs/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md), [PL](/docs/lang/pl/CONTRIBUTING.md) | # Guide pour contribuer diff --git a/docs/lang/fr/README.md b/docs/lang/fr/README.md index 2f11fe9539..1a69630e03 100644 --- a/docs/lang/fr/README.md +++ b/docs/lang/fr/README.md @@ -4,7 +4,7 @@ [![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) [![Follow on Mastodon](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@simplex) -| 30/03/2023 | FR, [EN](/README.md), [CZ](/docs/lang/cs/README.md) | +| 30/03/2023 | FR, [EN](/README.md), [CZ](/docs/lang/cs/README.md), [PL](/docs/lang/pl/README.md) | SimpleX logo diff --git a/docs/lang/fr/SERVER.md b/docs/lang/fr/SERVER.md index ac9cec7eb9..7bbe315273 100644 --- a/docs/lang/fr/SERVER.md +++ b/docs/lang/fr/SERVER.md @@ -2,7 +2,7 @@ title: Héberger votre propre serveur SMP revision: 05.06.2023 --- -| 05.06.2023 | FR, [EN](/docs/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | +| 05.06.2023 | FR, [EN](/docs/SERVER.md), [CZ](/docs/lang/cs/SERVER.md), [PL](/docs/lang/pl/SERVER.md) | # Héberger votre propre serveur SMP diff --git a/docs/lang/fr/SIMPLEX.md b/docs/lang/fr/SIMPLEX.md index a7134205d0..aacc645054 100644 --- a/docs/lang/fr/SIMPLEX.md +++ b/docs/lang/fr/SIMPLEX.md @@ -2,7 +2,7 @@ title: Plateforme SimpleX revision: 07.02.2023 --- -| 07.02.2023 | FR, [EN](/docs/SIMPLEX.md), [CZ](/docs/lang/cs/SIMPLEX.md) | +| 07.02.2023 | FR, [EN](/docs/SIMPLEX.md), [CZ](/docs/lang/cs/SIMPLEX.md), [PL](/docs/lang/pl/SIMPLEX.md) | # Plateforme SimpleX - motivation et comparaison diff --git a/docs/lang/fr/TRANSLATIONS.md b/docs/lang/fr/TRANSLATIONS.md index e85a4a8513..1e216900e7 100644 --- a/docs/lang/fr/TRANSLATIONS.md +++ b/docs/lang/fr/TRANSLATIONS.md @@ -2,7 +2,7 @@ title: Contribuer aux traductions de SimpleX Chat revision: 19.03.2023 --- -| 19.03.2023 | FR, [EN](/docs/TRANSLATIONS.md), [CZ](/docs/lang/cs/TRANSLATIONS.md) | +| 19.03.2023 | FR, [EN](/docs/TRANSLATIONS.md), [CZ](/docs/lang/cs/TRANSLATIONS.md), [PL](/docs/lang/pl/TRANSLATIONS.md) | # Contribuer aux traductions de SimpleX Chat diff --git a/docs/lang/fr/WEBRTC.md b/docs/lang/fr/WEBRTC.md index 47296274b3..381677b3f6 100644 --- a/docs/lang/fr/WEBRTC.md +++ b/docs/lang/fr/WEBRTC.md @@ -2,7 +2,7 @@ title: Utilisation de serveurs WebRTC ICE personnalisés dans SimpleX Chat revision: 31.01.2023 --- -| 31.01.2023 | FR, [EN](/docs/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md) | +| 31.01.2023 | FR, [EN](/docs/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md), [PL](/docs/lang/pl/WEBRTC.md) | # Utilisation de serveurs WebRTC ICE personnalisés dans SimpleX Chat diff --git a/docs/lang/pl/ANDROID.md b/docs/lang/pl/ANDROID.md new file mode 100644 index 0000000000..ce422ab3b5 --- /dev/null +++ b/docs/lang/pl/ANDROID.md @@ -0,0 +1,58 @@ +--- +title: Dostęp do plików w aplikacji Androidowej +revision: 07.02.2023 +--- + +| 07.02.2023 | PL, [EN](/docs/ANDROID.md), [CZ](/docs/lang/cs/ANDROID.md), [FR](/docs/lang/fr/ANDROID.md) | + +# Dostęp do plików w aplikacji Androidowej + +SimpleX wykorzystuje bazy danych i przechowuje ustawienia w prywatnym katalogu w systemie Android. Katalog ten zawiera: + +- bazy danych +- wysłane i odebrane pliki +- pliki tymczasowe, które zostaną usunięte, gdy nie są już potrzebne +- ustawienia użytkownika. + +Jeśli chcesz zobaczyć, co jest przechowywane w katalogu SimpleX, musisz mieć: + +- System operacyjny oparty na systemie Unix (lub [MinGW](https://www.mingw-w64.org/downloads/) na Windowsie) +- narzędzie ADB (Android Debug Bridge) zainstalowane na komputerze ([pobierz je tutaj](https://developer.android.com/studio/releases/platform-tools) i zainstaluj) +- urządzenie podłączone przez USB lub Wi-Fi do komputera. + +## Proces: + +- otwórz SimpleX, przejdź do `Hasło do bazy danych i eksport`, włącz `Kopia zapasowa danych aplikacji`. To sprawi, że następne kroki będą działać. +- _opcjonalnie_: jeśli chcesz wyświetlić zawartość bazy danych, zmień hasło bazy danych z losowego na swoje. Aby to zrobić, zatrzymaj czat na ekranie `Hasło do bazy danych i eksport`, otwórz `Hasło do bazy danych`, wprowadź nowe hasło i potwierdź je, a następnie zatwierdź. Nie zapomnij go, w przeciwnym razie utracisz wszystkie dane w przypadku, gdy zostaniesz ponownie poproszony o hasło. +- otwórz emulator terminala (Windows CMD/Powershell nie zadziała) i zmień katalog na ten, którego chcesz użyć do przechowywania kopii zapasowej: + +```bash +cd /tmp # to tylko przykład +``` +Następne uruchom: +```bash +adb -d backup -f chat.ab -noapk chat.simplex.app && +tail -n +5 chat.ab > chat.dat && +printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - chat.dat > chat.gz && +tar -xvzf chat.gz +``` + +Teraz odblokuj urządzenie i potwierdź operację tworzenia kopii zapasowej bez użycia hasła do szyfrowania, w przeciwnym razie polecenia nie będą działać. + +Po tym kopia zapasowa powinna zostać zrobiona. Jeśli pojawi się błąd `tar: Error is not recoverable: exiting now`, ale wcześniej pojawiło się kilka nazw plików, nie martw się, wszystko jest w porządku. + +Teraz zapisane pliki będą w `./apps/chat.simplex.app/`. + +Pamiętaj, że jeśli korzystasz z nowej wersji SimpleX, bazy danych będą zaszyfrowane i nie będziesz w stanie przeglądać ich zawartości bez użycia aplikacji `sqlcipher` oraz gdy nie znasz hasła deszyfrującego (musisz najpierw zmienić je na swoje z losowo wygenerowanego w aplikacji). + +## Odszyfrowywanie baz danych + +Aby wyświetlić dane bazy danych, należy je najpierw odszyfrować. Zainstaluj `sqlcipher` używając ulubionego menedżera pakietów i uruchom następujące polecenia w katalogu z bazami danych: +```bash +sqlcipher files_chat.db +pragma key="youDecryptionPassphrase"; +# Upewnij się, że to działa +select * from users; +``` + +Jeśli zobaczysz `Parse error: no such table: users`, upewnij się, że wprowadzono prawidłowe hasło i zostało ono zmienione z losowego w aplikacji na Androida (jeśli oczywiście pobrano tę bazę danych z urządzenia z Androidem). diff --git a/docs/lang/pl/CLI.md b/docs/lang/pl/CLI.md new file mode 100644 index 0000000000..585eca3e31 --- /dev/null +++ b/docs/lang/pl/CLI.md @@ -0,0 +1,244 @@ +--- +title: Aplikacja konsolowa +revision: 31.01.2023 +--- + +| Updated 31.01.2023 | Języki: PL, [EN](/docs/CLI.md), [FR](/docs/lang/fr/CLI.md), [CZ](/docs/lang/cs/CLI.md) | + +# Terminalowa (konsolowa) aplikacja SimpleX Chat dla systemów Linux/MacOS/Windows + +## Spis treści + +- [Funkcje czatu w terminalu](#funkcje-czatu-w-terminalu) +- [Instalacja](#🚀-instalacja) + - [Pobieranie klienta czatu](#pobieranie-klienta-czatu) + - [Linux i MacOS](#linux-i-macos) + - [Windows](#windows) + - [Budowanie z kodu źródłowego](#budowanie-z-kodu-źródłowego) + - [Używając dockera](#using-docker) + - [Używając Haskella na dowolnym systemie operacyjnym](#używając-haskella-na-dowolnym-systemie-operacyjnym) +- [Używanie](#używanie) + - [Używanie klienta czatu](#używanie-klienta-czatu) + - [Dostęp do serwerów wiadomości przez Tor](#dostęp-do-serwerów-wiadomości-przez-tor) + - [Jak używać czatu SimpleX](#jak-używać-czatu-simplex) + - [Grupy](#grupy) + - [Wysyłanie plików](#wysyłanie-plików) + - [Adresy kontaktowe użytkowników](#adresy-kontaktowe-użytkowników) + +## Funkcje czatu w terminalu + +- Konwersacje 1 na 1 z wieloma osobami w tym samym oknie terminala. +- Wiadomości grupowe. +- Wysyłanie plików do kontaktów i grup. +- Adresy kontaktowe użytkowników - nawiązywanie połączeń za pomocą linków kontaktowych wielokrotnego użytku. +- Wiadomości przechowywane w lokalnej bazie danych SQLite. +- Automatycznie wypełniana nazwa odbiorcy - po nawiązaniu połączenia wystarczy po prostu napisać wiadomość, aby odpowiedzieć nadawcy. +- Dostępne wstępnie skonfigurowane przykładowe serwery SMP - można też użyć [własnego serwera](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent). +- Żadna globalna tożsamość ani nazwy użytkowników nie są widoczne dla serwera (serwerów), co zapewnia pełną prywatność kontaktów i rozmów. +- Dwie warstwy szyfrowania E2E (double-ratchet dla połączeń dwukierunkowych, przy użyciu negocjacji klucza X3DH z efemerycznymi kluczami Curve448 i NaCl crypto_box dla kolejek SMP, przy użyciu kluczy Curve25519) oraz przekazywanie kluczy odbiorców za pomocą komunikacji out-of-band (zobacz [Jak używać czatu SimpleX](#how-to-use-simplex-chat)). +- Weryfikacja integralności wiadomości (poprzez uwzględnienie hashu poprzedniej wiadomości). +- Uwierzytelnianie każdego polecenia/wiadomości przez serwery SMP za pomocą automatycznie generowanych kluczy Ed448. +- Szyfrowanie transmisji przy użyciu TLS 1.3. +- Dodatkowe szyfrowanie wiadomości z serwera SMP do odbiorcy aby utrudnić możliwość korelacji ruchu. + +Klucze publiczne biorące udział podczas wymiany kluczy nie są używane jako tożsamość, są one generowane losowo dla każdego kontaktu. + +Aby uzyskać szczegółowe informacje techniczne zobacz [używane metody szyfrowania](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used). + + + +## 🚀 Instalacja + +### Pobieranie klienta czatu + +#### Linux i MacOS + +By **zainstalować** lub **zaktualizować** `simplex-chat`, należy uruchomić skrypt instalacyjny. Aby to zrobić, użyj następującego polecenia cURL lub Wget: + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +```sh +wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +Po pobraniu klienta czatu można go uruchomić za pomocą polecenia `simplex-chat`. + +Możesz również ręcznie pobrać plik binarny czatu dla swojego systemu z [najnowszej stabilnej wersji](https://github.com/simplex-chat/simplex-chat/releases) i uczynić go uruchamialnym w sposób pokazany poniżej. + +```sh +chmod +x +mv ~/.local/bin/simplex-chat +``` + +(lub użyj innej preferowanej lokalizacji w `PATH`). + +Na MacOS musisz również [zezwolić Gatekeeperowi, by go uruchomić](https://support.apple.com/en-us/HT202491). + +#### Windows + +```sh +move %APPDATA%/local/bin/simplex-chat.exe +``` + +### Budowanie z kodu źródłowego + +> **Uwaga:** aby zbudować aplikację użyj [wersji stabilnej](https://github.com/simplex-chat/simplex-chat/tree/stable). + +#### Używając Dockera + +Na Linuxie, aby zbudować plik wykonywalny możesz użyć [docker build z customowym outputem](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): + +```shell +git clone git@github.com:simplex-chat/simplex-chat.git +cd simplex-chat +git checkout stable +DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . +``` + +> **Uwaga:** Jeśli napotkasz błąd `` version `GLIBC_2.28' not found ``, przebuduj go z obrazem bazowym `haskell:8.10.7-stretch` (zmień go w Twoim lokalnym pliku [Dockerfile](Dockerfile)). + +#### Używając Haskella na dowolnym systemie operacyjnym + +1. Zainstaluj [Haskell GHCup](https://www.haskell.org/ghcup/), GHC 9.6.3 i cabal 3.10.1.0: + +```shell +curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | sh +``` + +Możesz użyć polecenia `ghcup tui`, aby sprawdzić lub dodać wersje GHC i cabal. + +2. Sklonuj kod źródłowy: + +```shell +git clone git@github.com:simplex-chat/simplex-chat.git +cd simplex-chat +git checkout stable +# lub aby zbudować konkretną wersję: +# git checkout v5.3.0-beta.8 +``` + +`master` to branch deweloperski, może on zawierać niestabilny kod. + +3. Przygotowywanie systemu: + +Na Linuxie: + +```shell +apt-get update && apt-get install -y build-essential libgmp3-dev zlib1g-dev +cp scripts/cabal.project.local.linux cabal.project.local +``` + +Na Macu: + +``` +brew install openssl@1.1 +cp scripts/cabal.project.local.mac cabal.project.local +``` + +Może być konieczna zmiana cabal.project.local, aby wskazać poprawną lokalizację openssl + +4. Budowanie aplikacji: + +```shell +cabal update +cabal install simplex-chat +``` + +## Używanie + +### Używanie klienta czatu + +Aby uruchomić klienta, uruchom w terminalu polecenie `simplex-chat`. + +Domyślnie katalog z danymi aplikacji jest tworzony w katalogu domowym (`~/.simplex`, lub `%APPDATA%/simplex` na Windowsie), a dwa pliki danych SQLite `simplex_v1_chat.db` i `simplex_v1_agent.db` są w nim zainicjowane. + +Aby wskazać inny prefiks ścieżki dla plików bazy danych, należy użyć polecenia `-d`: + +```shell +$ simplex-chat -d alice +``` + +Uruchomienie powyższego przykładu spowoduje utworzenie plików baz danych `alice_v1_chat.db` i `alice_v1_agent.db` w bieżącym katalogu. + +Trzy domyślne serwery SMP są hostowane na Linode - są one [wstępnie skonfigurowane w aplikacji](https://github.com/simplex-chat/simplex-chat/blob/stable/src/Simplex/Chat/Options.hs#L42). + +Jeśli posiadasz własny serwer(y) SMP, możesz skonfigurować klienta poprzez opcję `-s`: + +```shell +$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com +``` + +Ciąg zakodowany w Base64url poprzedzający adres serwera to odcisk palca certyfikatu offline serwera, który jest weryfikowany przez klienta podczas handshake'a TLS. + +Po konfiguracji innego serwera w swoim kliencie nadal możesz rozmawiać z ludźmi używającymi domyślnego lub dowolnego innego serwera - ustawienie to wpływa tylko na lokalizację kolejki wiadomości podczas nawiązywania połączenia (a kolejka odpowiedzi może znajdować się na zupełnie innym serwerze, zgodnie z ustawieniami klienta rozmówcy). + +Polecenie `simplex-chat -h` pokazuje wszystkie dostępne opcje. + +### Dostęp do serwerów wiadomości przez Tor + +Zainstaluj Tor i uruchom go jako proxy SOCKS5 na porcie 9050, przykład dla MacOS: + +``` +brew install tor +brew services start tor +``` + +Użyj opcji `-x`, aby uzyskać dostęp do serwerów przez Tor: + +``` +simplex-chat -x +``` + +Możesz także użyć opcji `--socks-proxy=ipv4:port` lub `--socks-proxy=:port`, aby skonfigurować adres i port serwera proxy SOCKS5, przykładowo jeśli uruchamiasz go na innym hoście lub porcie. + +### Jak używać czatu SimpleX + +Po uruchomieniu czatu zostaniesz poproszony o podanie swojej "nazwy wyświetlanej" oraz opcjonalnej "pełnej nazwy" w celu utworzenia lokalnego profilu czatu. Nazwa wyświetlana jest aliasem, za pomocą którego kontakty mogą się do ciebie odnosić - nie jest ona unikalna i nie służy jako globalna tożsamość. Jeśli kilka kontaktów wybrało tę samą nazwę wyświetlaną, klient czatu dodaje numeryczną końcówkę (sufiks) do ich lokalnej nazwy wyświetlanej. + +Poniższy schemat przedstawia sposób łączenia się z kontaktem i wysyłania do niego wiadomości: + +
+ +
+ +Gdy już skonfigurujesz swój profil lokalny, wpisz `/c` (oznaczające `/connect`), aby utworzyć nowe połączenie i wygenerować zaproszenie. Wyślij to zaproszenie do swojego kontaktu za pośrednictwem dowolnego innego kanału komunikacji. + +Możesz utworzyć wiele zaproszeń, kilkukrotnie wpisując `/connect` i wysłać te zaproszenia do kontaktów, z którymi chcesz się połączyć. + +Zaproszenie może być użyte tylko jeden raz i nawet jeśli zostanie ono przechwycone, atakujący nie będzie mógł go użyć do wysłania do Ciebie wiadomości za pośrednictwem tej kolejki, gdy Twój kontakt potwierdzi, że połączenie zostało nawiązane. Zobacz omówienie protokołu agenta dla [formatu zaproszeń](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request). + +Kontakt, który otrzymał zaproszenie powinien wpisać `/c `, aby zaakceptować połączenie. Spowoduje to nawiązanie połączenia, a obie strony zostaną o tym powiadomione. + +Następnie można użyć komendy `@ ` do wysłania wiadomości. Możesz także po prostu zacząć pisać wiadomość, aby wysłać ją do kontaktu, który był ostatni. + +Użyj `/help` na czacie, by uzyskać listę pozostałych dostępnych komend. + +### Grupy + +Aby utworzyć grupę, użyj `/g `, a następnie dodaj do niej kontakty za pomocą `/a `. Możesz wysyłać wiadomości do grupy wpisując `# `. Użyj `/help groups`, by uzyskać listę pozostałych dostępnych komend. + +![simplex-chat](../images/groups.gif) + +> **Uwaga**: informacje o grupach nie są przechowywane na żadnym serwerze, są one zapisywane jako lista członków w bazie danych aplikacji klientów, do których będą wysyłane wiadomości. + +### Wysyłanie plików + +Możesz wysłać plik do kontaktu za pomocą `/f @ <ścieżka_do_pliku>` - odbiorca będzie musiał go zaakceptować przed rozpoczęciem wysyłania. Użyj `/help files`, by uzyskać listę pozostałych dostępnych komend. + +![simplex-chat](../images/files.gif) + +Możesz wysyłać pliki do grupy za pomocą `/f # <ścieżka_do_pliku>`. + +### Adresy kontaktowe użytkowników + +Alternatywą dla jednorazowych linków zapraszających są adresy długoterminowe. Możesz je utworzyć za pomocą `/ad` (oznaczające `/address`). Utworzony adres może być następnie udostępniony za pośrednictwem dowolnego innego kanału komunikacji i użyty przez innych użytkowników jako link do prośby o kontakt używając `/c `. + +Prośby o kontakt możesz przyjąć za pomocą komendy `/ac ` oraz odrzucić za pomocą `/rc `. + +"Długoterminowy" adres użytkownika jest długoterminowy w tym sensie, że jest to link wielokrotnego użytku - może być używany do momentu usunięcia go przez użytkownika. Po usunięciu wszystkie nawiązane połączenia pozostaną aktywne (w przeciwieństwie do tego, jak działa to w przypadku poczty e-mail, gdy zmiana adresu powoduje, że ludzie nie mogą już wysyłać do siebie wiadomości). + +Użyj `/help address`, by uzyskać listę pozostałych dostępnych komend. + +![simplex-chat](../images/user-addresses.gif) diff --git a/docs/lang/pl/CONTRIBUTING.md b/docs/lang/pl/CONTRIBUTING.md new file mode 100644 index 0000000000..4f62217479 --- /dev/null +++ b/docs/lang/pl/CONTRIBUTING.md @@ -0,0 +1,121 @@ +--- +title: Poradnik wspierania projektu +revision: 31.01.2023 +--- + +| Updated 31.01.2023 | Języki: PL, [EN](/docs/CONTRIBUTING.md) [FR](/docs/lang/fr/CONTRIBUTING.md), [CZ](/docs/lang/cs/CONTRIBUTING.md) | + +# Poradnik wspierania projektu + +## Kompilacja z włączonym szyfrowaniem SQLCipher + +Dodaj `cabal.project.local` do katalogu głównego projektu z lokalizacją nagłówków i bibliotek OpenSSL oraz flagą ustawiającą tryb szyfrowania: + +``` +cp scripts/cabal.project.local.mac cabal.project.local +# lub +# cp scripts/cabal.project.local.linux cabal.project.local +``` + +## OpenSSL na MacOS + +MacOS ma domyślnie zainstalowany LibreSSL, OpenSSL musi być zainstalowany, aby skompilować SimpleX z kodu źródłowego. + +OpenSSL można zainstalować za pomocą `brew install openssl@1.1` + +Będziesz musiał dodać `/opt/homebrew/opt/openssl@1.1/bin` do swojego PATH, aby wszystko działało poprawnie + + +## Branche projektu + +**W repo simplex-chat** + + +- `stable` - stabilne wydanie aplikacji, może być używane do aktualizacji poprzedniego stabilnego wydania (GHC 9.6.3). + +- `stable-android` - używane do budowania stabilnej biblioteki rdzenia Androida z Nix (GHC 8.10.7) - tylko dla Androida armv7a. + +- `stable-ios` - używane do budowania stabilnej biblioteki rdzenia iOS z Nix (GHC 8.10.7) - ten branch powinien być taki sam jak `stable-android` z wyjątkiem plików konfiguracyjnych Nix. Przestarzałe. + +- `master` - branch dla wydań wersji beta (GHC 9.6.3). + +- `master-ghc8107` - branch dla wydań wersji beta (GHC 8.10.7). Przestarzałe. + +- `master-android` - używane do budowania biblioteki rdzenia Androida w wersji beta z Nix (GHC 8.10.7) - tylko dla Androida armv7a. + +- `master-ios` - służy do budowania biblioteki rdzenia beta iOS z Nix (GHC 8.10.7). Przestarzałe. + +- `windows-ghc8107` - branch do kompilacji głównej biblioteki Windows (GHC 8.10.7). Przestarzałe? + +Branche `master-ios` i `windows-ghc8107` powinny być takie same jak `master-ghc8107` z wyjątkiem plików konfiguracyjnych Nix. + +**W repo simplexmq** + +- `master` - używa GHC 9.6.3, jego commit powinien być użyty w branchu `master` repo simplex-chat. + +- `master-ghc8107` - jego commit powinien być użyty w branchu `master-android` (i `master-ios`) repo simplex-chat. Przestarzałe. + +## Development i proces wydawania + +1. Tworzenie PR-ów do brancha `master` _tylko_ dla repozytoriów simplex-chat i simplexmq. + +2. Jeśli repozytorium simplexmq zostało zmienione, aby skompilować mobilne biblioteki rdzenia należy połączyć jego branch `master` z branchem `master-ghc8107`. + +3. Aby skompilować podstawowe biblioteki dla Androida, iOS i Windows: + +- scal branch `master` z branchem `master-android`. + +- Zaktualizuj kod, aby był kompatybilny z GHC 8.10.7 (patrz niżej). + +- push do GitHuba. + +4. Wszystkie biblioteki powinny być budowane z brancha `master`, Android armv7a - z brancha `master-android`. + +5. Aby zbudować aplikacje Desktop i CLI, należy utworzyć tag w branchu `master`, pliki APK powinny być dołączone do wydania. + +6. Po publicznym wydaniu w App Store i Play Store, scal: + +- `master` do `stable` + +- `master` do `master-android` (i skompiluj/zaktualizuj kod) + +- `master-android` do `stable-android`. + +7. Branch `master` repo simplexmq powinien zostać niezależnie scalony z branchem `stable` w wydaniach stabilnych. + + +## Różnice pomiędzy GHC 8.10.7 i GHC 9.6.3 + +1. Główna różnica związana jest z rozszerzeniem `DuplicateRecordFields`. + +W GHC 9.6.3 nie jest już możliwe określenie typu podczas korzystania z selektorów, zamiast tego używane jest rozszerzenie OverloadedRecordDot i składnia, które muszą zostać usunięte w GHC 8.10.7: + +```haskell +{-# LANGUAGE DuplicateRecordFields #-} +-- Użyj tego w GHC 9.6.3, gdy jest to potrzebne +{-# LANGUAGE OverloadedRecordDot #-} + +-- syntax GHC 9.6.3 +let x = record.field + +-- syntax GHC 8.10.7 usunięty w GHC 9.6.3 +let x = field (record :: Record) +``` + +Nadal możliwe jest określenie typu podczas korzystania ze składni aktualizacji rekordu, użyj tej reguły, aby wyłączyć ostrzeżenie kompilatora: + +```haskell +-- Użyj tego w GHC 9.6.3, gdy jest to potrzebne +{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} + +let r' = (record :: Record) {field = value} +``` + +2. Większość funkcji monad musi być teraz importowana z `Control.Monad`, a nie z konkretnych modułów monad (np. `Control.Monad.Except`). + +```haskell +-- Użyj tego w GHC 9.6.3, gdy jest to potrzebne +import Control.Monad +``` + +[Ten PR](https://github.com/simplex-chat/simplex-chat/pull/2975/files) opisuje wszystkie różnice. diff --git a/docs/lang/pl/README.md b/docs/lang/pl/README.md new file mode 100644 index 0000000000..1fa9aa20eb --- /dev/null +++ b/docs/lang/pl/README.md @@ -0,0 +1,435 @@ +[![build](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml/badge.svg?branch=stable)](https://github.com/simplex-chat/simplex-chat/actions/workflows/build.yml) +[![Pobieranie z GitHuba](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) +[![Wydanie na Githubie](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases) +[![Dołącz na Reddicie](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat) +![Śledź na Mastodonie](https://img.shields.io/mastodon/follow/108619463746856738?domain=https%3A%2F%2Fmastodon.social&style=social) + +| 30/03/2023 | PL, [EN](/README.md), [FR](/docs/lang/fr/README.md), [CZ](/docs/lang/cs/README.md) | + +SimpleX logo + +# SimpleX - pierwszy komunikator bez jakichkolwiek identyfikatorów użytkowników - w 100% prywatny z założenia! + +[](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html)     [](https://www.privacyguides.org/en/real-time-communication/#simplex-chat)     [](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) + +## Witamy w SimpleX Chat! + +1. 📲 [Zainstaluj aplikację](#zainstaluj-aplikację). +2. ↔️ [Połącz się z naszym zespołem](#połącz-się-z-naszym-zespołem), [dołącz do grup użytkowników](#dołącz-do-grup-użytkowników) oraz [śledź nasze aktualizacje](#śledź-nasze-aktualizacje). +3. 🤝 [Wykonaj prywatne połączenie](#wykonaj-prywatne-połączenie) ze znajomym. +4. 🔤 [Pomóż w tłumaczeniu SimpleX Chat](#pomóż-nam-przetłumaczyć-simplex-chat). +5. ⚡️ [Kontrybuuj](#kontrybuuj) i [wesprzyj nas dotacjami](#wesprzyj-nas-dotacjami). + +[Dowiedz się więcej na temat SimpleX Chat](#informacje). + +## Zainstaluj aplikację + +[Aplikacja iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084) +  +[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app) +  +[F-Droid](https://app.simplex.chat) +  +[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu) +  +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) + +- 🖲 Chroni Twoje wiadomości i metadane - z kim rozmawiasz i kiedy. +- 🔐 Szyfrowanie end-to-end double ratchet, z dodatkową warstwą szyfrowania. +- 📱 Aplikacje mobilne dla Androida ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk)) oraz [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). +- 🚀 [TestFlight dla iOS](https://testflight.apple.com/join/DWuT2LQu) z nowymi funkcjami na tydzień-dwa wcześniej - **limitowane do 10,000 użytkowników**! +- 🖥 Dostępny jako terminalowa (konsolowa) [aplikacja / CLI](#zap-quick-installation-of-a-terminal-app) na Linuxa, MacOSa, Windowsa. + +## Połącz się z naszym zespołem + +Możesz połączyć się z naszym zespołem za pośrednictwem aplikacji, korzystając z przycisku "czat z deweloperami" który dostępny jest w przypadku gdy nie masz konwersacji na swoim profilu, opcji "wysyłaj pytania i pomysły" w ustawieniach aplikacji lub za pośrednictwem naszego [adresu SimpleX](https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23%2F%3Fv%3D1%26dh%3DMCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion). Please connect to: + +- by zadać dowolne pytania +- by zasugerować dowolne ulepszenia +- by udostępnić nam wszystko co istotne + +Odpowiadamy na pytania manualnie, więc nie jest to natychmiastowe - może to potrwać do 24 godzin. + +Jeśli jesteś zainteresowany pomocą w integracji otwartoźródłowych modeli językowych i [dołączeniem do naszego zespołu](./docs/lang/pl/JOIN_TEAM.md), skontaktuj się z nami. + +## Dołącz do grup użytkowników + +Możesz dołączyć do grup utworzonych przez innych użytkowników za pośrednictwem nowej [usługi katalogowej](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion). Nie jesteśmy odpowiedzialni za treści udostępniane w tych grupach. + +**Uwaga**: Poniższe grupy zostały utworzone, aby użytkownicy mogli zadawać pytania, zgłaszać sugestie i zadawać pytania dotyczące wyłącznie SimpleX Chat. + +Możesz również: +- krytykować aplikację i dokonywać porównań z innymi komunikatorami. +- udostępniać nowe komunikatory, które Twoim zdaniem mogą być interesujące z punktu widzenia prywatności, o ile nie spamujesz. +- udostępniać niektóre publikacje związane z prywatnością, raczej dość rzadko. +- po wstępnym zatwierdzeniu przez administratora w prywatnej wiadomości, udostępnić link do utworzonej grupy, ale tylko raz. Gdy grupa ma więcej niż 10 członków, może zostać przesłana do [SimpleX Directory Service](./docs/DIRECTORY.md), gdzie nowi użytkownicy będą mogli ją odkryć. + +Musisz: +- być uprzejmym wobec innych użytkowników. +- unikać spamu (zbyt częstych wiadomości, nawet jeśli są istotne). +- unikać ataków osobistych lub wrogiego nastawienia. +- unikać dzielenia się treściami, które nie są związane z powyższymi kwestiami (co obejmuje między innymi dyskusje na temat polityki lub innych aspektów życia społecznego niż prywatność, bezpieczeństwo, technologia i komunikacja, dzielenie się treściami, które mogą zostać uznane za obraźliwe przez innych użytkowników itp.). + +Wiadomości nieprzestrzegające tych zasad będą usuwane, prawo do wysyłania wiadomości może zostać odebrane ich autorom, a dostęp nowych członków do grupy może zostać tymczasowo ograniczony, aby zapobiec ponownemu dołączeniu pod inną nazwą - nasza niedoskonała moderacja grupy nie ma obecnie lepszego rozwiązania. + +Jeśli chcesz zadać jakieś pytania, możesz dołączyć do anglojęzycznej grupy użytkowników: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) + +Istnieje również [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D) dla deweloperów, którzy na bazie platformy SimpleX tworzą: + +- czatboty i automatyzacje +- integracje z innymi aplikacjami +- aplikacje społecznościowe i serwisy +- itp. + +Istnieją grupy w innych językach, na które przetłumaczyliśmy interfejs aplikacji. Grupy te służą do testowania i zadawania pytań innym użytkownikom SimpleX Chat: + +[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking). + +Możesz do nich dołączyć otwierając te linki w aplikacji lub otwierając je w przeglądarce na komputerze i skanując kod QR. + +## Śledź nasze aktualizacje + +Nasze aktualizacje i wydania publikujemy za pośrednictwem: + +- [Reddita](https://www.reddit.com/r/SimpleXChat/), [Twittera](https://twitter.com/SimpleXChat), [Lemmy](https://lemmy.ml/c/simplex), [Mastodona](https://mastodon.social/@simplex) oraz [Nostr](https://snort.social/p/npub1exv22uulqnmlluszc4yk92jhs2e5ajcs6mu3t00a6avzjcalj9csm7d828). +- [profilu zespołu](#connect-to-the-team) w aplikacji SimpleX. +- [bloga](https://simplex.chat/blog/) oraz [feedu RSS](https://simplex.chat/feed.rss). +- [listy mailingowej](https://simplex.chat/#join-simplex), bardzo rzadko. + +## Wykonaj prywatne połączenie + +Aby nawiązać połączenie i rozpocząć wysyłanie wiadomości, należy udostępnić znajomemu łącze lub zeskanować kod QR z jego telefonu, osobiście lub podczas połączenia wideo. + +Kanał, za pośrednictwem którego udostępniasz link, nie musi być bezpieczny - wystarczy, że możesz potwierdzić, kto wysłał Ci wiadomość i że połączenie SimpleX zostało nawiązane. + +Wykonaj prywatne połączenie Conversation Połączenie wideo + +Po wykonaniu połączenia możesz [zweryfikować kod bezpieczeństwa połączenia](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md#connection-security-verification). + +## Poradnik dla użytkownika (NOWE) + +Przeczytaj o funkcjach i ustawieniach aplikacji w nowym [Przewodniku użytkownika](./docs/guide/README.md). + +## Pomóż nam przetłumaczyć SimpleX Chat + +Dzięki naszym użytkownikom i [Weblate](https://hosted.weblate.org/engage/simplex-chat/), aplikacje SimpleX Chat, strona internetowa i dokumenty są tłumaczone na wiele innych języków. + +Dołącz do naszych tłumaczy, aby pomóc SimpleX w rozwoju! + +|region|język |kontrybutor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) i [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[strona](https://simplex.chat)|dokumenty na GitHubie| +|:----:|:-------:|:---------:|:---------:|:---------:|:---------:| +|🇬🇧 en|English | |✓|✓|✓|✓| +|ar|العربية |[jermanuts](https://github.com/jermanuts)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/ar/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ar/)
-|[![website](https://hosted.weblate.org/widgets/simplex-chat/ar/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ar/)|| +|🇧🇬 bg|Български | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/bg/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/bg/)
[![ios app](https://hosted.weblate.org/widget/simplex-chat/ios/bg/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)||| +|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/cs/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/cs/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/cs/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/cs/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)| +|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/de/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/de/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/de/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/de/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/de/)|| +|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)|| +|🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)|| +|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)| +|🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)
-||| +|🇭🇺 hu|Magyar | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/hu/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/hu/)
-||| +|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)|| +|🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)|| +|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)|| +|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pl/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/pl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)||| +|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)
-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)|| +|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)||| +|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)||| +|🇹🇷 tr|Türkçe | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/tr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/tr/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/tr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/tr/)||| +|🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)|| +|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)

[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)
[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)
 |

[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)|| + +Trwają prace nad wersjami językowymi: Arabski, japoński, koreański, portugalski i [inne](https://hosted.weblate.org/projects/simplex-chat/#languages). Będziemy dodawać kolejne języki, gdy niektóre z już dodanych zostaną ukończone - zasugeruj nowe języki, przejrzyj [przewodnik po tłumaczeniach](./docs/lang/pl/TRANSLATIONS.md) i skontaktuj się z nami! + +## Kontrybuuj + +Chcielibyśmy, abyś przyczynił się do naszego rozwoju! Możesz nam pomóc: + +- [dzieląc się motywem kolorystycznym](./docs/THEMES.md), którego używasz w aplikacji na Androida! +- pisząc samouczki lub poradniki, które dotyczą hostowania serwerów, automatyzacji czatbotów itp. +- współtworząc bazy wiedzy SimpleX Chat. +- rozwijając funkcje - skontaktuj się z nami za pośrednictwem czatu, abyśmy mogli pomóc Ci zacząć. + +## Wesprzyj nas dotacjami + +Ogromne podziękowania dla wszystkich, którzy wsparli projekt SimpleX Chat! + +Na pierwszym miejscu stawiamy prywatność i bezpieczeństwo użytkowników - byłoby to niemożliwe bez waszego wsparcia. + +Naszą obietnicą wobec użytkowników jest to, że protokoły SimpleX są i pozostaną otwarte i w domenie publicznej - tak więc każdy może stworzyć przyszłe implementacje klientów i serwerów. Budujemy platformę SimpleX opartą na tych samych zasadach, co poczta e-mail i Internet, ale znacznie bardziej prywatną i bezpieczną. + +Twoje darowizny pomogą nam zebrać więcej funduszy - każda kwota, nawet koszt filiżanki kawy, będzie dla nas ogromną pomocą. + +Możesz nas wesprzeć za pomocą: + +- [GitHuba](https://github.com/sponsors/simplex-chat) - jest to dla nas wolne od prowizji. +- [OpenCollective](https://opencollective.com/simplex-chat) - pobiera prowizję, a także przyjmuje darowizny w kryptowalutach. +- Monero: 8568eeVjaJ1RQ65ZUn9PRQ8ENtqeX9VVhcCYYhnVLxhV4JtBqw42so2VEUDQZNkFfsH5sXCuV7FN8VhRQ21DkNibTZP57Qt +- Bitcoin: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG +- BCH: 1bpefFkzuRoMY3ZuBbZNZxycbg7NYPYTG +- USDT: + - BNB Smart Chain: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2 + - Tron: TNnTrKLBmdy2Wn3cAQR98dAVvWhLskQGfW +- Ethereum: 0x83fd788f7241a2be61780ea9dc72d2151e6843e2 +- Solana: 43tWFWDczgAcn4Rzwkpqg2mqwnQETSiTwznmCgA2tf1L + +Dziękuję, + +Evgeny + +Twórca SimpleX Chat. + +## Informacje + +- [Dlaczego prywatność ma znaczenie](#dlaczego-prywatność-ma-znaczenie) +- [Podejście SimpleXa do problemu prywatności i bezpieczeństwa](#podejście-simplexa-do-problemu-prywatności-i-bezpieczeństwa) + - [Kompletna prywatność Twojej tożsamości, profilu, kontaktów i metadanych.](#kompletna-prywatność-twojej-tożsamości-profilu-kontaktów-i-metadanych) + - [Najlepsza ochrona przed spamem i nadużyciami](#najlepsza-ochrona-przed-spamem-i-nadużyciami) + - [Pełna kontrola i bezpieczeństwo Twoich danych](#pełna-kontrola-i-bezpieczeństwo-twoich-danych) + - [Użytkownicy są właścicielami sieci SimpleX](#użytkownicy-są-właścicielami-sieci-simplex) +- [Często zadawane pytania](#często-zadawane-pytania) +- [Newsy i aktualizacje](#newsy-i-aktualizacje) +- [Szybka instalacja terminalowej wersji aplikacji](#zap-szybka-instalacja-terminalowej-wersji-aplikacji) +- [Budowa Platformy SimpleX](#budowa-platformy-simplex) +- [Prywatność i bezpieczeństwo: szczegóły techniczne i ograniczenia](#prywatność-i-bezpieczeństwo-szczegóły-techniczne-i-ograniczenia) +- [Dla deweloperów](#dla-deweloperów) +- [Roadmapa](#roadmapa) +- [Ostrzeżenia, Kontakt w sprawie bezpieczeństwa, Licencja](#ostrzeżenia) + +## Dlaczego prywatność ma znaczenie + +Każdy powinien dbać o prywatność i bezpieczeństwo swojej komunikacji - nieszkodliwe rozmowy mogą narazić Cię na niebezpieczeństwo, nawet jeśli nie masz nic do ukrycia. + +Jedną z najbardziej wstrząsających historii jest doświadczenie [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi). opisane w jego pamiętniku i pokazane w filmie Mauretańczyk (2021). Został on umieszczony w obozie Guantanamo, bez procesu, i był tam torturowany przez 15 lat po telefonie do swojego krewnego w Afganistanie, pod zarzutem udziału w atakach 9/11, mimo że przez poprzednie 10 lat mieszkał w Niemczech. + +Używanie szyfrowanego komunikatora end-to-end nie jest wystarczające. Powinniśmy używać komunikatorów, które zapewniają prywatność naszym powiązaniom, czyli tym z kim jesteśmy jakkolwiek połączeni. + +## Podejście SimpleXa do problemu prywatności i bezpieczeństwa + +### Kompletna prywatność Twojej tożsamości, profilu, kontaktów i metadanych. + +**W przeciwieństwie do innych komunikatorów, SimpleX nie posiada żadnych identyfikatorów przypisanych do użytkowników**. Nie posiada nawet numerów generowanych losowo. Zapewnia to prywatność tego, z kim się komunikujesz, ukrywając jego tożsamość oraz fakt komunikacji przed serwerami platformy SimpleX i wszelkimi obserwatorami [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). + +### Najlepsza ochrona przed spamem i nadużyciami + +Ponieważ na platformie SimpleX nie masz identyfikatora ani stałego adresu, nikt nie może się z Tobą skontaktować, chyba że udostępnisz jednorazowy lub tymczasowy adres użytkownika, w postaci kodu QR lub linku. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#the-best-protection-against-spam-and-abuse). + +### Pełna kontrola i bezpieczeństwo Twoich danych + +SimpleX przechowuje wszystkie dane użytkownika na urządzeniach klienckich, wiadomości są przechowywane tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#complete-ownership-control-and-security-of-your-data). + +### Użytkownicy są właścicielami sieci SimpleX + +Możesz używać SimpleX na własnych serwerach i nadal komunikować się z ludźmi za pomocą serwerów, które są wstępnie skonfigurowane w aplikacjach lub z dowolnymi innymi serwerami SimpleX. [Czytaj więcej](./docs/lang/pl/SIMPLEX.md#users-own-simplex-network). + +## Często zadawane pytania + +1. _W jaki sposób SimpleX może dostarczać wiadomości bez jakichkolwiek identyfikatorów użytkownika?_ Zobacz [ogłoszenie wydania v2](./blog/20220511-simplex-chat-v2-images-files.md#the-first-messaging-platform-without-user-identifiers) wyjaśniające jak SimpleX działa. + +2. _Dlaczego po prostu nie mogę używać Signal?_ Signal to scentralizowana platforma, która wykorzystuje numery telefonów do identyfikacji użytkowników i ich kontaktów. Oznacza to, że podczas gdy treść wiadomości w Signal jest chroniona solidnym szyfrowaniem end-to-end, istnieje duża ilość metadanych widocznych dla Signal - to, z kim rozmawiasz i kiedy. + +3. _Czym to się różni od Matrix, Session, Ricochet, Cwtch itp., które również nie wymagają tożsamości użytkownika?_ Mimo że te platformy nie wymagają _prawdziwej tożsamości_, to polegają na anonimowych tożsamościach użytkowników w celu dostarczania wiadomości - może to być na przykład klucz tożsamości lub liczba losowa. Korzystanie z trwałej tożsamości użytkownika, nawet anonimowej, stwarza ryzyko, że informacje na temat powiązań użytkownika staną się znane obserwatorom i/lub dostawcom usług, co może prowadzić do deanonimizacji poszczególnych użytkowników. Jeśli ten sam profil użytkownika jest używany do łączenia się z dwiema różnymi osobami za pośrednictwem dowolnego komunikatora innego niż SimpleX, wspomniane dwie osoby mogą stwierdzić, czy są rozmawiają z tą samą osobą - w wiadomościach używają tego samego identyfikatora użytkownika. W SimpleX nie ma metadanych wspólnych dla rozmów z różnymi kontaktami - jest to cecha, której nie ma żaden inny komunikator. + +## Newsy i aktualizacje + +Najnowsze i ważne wiadomości: + +[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md) + +[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md) + +[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md) + +[Nov 25, 2023. SimpleX Chat v5.4 released: link mobile and desktop apps via quantum resistant protocol, and much better groups](./blog/20231125-simplex-chat-v5-4-link-mobile-desktop-quantum-resistant-better-groups.md). + +[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md). + +[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). + +[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md). + +[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). + +[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). + +[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). + +[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md). + +[All updates](./blog) + +## :zap: Szybka instalacja terminalowej wersji aplikacji + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +Po pobraniu klienta czatu można go uruchomić za pomocą polecenia `simplex-chat`. + +![simplex-chat](./images/connection.gif) + +Przeczytaj więcej o [instalowaniu i używaniu terminalowej wersji czatu](./docs/lang/pl/CLI.md). + +## Budowa Platformy SimpleX + +SimpleX to sieć typu klient-serwer z unikatową topologią sieciową, która wykorzystuje redundantne, jednorazowe węzły przekazywania wiadomości do asynchronicznego przekazywania wiadomości za pośrednictwem jednokierunkowych (simpleksowych) kolejek wiadomości, zapewniając anonimowość odbiorcy i nadawcy. + +W przeciwieństwie do sieci P2P, wszystkie wiadomości są przekazywane przez jeden lub kilka węzłów serwera, które nawet nie muszą być trwałe. Obecna implementacja [serwera SMP](https://github.com/simplex-chat/simplexmq#smp-server) wykorzystuje przechowywanie wiadomości w pamięci, utrzymując jedynie rejestr kolejki. SimpleX zapewnia lepszą ochronę metadanych niż projekty P2P, ponieważ żadne globalne identyfikatory uczestników nie są używane do dostarczania wiadomości i pozwala to uniknąć [różnych problemów związanych z sieciami P2P](./docs/lang/pl/SIMPLEX.md#comparison-with-p2p-messaging-protocols). + +W przeciwieństwie do sieci sfederowanych, węzły serwera **nie posiadają danych użytkowników**, **nie komunikują się ze sobą** i **nie przechowują wiadomości** po ich dostarczeniu do odbiorców. Nie ma możliwości na odkrycie pełnej listy serwerów działających w sieci SimpleX. Taka konstrukcja pozwala uniknąć problemu związanego z widocznością metadanych, z którym borykają się wszystkie sieci sfederowane i pozwala ona na lepszą ochronę przed atakami obejmującymi całą sieć. + +Informacje o użytkownikach, ich kontaktach i grupach znajdują się wyłącznie na urządzeniach klienckich. + +Przeczytaj [whitepaper SimpleX](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md) po więcej informacji o zadaniach platformy oraz by dowiedzieć się jak wygląda koncepcja techniczna modelu. + +Zobacz [Protokół Czatu SimpleX](./docs/protocol/simplex-chat.md) by dowiedzieć się o formacie wiadomości wysyłanych między klientem czatu za pośrednictwem [Protokołu Wiadomości SimpleX](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/simplex-messaging.md). + +## Prywatność i bezpieczeństwo: szczegóły techniczne i ograniczenia + +Prace nad SimpleX Chat wciąż trwają - udostępniamy nowe ulepszenia, gdy tylko będą gotowe. To Ty musisz zdecydować, czy obecny stan jest wystarczająco dobry dla Twojego przypadku zastosowania. + +Stworzyliśmy [słownik pojęć](./docs/GLOSSARY.md) używany do opisu systemów komunikacyjnych, aby pomóc zrozumieć niektóre z poniższych pojęć oraz aby pomóc Ci w porównaniu zalet i wad różnych systemów komunikacyjnych. + +Co zostało już wprowadzone: + +1. Zamiast identyfikatorów użytkownika używanych przez wszystkie inne platformy, nawet te najbardziej prywatne, SimpleX używa [pairwise per-queue identifiers](./docs/GLOSSARY.md#pairwise-pseudonymous-identifier) (2 adresy dla każdej jednokierunkowej kolejki wiadomości, z opcjonalnym trzecim adresem dla powiadomień push na iOS, 2 kolejki w każdym połączeniu między użytkownikami). Sprawia to, że trudniej jest w ten sposób obserwować przebieg połączeń sieciowych na poziomie aplikacji, ponieważ dla `n` użytkowników może istnieć do `n * (n-1)` kolejek wiadomości. +2. [Szyfrowanie end-to-end](./docs/GLOSSARY.md#end-to-end-encryption) w każdej kolejce wiadomości używając [cryptoboxa NaCl](https://nacl.cr.yp.to/box.html). Zostało to dodane, aby umożliwić redundancję w przyszłości (przekazywanie każdej wiadomości przez kilka serwerów), aby uniknąć posiadania tego samego ciphertext w różnych kolejkach (które byłyby widoczne tylko dla atakującego, w przypadku przejęcia TLS). Klucze szyfrujące używane do tego szyfrowania nie są rotowane, zamiast tego planujemy rotować kolejki. Do negocjacji kluczy używane są klucze Curve25519. +3. Szyfrowanie end-to-end [double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) w każdej rozmowie między dwoma użytkownikami (lub członkami grupy). Jest to ten sam algorytm, który jest używany w Signal i wielu innych komunikatorach; zapewnia on komunikację OTR z [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (każda wiadomość jest szyfrowana własnym kluczem efemerycznym) i [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (klucze są często renegocjowane w ramach wymiany wiadomości). Dwie pary kluczy Curve448 są używane do początkowego [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), strona inicjująca przekazuje te klucze przez link połączenia, a strona akceptująca - w nagłówku wiadomości potwierdzającej. +4. Dodatkowa warstwa szyfrowania przy użyciu NaCL cryptobox dla wiadomości dostarczanych z serwera do odbiorcy. Warstwa ta pozwala uniknąć wspólnego szyfrogramu między wysyłanym i odbieranym ruchem serwera wewnątrz TLS (i nie ma też wspólnych identyfikatorów). +5. Kilka poziomów [content padding](./docs/GLOSSARY.md#message-padding) w celu utrudnienia ataków na rozmiar wiadomości. +6. Wszystkie metadane wiadomości, w tym czas odebrania wiadomości przez serwer (zaokrąglony do sekundy), są wysyłane do odbiorców w zaszyfrowanej postaci, więc nawet jeśli TLS zostanie przejęty, nie można ich zobaczyć. +7. Dozwolone są tylko TLS 1.2/1.3 dla połączeń klient-serwer, z ograniczeniem do algorytmów kryptograficznych: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. +8. Aby zapobiec atakom typu replay, serwery SimpleX wymagają [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) jako identyfikatora sesji w każdym poleceniu klienta podpisanym kluczem efemerycznym dla każdej kolejki. +9. Aby ochronić swój adres IP, wszystkie klienty SimpleX Chat obsługują dostęp do serwerów komunikacyjnych za pośrednictwem Tora - zobacz [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) po więcej szczegółów. +10. Lokalne szyfrowanie bazy danych z hasłem - kontakty, grupy oraz wszystkie wysłane i odebrane wiadomości są przechowywane w postaci zaszyfrowanej. Jeśli korzystałeś z SimpleX Chat przed wersją v4.0, musisz włączyć szyfrowanie w ustawieniach aplikacji. +11. Izolacja transportu - różne połączenia TCP i obwody Tor używane są dla ruchu różnych profili użytkowników, opcjonalnie - dla różnych kontaktów i połączeń członków grupy. +12. Ręczne obracanie kolejki wiadomości w celu przeniesienia konwersacji do innego przekaźnika SMP. +13. Wysyłanie zaszyfrowanych plików end-to-end przy użyciu [protokołu XFTP](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). +14. Szyfrowanie plików lokalnych. + +Planujemy dodać: + +1. Przekaźniki SMP nadawców i przekaźniki XFTP odbiorców w celu zmniejszenia ruchu i w celu ukrycia adresów IP przed przekaźnikami wybranymi i potencjalnie kontrolowanymi przez drugą stronę. +2. Post-kwantowa wymiana kluczy w protokole Double Ratchet. +3. Automatyczna rotacja kolejek wiadomości i redundancja. Obecnie kolejki utworzone między dwoma użytkownikami są używane, dopóki kolejka nie zostanie ręcznie zmieniona przez użytkownika lub dopóki kontakt nie zostanie usunięty. Planujemy dodać automatyczną rotację kolejek, aby te identyfikatory były tymczasowe i rotowały w oparciu o pewien harmonogram TBC (np. co X wiadomości lub co X godzin/dni). +4. "Mieszanie" wiadomości - dodanie opóźnienia do dostarczania wiadomości, w celu ochrony przed korelacją ruchu według czasu wiadomości. +5. Reprodukowalne kompilacje - ograniczeniem jest tu stos deweloperski, ale będziemy starali się rozwiązać ten problem. Użytkownicy nadal mogą tworzyć wszystkie aplikacje i usługi z kodu źródłowego. + +## Dla deweloperów + +Możesz: + +- korzystać z biblioteki SimpleX Chat w celu zintegrowania funkcji czatu z aplikacjami mobilnymi. +- tworzyć boty i usługi czatu w języku Haskell - zobacz [prosty](./apps/simplex-bot/) i bardziej [zaawansowany przykład bota czatu](./apps/simplex-bot-advanced/). +- tworzenie chat botów i usług w dowolnym języku z wykorzystaniem terminala CLI SimpleX Chat jako lokalnego serwera WebSocket. Zobacz [TypeScript SimpleX Chat client](./packages/simplex-chat-client/) i [JavaScript chat bot example](./packages/simplex-chat-client/typescript/examples/squaring-bot.js). +- uruchomić [simplex-chat w terminal ](./docs/lang/pl/CLI.md), aby wykonywać poszczególne polecenia czatu, np. wysyłać wiadomości w ramach wykonywania skryptu powłoki. + +Jeśli chcesz rozwijać platformę SimpleX, skontaktuj się z nami, aby uzyskać porady i wsparcie. + +Dołącz również do grupy [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F6eHqy7uAbZPOcA6qBtrQgQquVlt4Ll91%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAqV_pg3FF00L98aCXp4D3bOs4Sxv_UmSd-gb0juVoQVs%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22XonlixcHBIb2ijCehbZoiw%3D%3D%22%7D), aby zadawać pytania i dzielić się swoimi sukcesami. + +## Roadmapa + +- ✅ Łatwy do utworzenia serwer SimpleX z przechowywaniem wiadomości w pamięci, bez żadnych dependency. +- ✅ Terminalowa (konsolowa) wersja klienta z obsługą grup oraz plików. +- ✅ Utworzenie serwera SimpleX na Linode zaledwie jednym kliknięciem. +- ✅ Szyfrowanie end-to-end używając protokołu double-ratchet z dodatkową warstwą szyfrowania. +- ✅ Pierwsza wersja aplikacji na Androida i iOS. +- ✅ Zachowujące prywatność natychmiastowe powiadomienia na Androida przy użyciu usługi działającej w tle. +- ✅ Przykłady czatbota w Haskellu. +- ✅ v2.0 - wsparcie dla zdjęć oraz plików w aplikacjach mobilnych. +- ✅ Manualne usuwanie historii czatu. +- ✅ Szyfrowane end-to-end encrypted audio oraz wideo rozmowy przy użyciu WebRTC w aplikacjach mobilnych. +- ✅ Zachowujące prywatność natychmiastowe powiadomienia na iOS przy użyciu usługi Apple Push Notification. +- ✅ Eksportowanie oraz importowanie bazy danych czatu. +- ✅ Konwersacje grupowe w aplikacjach mmobilnych. +- ✅ Łączenie się z serwerami wiadomości przez Tor. +- ✅ Podwójne adresy serwerów w celu uzyskania dostępu do serwerów wiadomości jako ukrytych usług v3. +- ✅ Serwer czatu i SDK klienta w języku TypeScript do tworzenia interfejsów czatu, integracji i botów czatu (gotowe do ogłoszenia). +- ✅ Tryb incognito do udostępniania nowej losowej nazwy każdemu kontaktowi. +- ✅ Szyfrowanie bazy danych czatu. +- ✅ Automatyczne usuwanie historii czatu. +- ✅ Linki umożliwiające dołączanie do grup i poprawienie ich stabilności. +- ✅ Wiadomości głosowe (z opcją rezygnacji odbiorcy dla każdego kontaktu). +- ✅ Podstawowe uwierzytelnianie dla serwerów SMP (w celu autoryzacji tworzenia nowych kolejek). +- ✅ Wyświetlanie usuniętych wiadomości, pełne usuwanie wiadomości przez nadawcę (z wyrażeniem zgody przez odbiorcę dla każdego kontaktu). +- ✅ Blokowanie zrzutów ekranu i wyświetlania zawartości aplikacji w "ostatnich aplikacjach". +- ✅ Zaawansowana konfiguracja serwera. +- ✅ Znikające wiadomości (z możliwością wyboru przez odbiorcę dla każdego kontaktu). +- ✅ Wiadomości "na żywo". +- ✅ Weryfikacja kontaktu za pośrednictwem oddzielnego kanału out-of-band. +- ✅ Wiele profili użytkowników w tej samej bazie danych czatu. +- ✅ Opcjonalnie unikanie ponownego użycia tej samej sesji TCP dla wielu połączeń. +- ✅ Zachowywanie wersji roboczych wiadomości. +- ✅ Serwer plików do optymalizacji wydajnego i prywatnego wysyłania dużych plików. +- ✅ Ulepszone połączenia audio i wideo. +- ✅ Obsługa starszego systemu operacyjnego Android i 32-bitowych procesorów. +- ✅ Ukryte profile czatu. +- ✅ Wysyłanie i odbieranie dużych plików przez [protokół XFTP](./blog/20230301-simplex-file-transfer-protocol.md). +- ✅ Wiadomości wideo. +- ✅ Kod dostępu do aplikacji. +- ✅ Ulepszenie interfejsu Androidowej aplikacji. +- ✅ Opcjonalne alternatywne hasło dostępu. +- ✅ Reakcje na wiadomości +- ✅ Historia edytowania wiadomości +- ✅ Zmniejszenie zużycia baterii i transferu danych w dużych grupach. +- ✅ Potwierdzenie dostarczenia wiadomości (z opcją rezygnacji nadawcy dla każdego kontaktu). +- ✅ Klient desktopowy. +- ✅ Szyfrowanie plików lokalnych przechowywanych w aplikacji. +- ✅ Korzystanie z profili mobilnych z poziomu aplikacji komputerowej. +- ✅ Prywatne notatki. +- ✅ Usprawnienie wysyłania filmów (w tym szyfrowanie lokalnie przechowywanych filmów). +- ✅ Post-kwantowa wymiana kluczy w protokole double ratchet. +- 🏗 Poprawienie stabilności i zmniejszenie zużycia baterii. +- 🏗 Poprawienie odczuć dla nowych użytkowników. +- 🏗 Duże grupy, społeczności i kanały publiczne. +- 🏗 Przekaźnik dostarczania wiadomości dla nadawców (w celu ukrycia adresu IP przed serwerami odbiorców i zmniejszenia ruchu). +- Suwak prywatności i bezpieczeństwa - prosty sposób na ustawienie wszystkich ustawień za jednym zamachem. +- Redundancja i rotacja kolejek SMP (obsługiwana ręcznie). +- Dołączanie opcjonalnej wiadomości do żądania połączenia wysyłanego za pośrednictwem adresu kontaktowego. +- Ulepszona nawigacja i wyszukiwanie w konwersacji (rozwijanie i przewijanie do cytowanej wiadomości, przewijanie do wyników wyszukiwania itp.) +- Kanały/transmisje. +- Efemeryczne/znikające/jednorazowe konwersacje z istniejącymi kontaktami. +- Prywatne udostępnianie swojej lokalizacji. +- Widżety internetowe dla niestandardowej interaktywności w czatach. +- Programowalne automatyzacje czatów / reguły (automatyczne odpowiedzi / przekazywanie / usuwanie / wysyłanie, przypomnienia itp.) +- Chroniący prywatność serwer tożsamości dla opcjonalnych adresów kontaktów/grup opartych na DNS w celu uproszczenia połączenia i odnajdywania, ale nieużywany do dostarczania wiadomości: + - zachowanie wszystkich kontaktów i grup nawet w przypadku utraty domeny. + - Serwer nie posiada informacji o kontaktach i grupach użytkownika. +- Wielowęzłowe przekaźniki SMP o dużej pojemności. + +## Ostrzeżenia + +[Protokoły i model bezpieczeństwa SimpleX](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) zostały poddane przeglądowi i zawierały wiele istotnych zmian i ulepszeń w wersji v1.0.0. + +Audyt bezpieczeństwa został przeprowadzony w październiku 2022 r. przez [Trail of Bits](https://www.trailofbits.com/about), a większość poprawek została wydana w wersji 4.2.0 - zobacz [ogłoszenie](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). + +SimpleX Chat jest nadal na stosunkowo wczesnym etapie rozwoju (aplikacje mobilne zostały wydane w marcu 2022 r.), więc możesz odkryć pewne błędy i brakujące funkcje. Będziemy bardzo wdzięczni za poinformowanie nas o wszystkim, co wymaga naprawy lub ulepszenia. + +Domyślne serwery skonfigurowane w aplikacji są dostarczane na zasadzie najlepszych starań. Obecnie nie gwarantujemy żadnych umów SLA, chociaż historycznie nasze serwery miały ponad 99,9% czasu pracy. + +Nigdy nie udostępnialiśmy ani nie byliśmy proszeni o dostęp do naszych serwerów lub jakichkolwiek informacji z naszych serwerów przez osoby trzecie. Jeśli kiedykolwiek zostaniemy poproszeni o zapewnienie takiego dostępu lub informacji, będziemy postępować zgodnie z odpowiednim procesem prawnym. + +Nie rejestrujemy adresów IP użytkowników i nie przeprowadzamy żadnej korelacji ruchu na naszych serwerach. Jeśli bezpieczeństwo na poziomie transportu jest krytyczne, musisz użyć Tor lub innej podobnej sieci, aby uzyskać dostęp do serwerów wiadomości. Będziemy ulepszać aplikacje klienckie, aby zmniejszyć możliwości korelacji ruchu. + +Więcej informacji można znaleźć w [Warunkach i polityce prywatności](./PRIVACY.md). + +## Kontakt w sprawie bezpieczeństwa + +Aby zgłosić podatność wyślij nam wiadomość e-mail na adres chat@simplex.chat. Będziemy wspólnie pracować nad poprawką i ujawnieniem szczegółów. NIE zgłaszaj luk w zabezpieczeniach za pośrednictwem zgłoszeń GitHub. + +Prosimy o traktowanie wszelkich ustaleń dotyczących możliwych ataków korelacji ruchu umożliwiających skorelowanie dwóch różnych konwersacji z tym samym użytkownikiem, innych niż objęte [modelem zagrożeń](https://github.com/simplex-chat/simplexmq/blob/stable/protocol/overview-tjr.md#threat-model), jako luk w zabezpieczeniach i postępowanie zgodnie z tym procesem ujawniania. + +## Licencja + +[AGPL v3](./LICENSE) + +[iOS app](https://apps.apple.com/us/app/simplex-chat/id1605771084) +  +[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app) +  +[F-Droid](https://app.simplex.chat) +  +[iOS TestFlight](https://testflight.apple.com/join/DWuT2LQu) +  +[APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) diff --git a/docs/lang/pl/SERVER.md b/docs/lang/pl/SERVER.md new file mode 100644 index 0000000000..72cb51a4bf --- /dev/null +++ b/docs/lang/pl/SERVER.md @@ -0,0 +1,485 @@ +--- +title: Hostowanie własnego serwera SMP +revision: 31.07.2023 +--- + +| Updated 05.06.2023 | Języki: PL, [EN](/docs/SERVER.md), [FR](/docs/lang/fr/SERVER.md), [CZ](/docs/lang/cs/SERVER.md) | + +# Hostowanie własnego serwera SMP + +## Informacje ogólne + +Serwer SMP to serwer przekaźnikowy używany do przekazywania wiadomości w sieci SimpleX. Aplikacje SimpleX Chat mają wstępnie ustawione serwery (dla aplikacji mobilnych są to smp11, smp12 i smp14.simplex.im), ale można łatwo zmienić konfigurację aplikacji, aby korzystać z innych serwerów. + +Klienty SimpleX określają tylko, który serwer jest używany do odbierania wiadomości, oddzielnie dla każdego kontaktu (lub połączenia grupowego z członkiem grupy), a serwery te są tylko tymczasowe, ponieważ adres dostawy może ulec zmianie. + +_Uwaga_: gdy zmienisz serwery w ustawieniach aplikacji, wpłynie to tylko na to, który serwer będzie używany dla nowych kontaktów, istniejące kontakty nie zostaną automatycznie przeniesione na nowe serwery, ale możesz przenieść je ręcznie za pomocą przycisku ["Zmień adres odbiorczy"](../blog/20221108-simplex-chat-v4.2-security-audit-new-website.md#change-your-delivery-address-beta) na stronie z informacjami kontaktu/członka - wkrótce zostanie to zautomatyzowane. + +## Instalacja + +1. Najpierw zainstaluj `smp-server`: + + - Manualna instalacja (patrz niżej) + + - Półautomatyczna instalacja: + - [Oficjalny skrypt instalacyjny](https://github.com/simplex-chat/simplexmq#using-installation-script) + - [Kontener Dockera](https://github.com/simplex-chat/simplexmq#using-docker) + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + +Instalacja ręczna wymaga kilku kroków wstępnych: + +1. Zainstaluj binarkę: + + - Używając oficjalnych binarek: + + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server + ``` + + - Budowanie z kodu źródłowego: + + Zobacz [Build from source: Using your distribution](https://github.com/simplex-chat/simplexmq#using-your-distribution) + +2. Utwórz użytkownika i grupę dla `smp-server`: + + ```sh + sudo useradd -m smp + ``` + +3. Utwórz niezbędne katalogi i przypisz uprawnienia: + + ```sh + sudo mkdir -p /var/opt/simplex /etc/opt/simplex + sudo chown smp:smp /var/opt/simplex /etc/opt/simplex + ``` + +4. Zezwól na port `smp-server` w firewallu: + + ```sh + # Dla Ubuntu + sudo ufw allow 5223/tcp + # Dla Fedory + sudo firewall-cmd --permanent --add-port=5223/tcp && \ + sudo firewall-cmd --reload + ``` + +5. **Opcjonalnie** - Jeśli używasz dystrybucji z `systemd`, utwórz plik `/etc/systemd/system/smp-server.service` z następującą zawartością: + + ```sh + [Unit] + Description=SMP server systemd service + + [Service] + User=smp + Group=smp + Type=simple + ExecStart=/usr/local/bin/smp-server start +RTS -N -RTS + ExecStopPost=/usr/bin/env sh -c '[ -e "/var/opt/simplex/smp-server-store.log" ] && cp "/var/opt/simplex/smp-server-store.log" "/var/opt/simplex/smp-server-store.log.bak"' + LimitNOFILE=65535 + KillSignal=SIGINT + TimeoutStopSec=infinity + + [Install] + WantedBy=multi-user.target + ``` + + I uruchom `sudo systemctl daemon-reload`. + +## Instalacja Tora + +smp-server można również zainstalować jako serwer działający w sieci [tor](https://www.torproject.org). Uruchom następujące polecenia jako użytkownik `root`. + +1. Zainstaluj Tor: + + Zakładamy, że używasz dystrybucji opartych na Ubuntu/Debian. Jeśli nie, zapoznaj się z [oficjalną dokumentacją tor](https://community.torproject.org/onion-services/setup/install/) lub poradnikiem dla Twojej dystrybucji. + + - Skonfiguruj oficjalne repozytorium Tor PPA: + + ```sh + CODENAME="$(lsb_release -c | awk '{print $2}')" + echo "deb [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main + deb-src [signed-by=/usr/share/keyrings/tor-archive-keyring.gpg] https://deb.torproject.org/torproject.org ${CODENAME} main" > /etc/apt/sources.list.d/tor.list + ``` + + - Zimportuj klucz repozytorium: + + ```sh + curl --proto '=https' --tlsv1.2 -sSf https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc | gpg --dearmor | tee /usr/share/keyrings/tor-archive-keyring.gpg >/dev/null + ``` + + - Zaktualizuj indeks repozytorium: + + ```sh + apt update + ``` + + - Zainstaluj paczkę `tor`: + + ```sh + apt install -y tor deb.torproject.org-keyring + ``` + +2. Skonfiguruj tor: + + - Konfiguracja pliku: + + Otwórz konfigurację tora w wybranym edytorze (`nano`,`vim`,`emacs`, itp.): + + ```sh + vim /etc/tor/torrc + ``` + + I umieść następujące linie na dole konfiguracji. Zwróć uwagę na linie zaczynające się od `#`: są to komentarze dotyczące poszczególnych opcji. + + ```sh + # Włącz logowanie (w przeciwnym razie tor nie wyda adresu onion). + Log notice file /var/log/tor/notices.log + # Włącz routowanie single hop (2 opcje poniżej są zależne od trzeciej). Zmniejszy to opóźnienie w zamian za anonimowość (jako że tor działa równolegle z serwerem smp i adres onion będzie wyświetlany w klientach, jest to całkowicie w porządku). + SOCKSPort 0 + HiddenServiceNonAnonymousMode 1 + HiddenServiceSingleHopMode 1 + # Katalog hostów i mapowanie portów usługi ukrytej smp-server + HiddenServiceDir /var/lib/tor/simplex-smp/ + HiddenServicePort 5223 localhost:5223 + ``` + + - Utwórz katalogi: + + ```sh + mkdir /var/lib/tor/simplex-smp/ && chown debian-tor:debian-tor /var/lib/tor/simplex-smp/ && chmod 700 /var/lib/tor/simplex-smp/ + ``` + +3. Uruchom tor: + + Włącz usługę `systemd` oraz uruchom tor. Oficjalny `tor` jest nieco kłopotliwy przy pierwszym uruchomieniu i może nie utworzyć adresu hosta cebuli, więc na wszelki wypadek uruchamiamy go ponownie. + + ```sh + systemctl enable tor && systemctl start tor && systemctl restart tor + ``` + +4. Wyświetla onionowego hosta: + + Wykonaj następujące polecenie, aby wyświetlić adres onionowego hosta: + + ```sh + cat /var/lib/tor/simplex-smp/hostname + ``` + +## Configuration + +Aby zobaczyć, jakie opcje są dostępne, wykonaj `smp-server` bez flag: + +```sh +sudo su smp -c smp-server + +... +Available commands: + init Initialize server - creates /etc/opt/simplex and + /var/opt/simplex directories and configuration files + start Start server (configuration: + /etc/opt/simplex/smp-server.ini) + delete Delete configuration and log files +``` + +Możesz uzyskać dalszą pomoc, wykonując polecenie `sudo su smp -c "smp-server -h"` + +Następnie musimy skonfigurować `smp-server`: + +### Interaktywnie + +Wykonaj poniższe polecenie: + +```sh +sudo su smp -c "smp-server init" +``` + +Istnieje kilka opcji, które należy rozważyć: + +- `Enable store log to restore queues and messages on server restart (Yn):` + + Wpisz `y`, aby włączyć zapisywanie i przywracanie połączeń i wiadomości po ponownym uruchomieniu serwera. + + _Uwaga_: ważne jest, aby użyć SIGINT do ponownego uruchomienia serwera, ponieważ w przeciwnym razie niedostarczone wiadomości nie zostaną przywrócone. Połączenia zostaną przywrócone niezależnie od sposobu ponownego uruchomienia serwera, ponieważ w przeciwieństwie do wiadomości są one dodawane do dziennika append-only. + +- `Enable logging daily statistics (yN):` + + Wpisz `y`, aby włączyć logowanie statystyk w formacie CSV, mogą one być przykładowo użyte do pokazania wykresów użycia w `Grafana`. + +Statystyki te obejmują dzienną liczbę utworzonych, zabezpieczonych i usuniętych kolejek, wysłanych i odebranych wiadomości, a także dzienną, tygodniową i miesięczną liczbę aktywnych kolejek (tj. kolejek, które były używane do wysyłania wiadomości). Uważamy, że informacje te nie zawierają niczego, co pozwoliłoby na skorelowanie różnych kolejek jako należących bezpośrednio do użytkowników, ale prosimy o poufne poinformowanie nas, jeśli uważasz, że można to w jakikolwiek sposób nadużyć. + +- `Require a password to create new messaging queues?` + + Wpisz `r` lub dowolne hasło, aby zabezpieczyć hasłem `smp-server`, lub `n`, aby wyłączyć ochronę hasłem. + +- `Enter server FQDN or IP address for certificate (127.0.0.1):` + + Wprowadź domenę lub adres IP, na którym działa Twój smp-server - zostanie on zamieszczony w certyfikatach serwera, a także wyświetlony jako część adresu serwera. + +### Za pomocą opcji wiersza poleceń + +Wykonaj poniższe polecenie: + +```sh +sudo su smp -c "smp-server init -h" + +... +Available options: + -l,--store-log Enable store log for persistence + -s,--daily-stats Enable logging daily server statistics + -a,--sign-algorithm ALG Signature algorithm used for TLS certificates: + ED25519, ED448 (default: ED448) + --ip IP Server IP address, used as Common Name for TLS online + certificate if FQDN is not supplied + (default: "127.0.0.1") + -n,--fqdn FQDN Server FQDN used as Common Name for TLS online + certificate + --no-password Allow creating new queues without password + --password PASSWORD Set password to create new messaging queues + -y,--yes Non-interactive initialization using command-line + options + -h,--help Show this help text +``` + +Powinieneś określić, które flagi są potrzebne dla Twojego zastosowania, a następnie wykonać `smp-server init` z flagą `-y` dla nieinteraktywnej inicjalizacji: + +```sh +sudo su smp -c "smp-server init -y - " +``` + +Przykładowo, uruchom: + +```sh +sudo su smp -c "smp-server init -y -l --ip 192.168.1.5 --password test" +``` + +aby zainicjować konfigurację `smp-server` z: + +- przywracaniem połączeń i wiadomości po ponownym uruchomieniu serwera (flaga `-l`), +- adresem IP `192.168.1.5`, +- zabezpieczeniem `smp-server` hasłem `test`. + +--- + +Po tym instalacja jest ukończona i powinieneś zobaczyć coś takiego: + +```sh +Certificate request self-signature ok +subject=CN = 127.0.0.1 +Server initialized, you can modify configuration in /etc/opt/simplex/smp-server.ini. +Run `smp-server start` to start server. +---------- +You should store CA private key securely and delete it from the server. +If server TLS credential is compromised this key can be used to sign a new one, keeping the same server identity and established connections. +CA private key location: /etc/opt/simplex/ca.key +---------- +SMP server v3.4.0 +Fingerprint: d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss= +Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZnTC_QuSHfCEYq53uLaJKQ_oIC6-ve8=@ +``` + +Powyższy adres serwera powinien być użyty w konfiguracji klienta, a jeśli dodałeś hasło serwera, powinno ono być udostępnione innym osobom tylko wtedy, gdy chcesz zezwolić im na korzystanie z Twojego serwera do odbierania wiadomości (wszystkie Twoje kontakty będą mogły wysyłać wiadomości, ponieważ nie wymaga to hasła). Jeśli podałeś adres IP lub nazwę hosta podczas instalacji, zostanie to wyświetlone jako część adresu serwera, w przeciwnym razie zastąp `` rzeczywistymi adresami serwerów. + +## Dokumentacja + +Wszystkie niezbędne pliki dla `smp-server` znajdują się w folderze `/etc/opt/simplex/`. + +Przechowywane wiadomości, połączenia, statystyki i dziennik serwera znajdują się w folderze `/var/opt/simplex/`. + +### Adres serwera SMP + +Adres serwera SMP ma następujący format: + +``` +smp://[:]@[,] +``` + +- `` + + To odcisk palca certyfikatu Twojego `smp-server`. Odcisk palca certyfikatu możesz sprawdzić w `/etc/opt/simplex/fingerprint`. + +- **opcjonalnie** `` + + To ustawione przez Ciebie hasło Twojego `smp-server`. Możesz sprawdzić to hasło w pliku `/etc/opt/simplex/smp-server.ini`, w sekcji `[AUTH]` w polu `create_password:`. + +- ``, **optional** `` + + To skonfigurowane przez Ciebie nazwy hosta Twojego `smp-server`. Nazwy hostów możesz sprawdzić w pliku `/etc/opt/simplex/smp-server.ini`, w sekcji `[TRANSPORT]` w polu `host:`. + +### Komendy systemd + +Aby uruchomić `smp-server` przy starcie hosta, uruchom: + +```sh +sudo systemctl enable smp-server.service + +Created symlink /etc/systemd/system/multi-user.target.wants/smp-server.service → /etc/systemd/system/smp-server.service. +``` + +Aby uruchomić `smp-server`, uruchom: + +```sh +sudo systemctl start smp-server.service +``` + +Aby sprawdzić status `smp-server`, uruchom: + +```sh +sudo systemctl status smp-server.service + +● smp-server.service - SMP server + Loaded: loaded (/etc/systemd/system/smp-server.service; enabled; vendor preset: enabled) + Active: active (running) since Sat 2022-11-23 19:23:21 UTC; 1min 48s ago + Main PID: 30878 (smp-server) + CGroup: /docker/5588ab759e80546b4296a7c50ffebbb1fb7b55b8401300e9201313b720989aa8/system.slice/smp-server.service + └─30878 smp-server start + +Nov 23 19:23:21 5588ab759e80 systemd[1]: Started SMP server. +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: SMP server v3.4.0 +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Fingerprint: d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss= +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZnTC_QuSHfCEYq53uLaJKQ_oIC6-ve8=@ +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Store log: /var/opt/simplex/smp-server-store.log +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Listening on port 5223 (TLS)... +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: not expiring inactive clients +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password +``` + +Aby zatrzymać `smp-server`, uruchom: + +```sh +sudo systemctl stop smp-server.service +``` + +Aby sprawdzić zawartość dziennika `smp-server`, uruchom: + +```sh +sudo journalctl -fu smp-server.service + +Nov 23 19:23:21 5588ab759e80 systemd[1]: Started SMP server. +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: SMP server v3.4.0 +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Fingerprint: d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss= +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Server address: smp://d5fcsc7hhtPpexYUbI2XPxDbyU2d3WsVmROimcL90ss=:V8ONoJ6ICwnrZnTC_QuSHfCEYq53uLaJKQ_oIC6-ve8=@ +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Store log: /var/opt/simplex/smp-server-store.log +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: Listening on port 5223 (TLS)... +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: not expiring inactive clients +Nov 23 19:23:21 5588ab759e80 smp-server[30878]: creating new queues requires password +``` + +### Monitorowanie + +Możesz włączyć statystyki `smp-server` dla dashboardu `Grafana` ustawiając wartość `on` w `/etc/opt/simplex/smp-server.ini`, w sekcji `[STORE_LOG]` w polu `log_stats:`. + +Logi będą przechowywane jako plik `csv` w `/var/opt/simplex/smp-server-stats.daily.log`. Pola dla pliku `csv` to: + +```sh +fromTime,qCreated,qSecured,qDeleted,msgSent,msgRecv,dayMsgQueues,weekMsgQueues,monthMsgQueues +``` + +- `fromTime` - timestamp; data i godzina zdarzenia + +- `qCreated` - int; utworzone kolejki + +- `qSecured` - int; ustanowione kolejki + +- `qDeleted` - int; usunięte queues + +- `msgSent` - int; wysłane wiadomości + +- `msgRecv` - int; odebrane wiadomości + +- `dayMsgQueues` - int; aktywnych kolejek podczas dnia + +- `weekMsgQueues` - int; aktywnych kolejek w tygodniu + +- `monthMsgQueues` - int; aktywnych kolejek w miesiącu + +Aby zaimportować `csv` do `Grafana` należy: + +1. Zainstalować wtyczkę Grafana: [Grafana - CSV datasource](https://grafana.com/grafana/plugins/marcusolsson-csv-datasource/) + +2. Zezwolić na tryb lokalny, dołączając następujące elementy: + + ```sh + [plugin.marcusolsson-csv-datasource] + allow_local_mode = true + ``` + + ... do `/etc/grafana/grafana.ini` + +3. Dodaj źródło danych CSV: + + - W menu bocznym kliknij zakładkę Configuration (ikona koła zębatego) + - Kliknij Add data source (Dodaj źródło danych) w prawym górnym rogu zakładki Data Sources (Źródła danych). + - Wpisz "CSV" w polu wyszukiwania, aby znaleźć źródło danych CSV. + - Kliknij wynik wyszukiwania z napisem "CSV". + - W polu URL wprowadź plik wskazujący na zawartość CSV. + +4. Gotowe! Teraz możesz utworzyć własny pulpit nawigacyjny ze statystykami. + +Dalsza dokumentacja znajduje się na stronie: [CSV Data Source for Grafana - Documentation](https://grafana.github.io/grafana-csv-datasource/). + +# Aktualizowanie twojego serwera SMP + +Aby zaktualizować smp-server do najnowszej wersji, wybierz metodę instalacji i postępuj zgodnie z instrukcjami: + + - Manualnie + 1. Zatrzymaj serwer: + ```sh + sudo systemctl stop smp-server + ``` + 2. Zaktualizuj binarkę: + ```sh + curl -L https://github.com/simplex-chat/simplexmq/releases/latest/download/smp-server-ubuntu-20_04-x86-64 -o /usr/local/bin/smp-server && chmod +x /usr/local/bin/smp-server + ``` + 3. Uruchom serwer: + ```sh + sudo systemctl start smp-server + ``` + + - Używając [oficjalnego skryptu instalacyjnego](https://github.com/simplex-chat/simplexmq#using-installation-script) + 1. Uruchom: + ```sh + sudo simplex-servers-update + ``` + 2. Gotowe! + + - Używając [kontenera Dockera](https://github.com/simplex-chat/simplexmq#using-docker) + 1. Zatrzymaj i usuń kontener: + ```sh + docker rm $(docker stop $(docker ps -a -q --filter ancestor=simplexchat/smp-server --format="\{\{.ID\}\}")) + ``` + 2. Pobierz najnowszą wersję kontenera: + ```sh + docker pull simplexchat/smp-server:latest + ``` + 3. Uruchom nowy kontener: + ```sh + docker run -d \ + -p 5223:5223 \ + -v $HOME/simplex/smp/config:/etc/opt/simplex:z \ + -v $HOME/simplex/smp/logs:/var/opt/simplex:z \ + simplexchat/smp-server:latest + ``` + + - [Linode Marketplace](https://www.linode.com/marketplace/apps/simplex-chat/simplex-chat/) + 1. Pobierz najnowsze obrazy: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex pull + ``` + 2. Zrestartuj kontenery: + ```sh + docker-compose --project-directory /etc/docker/compose/simplex up -d --remove-orphans + ``` + 3. Usuń niepotrzebne obrazy: + ```sh + docker image prune + ``` + +### Konfigurowanie aplikacji do korzystania z serwera + +Aby skonfigurować aplikację do korzystania z serwera wiadomości, skopiuj jego pełny adres, w tym hasło, i dodaj go do aplikacji. Możesz używać swojego serwera razem z predefiniowanymi serwerami lub bez nich - możesz je usunąć lub wyłączyć. + +Możliwe jest również udostępnienie adresu swojego serwera znajomym, pozwalając im zeskanować kod QR z ustawień serwera - będzie on zawierał hasło serwera, dzięki czemu będą mogli również otrzymywać wiadomości za pośrednictwem twojego serwera. + +_Uwaga_: Do obsługi haseł wymagany jest serwer SMP w wersji 4.0. Jeśli już posiadasz serwer, możesz dodać hasło do niego poprzez wpisanie hasła do pliku INI serwera. + +       diff --git a/docs/lang/pl/SIMPLEX.md b/docs/lang/pl/SIMPLEX.md new file mode 100644 index 0000000000..ff7106d84c --- /dev/null +++ b/docs/lang/pl/SIMPLEX.md @@ -0,0 +1,102 @@ +--- +title: Platfoma SimpleX +revision: 07.02.2023 +--- + +| Updated 07.02.2023 | Języki: PL, [EN](/docs/SIMPLEX.md), [FR](/docs/lang/fr/SIMPLEX.md), [CZ](/docs/lang/cs/SIMPLEX.md) | +# Platfoma SimpleX - motywacja i porównanie + +## Problemy + +Istniejące komunikatory oraz protokoły borykają się ze wszystkimi lub kilkoma podanymi problemami: + +- Brak zachowania prywatności profilu i kontaktów użytkownika (zachowanie poufności metadanych). +- Brak ochrony (lub jedynie opcjonalna ochrona) przed atakami MITM przez dostawcę usług przy użyciu szyfrowania [end to end](1) +- Niechciane wiadomości (spam i nadużycia). +- Brak własności danych i ich ochrony. +- Dla nietechnicznych użytkowników używanie niescentralizowanych protokołów jest skomplikowane. + +Koncentracja komunikacji na niewielkiej liczbie scentralizowanych platform sprawia, że rozwiązanie tych problemów jest dość trudne. + +## Proponowane rozwiązanie + +Proponowany zestaw protokołów pozwala rozwiązać te problemy poprzez przechowywanie zarówno wiadomości, jak i kontaktów wyłącznie na urządzeniach klienckich, redukując rolę serwerów do zwykłych przekaźników wiadomości. Wymagają one jedynie autoryzacji wiadomości wysyłanych do kolejek, ale NIE wymagają uwierzytelniania użytkowników - dzięki temu chronione są nie tylko wiadomości, ale także metadane, ponieważ użytkownicy nie mają przypisanych do siebie żadnych identyfikatorów - w przeciwieństwie do innych platform. + +Zobacz [whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) po więcej informacji o zadaniach platformy oraz by dowiedzieć się jak wygląda koncepcja techniczna modelu. + +## Dlaczego SimpleX + +## SimpleX podchodzi do problemu prywatności i bezpieczeństwa w unikalny sposób + +Każdy powinien zwracać uwagę na prywatność i bezpieczeństwo swojej komunikacji - nawet zwykłe rozmowy mogą narazić Cię na niebezpieczeństwo. + +### Pełna prywatność Twojej tożsamości, profilu, kontaktu i metadanych + +**W przeciwieństwie do innych komunikatorów, SimpleX nie posiada żadnych identyfikatorów przypisanych do użytkowników** - nie wymaga użycia numeru telefonu (jak Signal czy Whatsapp), adresu opartego o domenę (jak email, XMPP czy Matrix), nazw użytkownika (jak Telegram), kluczy publicznych czy nawet losowych numerów (jak pozostałe komunikatory) do identyfikowania użytkowników - nie wiemy nawet ile osób używa SimpleX. + +Do dostarczania wiadomości zamiast identyfikatorów użytkowników, których używają wszystkie inne platformy, SimpleX wykorzystuje adresy jednokierunkowych (simpleksowych) kolejek wiadomości. Korzystanie z SimpleX jest jak posiadanie innego adresu e-mail lub numeru telefonu dla każdego kontaktu, ale bez kłopotów z zarządzaniem tymi wszystkimi adresami. W niedalekiej przyszłości aplikacje SimpleX będą również automatycznie zmieniać kolejki wiadomości, przenosząc konwersacje z jednego serwera na drugi, aby zapewnić użytkownikom jeszcze lepszą prywatność. + +Takie podejście chroni prywatność tego, z kim się komunikujesz, ukrywając jego tożsamość oraz fakt komunikacji przed serwerami platformy SimpleX i wszelkimi obserwatorami. Prywatność komunikacji można dodatkowo zwiększyć, konfigurując dostęp do sieci w taki sposób, by łączyć się z serwerami SimpleX za pośrednictwem sieci transportowej typu overlay, np. sieci Tor. + +### Najlepsza ochrona przed spamem i nadużyciami + +Ponieważ nie masz żadnego identyfikatora na platformie SimpleX, nie można się z Tobą skontaktować, chyba że udostępnisz jednorazowy link z zaproszeniem lub opcjonalny tymczasowy adres użytkownika. Nawet przy użyciu opcjonalnych adresów użytkownika, które mogą być wykorzystywane do wysyłania spamu z prośbami o kontakt, można je zmienić lub całkowicie usunąć bez utraty jakichkolwiek połączeń (kontaktów). + +### Pełna kontrola i bezpieczeństwo Twoich danych + +SimpleX przechowuje wszystkie dane użytkownika na urządzeniach klienckich, wiadomości są przetrzymywane tylko tymczasowo na serwerach przekaźnikowych SimpleX do momentu ich odebrania, po czym są trwale usuwane. + +Używamy przenośnego formatu bazy danych, który może być używany na wszystkich obsługiwanych urządzeniach - wkrótce dodamy możliwość eksportu bazy danych czatu z aplikacji mobilnej, aby można było jej używać na innym urządzeniu. + +W przeciwieństwie do serwerów sieci federowanych (e-mail, XMPP lub Matrix), serwery SimpleX nie przechowują kont użytkowników, a jedynie przekazują wiadomości do odbiorców, chroniąc prywatność obu stron. Nie ma żadnych identyfikatorów ani zaszyfrowanych wiadomości występujących wspólnie z wysłanym i odbieranym ruchem serwera, dzięki dodatkowej warstwie szyfrowania dostarczanych wiadomości. Jeśli więc ktoś obserwuje ruch na serwerze, nie może łatwo określić, kto komunikuje się z kim (sprawdź [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) by dowiedzieć się o znanych atakach korelacji ruchu). + +### Użytkownicy są właścicielami sieci SimpleX + +Możesz używać SimpleX na własnych serwerach i nadal komunikować się z ludźmi za pomocą serwerów, które są wstępnie skonfigurowane w aplikacjach lub z dowolnymi innymi serwerami SimpleX. + +Platforma SimpleX korzysta z otwartego protokołu i zapewnia zestaw SDK do tworzenia czatbotów, umożliwiając implementację usług, z którymi użytkownicy mogą wchodzić w interakcje za pośrednictwem aplikacji SimpleX Chat - naprawdę nie możemy się doczekać, aby zobaczyć, jakie usługi oparte o SimpleX można stworzyć. + +Jeśli rozważasz stworzenie czegoś w oparciu o platformę SimpleX, niezależnie od tego, czy chodzi o usługi czatbotów dla użytkowników aplikacji SimpleX, czy też integrację biblioteki SimpleX Chat z aplikacjami mobilnymi, skontaktuj się z nami, aby uzyskać porady i wsparcie. + +## Porównanie z innymi protokołami + +| | SimpleX Chat | Signal, duże platformy | XMPP, Matrix | Protokoły P2P | +| :---------------------------------------------------------- | :----------------------: | :--------------------: | :-------------: | :-------------: | +| Wymaga identyfikatorów użytkownika | Nie = prywatny | Tak1 | Tak2 | Tak3 | +| Możliwość ataku MITM | Nie = bezpieczny | Tak4 | Tak | Tak | +| Polega na DNS | Nie = odporny na cenzurę | Tak | Tak | Nie | +| Pojedynczy operator lub sieć | Nie = zdecentralizowany | Tak | Nie | Tak5 | +| Scentralizowanie lub możliwość ataku obejmującego całą sieć | Nie = odporny na cenzurę | Tak | Tak2 | Tak6 | + +1. Zwykle opiera się na numerze telefonu, w niektórych przypadkach na nazwie użytkownika. +2. Bazuje na DNS. +3. Klucz publiczny lub inny globalnie unikalny identyfikator. +4. Jeśli serwery operatora zostaną przejęte. +5. Mimo że sieci P2P i sieci oparte na kryptowalutach są rozproszone, nie są w pełni zdecentralizowane - działają jako pojedyncza sieć, z pojedynczą przestrzenią nazw adresów użytkowników. +6. Sieci P2P albo mają jakiś centralny serwer, albo cała sieć może zostać przejęta - patrz następna sekcja. + +## Porównanie z komunikatorami [P2P][9] + +Istnieje kilka protokołów czatu/wiadomości P2P i implementacji, które mają na celu rozwiązanie problemu prywatności i centralizacji, ale mają one swój własny szereg problemów, które sprawiają, że są mniej niezawodne niż proponowany projekt, są bardziej skomplikowane w implementacji i analizie oraz są bardziej podatne na ataki. + +1. Sieci [P2P][9] korzystają z jakiegoś rodzaju [DHT][10] do routowania wiadomości/zapytań po sieci. Implementacje DHT mają złożone konstrukcje, muszą równoważyć niezawodność, gwarancję dostawy i czas oczekiwania. Proponowany model zapewnia zarówno większą gwarancję dostarczalności, jak i mniejsze opóźnienia (wiadomość jest przekazywana wiele razy równolegle, za każdym razem przez jeden węzeł, przy użyciu serwerów wybranych przez odbiorcę, podczas gdy w sieciach P2P wiadomość jest przekazywana przez `O(log N)` węzłów sekwencyjnie, przy użyciu węzłów wybranych przez algorytm). + +2. Proponowany model, w przeciwieństwie do większości sieci P2P, nie posiada żadnych globalnych identyfikatorów użytkowników, nawet tymczasowych. + +3. P2P samo w sobie nie rozwiązuje problemu [ataku MITM][2], a większość istniejących rozwiązań nie wykorzystuje komunikacji out-of-band do początkowej wymiany kluczy. Proponowany projekt wykorzystuje wiadomości out-of-band lub (w niektórych przypadkach) istniejące wcześniej bezpieczne i zaufane połączenia do początkowej wymiany kluczy. + +4. Implementacje P2P mogą być blokowane przez niektórych dostawców Internetu (tak jak [BitTorrent][11]). Proponowany model jest niezależny od rodzaju transmisji - może działać na standardowych protokołach sieciowych, a serwery mogą działać na tych samych domenach, co strony internetowe. + +5. Wszystkie znane sieci P2P mogą być podatne na [atak typu Sybil][12], ponieważ każdy węzeł jest wykrywalny, a sieć działa jako całość. Znane środki mające na celu zmniejszenie prawdopodobieństwa ataku typu Sybil wymagają zastosowania scentralizowanego komponentu lub kosztownego [proof of work][13]. Proponowany model, przeciwnie, nie ma możliwości wykrycia serwera - serwery nie są połączone, nie są znane sobie nawzajem i wszystkim klientom. Sieć SimpleX jest pofragmentowana i działa jako wiele odizolowanych połączeń. Uniemożliwia to ataki na całą sieć SimpleX - nawet jeśli niektóre serwery są zagrożone, inne części sieci mogą działać normalnie, a dotknięci atakiem użytkownicy mogą przełączyć się na inne serwery bez utraty kontaktów lub wiadomości. + +6. Sieci P2P są prawdopodobnie [podatne][14] na [atak DRDoS][15]. W proponowanym modelu klienci przekazują tylko ruch ze znanych zaufanych połączeń i nie mogą być wykorzystywani do odbijania i wzmacniania ruchu w całej sieci. + +[1]: https://pl.wikipedia.org/wiki/Szyfrowanie_od_ko%C5%84ca_do_ko%C5%84ca +[2]: https://pl.wikipedia.org/wiki/Atak_man_in_the_middle +[9]: https://pl.wikipedia.org/wiki/Peer-to-peer +[10]: https://pl.wikipedia.org/wiki/Rozproszona_tablica_mieszaj%C4%85ca +[11]: https://pl.wikipedia.org/wiki/BitTorrent +[12]: https://en.wikipedia.org/wiki/Sybil_attack +[13]: https://pl.wikipedia.org/wiki/Proof_of_Work +[14]: https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent +[15]: https://pl.wikipedia.org/wiki/DRDoS diff --git a/docs/lang/pl/TRANSLATIONS.md b/docs/lang/pl/TRANSLATIONS.md new file mode 100644 index 0000000000..36daa5a148 --- /dev/null +++ b/docs/lang/pl/TRANSLATIONS.md @@ -0,0 +1,104 @@ +--- +title: Współtworzenie tłumaczenia SimpleX Chat +revision: 19.03.2023 +--- + +| 19.03.2023 | PL, [EN](/docs/TRANSLATIONS.md), [CZ](/docs/lang/cs/TRANSLATIONS.md), [FR](/docs/lang/fr/TRANSLATIONS.md)| + +# Współtworzenie tłumaczenia SimpleX Chat + +Dziękujemy za zainteresowanie się tłumaczeniem SimpleX Chat - to bardzo pomaga w uczynieniu go dostępnym dla szerszego grona użytkowników i naprawdę doceniamy Twoją pomoc. + +Wymaga to znacznej inwestycji czasu - większość ludzi tego początkowo nie docenia - oraz stałej opieki w miarę rozwoju aplikacji. + +Ten dokument został stworzony, po to by przyspieszyć ten proces i podzielić się kilkoma ważnymi "gafami", które odkryliśmy podczas pracy z Weblate - platformą, której używamy do tłumaczeń interfejsu. + +## Zanim rozpoczniesz tłumaczenie + +1. Utwórz konto w Weblate, używając tego samego adresu e-mail, którego używasz na platformie GitHub - dzięki temu Twój wkład będzie powiązany z kontem GitHub, co może okazać się dla Ciebie przydatne w niektórych przypadkach. Gdy tłumaczenie zostanie udostępnione użytkownikom, dodamy nazwę twojego konta do [listy tłumaczy] (https://github.com/simplex-chat/simplex-chat#translate-the-apps), chyba że poprosisz nas, abyśmy tego nie robili. + +2. Przed rozpoczęciem tłumaczenia należy podpisać prostą umowę licencyjną za pośrednictwem Weblate - ma to na celu uniknięcie konfliktów związanych z prawami własności intelektualnej. Kopia tej umowy jest również [dostępna tutaj](https://github.com/simplex-chat/cla/blob/master/CLA.md). + +3. Możemy również dodać Cię do grupy tłumaczy w przypadku jakichkolwiek pytań i aktualizacji - skontaktuj się z programistami za pośrednictwem czatu (po zainstalowaniu aplikacji lub później, poprzez "Wyślij pytania i pomysły" w ustawieniach aplikacji). + +## Proces tłumaczenia + +Najłatwiej jest najpierw przetłumaczyć aplikację na Androida, a dopiero później aplikację na iOS, ponieważ przetłumaczone ciągi Androidowej aplikacji są skonfigurowane jako słownik dla iOS. + +Kroki są następujące: + +1. [Tłumaczysz aplikację na Androida](#translating-android-app) w Weblate. +2. [Sprawdzamy i publikujemy tłumaczenia aplikacji na Androida](#releasing-android-app-translations). +3. Sprawdzasz tłumaczenia w aplikacji i poprawiasz ewentualne błędy. +4. [Tłumaczysz aplikację iOS w Weblate](#translating-ios-app). +5. Sprawdzamy i publikujemy tłumaczenia aplikacji iOS. + +### Tłumaczenie aplikacji na Androida + +1. Zacznij od [aplikacji na Androida](https://hosted.weblate.org/projects/simplex-chat/android/), zarówno podczas wykonywania najbardziej czasochłonnego tłumaczenia wstępnego, jak i dodawania ciągów później. Ze względu na to, że po pierwsze, ciągi w systemie iOS mogą pojawiać się w Weblate z pewnym opóźnieniem, ponieważ wymagają ręcznego zatwierdzenia z naszej strony, zanim będą widoczne, a po drugie, aplikacja na Androida jest skonfigurowana jako słownik dla aplikacji na iOS. 2/3 wszystkich ciągów wymaga tylko kliknięcia, aby przenieść je z Androida na iOS (nadal zajmuje to trochę czasu, Weblate niestety tego nie automatyzuje). + +2. Niektóre ciągi nie wymagają tłumaczenia, ale nadal trzeba je skopiować - w interfejsie użytkownika weblate znajduje się odpowiedni przycisk: + +weblate: copy source to translation + +3. Weblate posiada również automatyczne sugestie, które mogą przyspieszyć ten proces. Czasami mogą być używane w niezmienionej formie, a czasami wymagają edycji - kliknij, aby użyć ich w tłumaczeniach. + +4. Zwróć również uwagę na Klucz ciągu (znajduje się po prawej stronie ekranu) - może on dać ci podpowiedź, co ten ciąg oznacza, gdy jego znaczenie jest niejasne. Przykładowo, klucz dla " Dodatkowy akcent" ( nie wiadomo) to "color_primary_variant" (nieco bardziej jasne, że odnosi się do koloru używanego w aplikacji). + +5. Gdy wszystkie ciągi w aplikacji na Androida zostaną przetłumaczone, przejrzyj je, aby zapewnić spójny styl i język, tak aby te same słowa były konsekwentnie używane do podobnych działań użytkownika, tak samo jak w języku angielskim. Czasami będziesz musiał użyć różnych słów w przypadkach, gdy angielski ma tylko jedno, spróbuj użyć tych wyborów spójnie w podobnych kontekstach, aby uprościć obsługę użytkownikom końcowym. + +Prosimy również o sprawdzenie tłumaczeń przy użyciu przeglądarki Chrome i funkcji *Tłumacz na angielski* w trybie _Przeglądaj_ w weblate - tak będziemy sprawdzać tłumaczenia przed ich opublikowaniem. Popraw wszelkie błędy i dodaj komentarze w przypadkach, gdy uzasadnione jest użycie różnych tłumaczeń - znacznie przyspieszy to weryfikację. + +### Udostępnianie tłumaczeń dla aplikacji na Androida + +Gdy aplikacja na Androida zostanie przetłumaczona, poinformuj nas o tym. + +My wtedy: + - przejrzymy wszystkie tłumaczenia i zasugerujemy ewentualne poprawki - to również zajmie trochę czasu :) + - scalimy je z kodem źródłowym - w tym czasie weblate będzie ustawiony na blokadę zmian. + - stworzymy wersje beta aplikacji na iOS i Androida - możemy również dodać Cię do wewnętrznych grup testerów, abyś mógł zainstalować aplikacje przed innymi. + - udostępnimy ją naszym użytkownikom korzystającym z wersji beta - już ponad tysiąc osób korzysta z wersji beta. + - wydamy aplikację i uwzględnimy nowy język w ogłoszeniu. + +### Tłumaczenie aplikacji iOS + +1. Podczas tłumaczenia [aplikacji iOS](https://hosted.weblate.org/projects/simplex-chat/ios/) duża część ciągów jest dokładnie taka sama - można je skopiować jednym kliknięciem w sekcji słowniczka. Wskazówką jest podświetlenie całego ciągu źródłowego na żółto. Wiele innych ciągów jest bardzo do siebie podobnych, różnią się jedynie składnią lub sposobem pogrubienia czcionki - wymagają one minimalnej edycji. Istnieją jednak pewne ciągi które są unikalne dla platformy iOS - należy je przetłumaczyć osobno + +2. Przejrzyj tłumaczenia na iOS w taki sam sposób jak na Androida i daj nam znać, kiedy będą gotowe do sprawdzenia - powtórzymy ten sam proces dla aplikacji na iOS. + +Serdecznie dziękujemy! To ogromny wysiłek i wielka pomoc dla rozwoju sieci SimpleX. + +weblate: automatic suggestions + +## Częste błędy w tłumaczeniu + +1. Słowo "chat" jest używane w kilku znaczeniach, w zależności od kontekstu. Może ono oznaczać "aplikację SimpleX Chat" (np. w opcji Rozpocznij/zatrzymaj czat) lub "pojedynczą rozmowę". Jeśli nie jest to jasne, zapytaj się nas, a my dodamy więcej uwag dotyczących tłumaczenia. + +2. Prosimy o używanie liczby mnogiej i pojedynczej tak jak w oryginalnych ciągach, w przeciwnym razie może to zmienić ich znaczenie. Przykładowo, niektóre ustawienia mają zastosowanie do wszystkich kontaktów, a niektóre tylko do jednego kontaktu, będzie to mylące dla użytkownika, jeśli użyjesz liczby mnogiej w obu przypadkach. + +3. Aplikacja używa "Passcode" do zapewnienia dostępu, a nie "hasła" ("password") - w wielu językach jest to tłumaczone jako "kod dostępu". Baza danych używa "Passphrase" - w wielu językach jest to tłumaczone jako "hasło". Prosimy o spójne używanie tych słów. + +4. "Rola" użytkownika. To słowo odnosi się do zestawu uprawnień posiadanych przez użytkownika, może to być "właściciel", "administrator", "członek" lub "obserwator" (najniższe uprawnienie, które pozwala tylko na czytanie wiadomości i dodawanie reakcji na wiadomości). Tłumaczenie tego jako "tożsamość" lub "funkcja" może być nieprawidłowe. + +5. "Moderate" / "moderated" ("moderować" / "zmoderowany"). Te słowa oznaczają odpowiednio "usunięcie wiadomości innego użytkownika" i "usunięcie przez administratora". Ta funkcja jest używana, gdy członek wysyła wiadomość, która nie jest odpowiednia dla grupy. Wiele języków ma podobne słowa. + +## Jak sprawdzamy tłumaczenia + +Aby zweryfikować poprawność tłumaczeń, sprawdzamy tłumaczenia poprzez przeglądanie stron Weblate w przeglądarce Google Chrome w trybie "Tłumacz na angielski". Na przykład, aby sprawdzić niemieckie tłumaczenia interfejsu Androida, ktoś z naszego zespołu przewinął [te 68 stron] (https://hosted.weblate.org/browse/simplex-chat/android/de/). + +Nie oczekujemy, że odwrócone tłumaczenie będzie dokładnie takie samo jak oryginał, rzadko się to zdarza, ale że będzie ogólnie poprawne. + +Znacznie ułatwiłoby to recenzję, gdybyś mógł wcześniej sprawdzić to w ten sam sposób i skomentować wszystkie przypadki, w których odwrócone tłumaczenia są zupełnie inne (mogą istnieć uzasadnione przypadki). + +## Co dalej + +1. W miarę aktualizowania aplikacji będziemy publikować aktualizacje w grupie tłumaczy. Nie masz absolutnie żadnego obowiązku tłumaczenia tych dodatkowych ciągów. Niemniej jednak bardzo docenimy, jeśli to zrobisz, ponieważ sprawia to, że użytkownicy mają o wiele lepsze wrażenia, gdy polegają na Twoich tłumaczeniach, niż gdyby jakaś nowa część aplikacji nie została przetłumaczona. + +2. Możesz jeszcze bardziej pomóc w popularyzacji SimpleX w swoim kraju / grupie językowej, tłumacząc [naszą stronę internetową](https://simplex.chat) (również [przez weblate](https://hosted.weblate.org/projects/simplex-chat/website/)) i/lub [dokumenty GitHub](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang) (jest to możliwe tylko przez git)! + +3. Ponadto, jeśli chcesz być moderatorem / administratorem grupy użytkowników w swoim języku, po przetłumaczeniu aplikacji możemy hostować taką grupę - przygotowujemy wytyczne dla społeczności i dodajemy kilka narzędzi moderacyjnych do aplikacji, która zostanie wydana w wersji 4.6 w marcu. + + +Jeszcze raz bardzo dziękujemy za pomoc w rozwoju SimpleX Chat! + +Evgeny, założyciel SimpleX Chat. diff --git a/docs/lang/pl/WEBRTC.md b/docs/lang/pl/WEBRTC.md new file mode 100644 index 0000000000..d279491fb8 --- /dev/null +++ b/docs/lang/pl/WEBRTC.md @@ -0,0 +1,158 @@ +--- +title: Korzystanie z niestandardowych serwerów WebRTC ICE w SimpleX Chat +revision: 31.01.2023 +--- + +| Updated 31.01.2023 | Języki: PL, [EN](/docs/WEBRTC.md), [FR](/docs/lang/fr/WEBRTC.md), [CZ](/docs/lang/cs/WEBRTC.md) | + +# Korzystanie z niestandardowych serwerów WebRTC ICE w SimpleX Chat + +## Instalacja serwera STUN/TURN + +W tym poradniku będziemy używać najbardziej funkcjonalnej i przetestowanej w boju implementacji serwera STUN/TURN - [`coturn`](https://github.com/coturn/coturn) i [Ubuntu 20.04 LTS`](https://ubuntu.com/download/server) dystrybucji Linuksa. + +0. Uzyskaj certyfikaty `stun.$TWOJA_DOMENA` i `turn.$TWOJA_DOMENA`. + + Używamy [Let's Encrypt](https://letsencrypt.org/getting-started/). + +1. Zainstaluj pakiet `coturn` z głównego repozytorium. + +```sh +apt update && apt install coturn` +``` + +2. Odkomentuj `TURNSERVER_ENABLED=1` z `/etc/default/coturn`: + +```sh +sed -i '/TURN/s/^#//g' /etc/default/coturn +``` + +3. Skonfiguruj `coturn` w `/etc/turnserver.conf`: + + Zobacz również komentarze dotyczące poszczególnych opcji. + +```sh +# Nasłuchuj również na porcie 443 dla tls +alt-tls-listening-port=443 +# Używaj odcisków palców w komunikatach TURN +fingerprint +# Użyj mechanizmu poświadczeń długoterminowych +lt-cred-mech +# Twoje poświadczenia +user=$YOUR_LOGIN:$YOUR_PASSWORD +# Domena Twojego serwera +server-name=$YOUR_DOMAIN +# Domyślny obszar, który ma być używany dla użytkowników, gdy nie znaleziono wyraźnej relacji pochodzenie/obszar +realm=$YOUR_DOMAIN +# Ścieżka do Twoich certyfikatów. Upewnij się, że są one czytelne dla użytkownika/grupy procesu cotun. +cert=/var/lib/turn/cert.pem +pkey=/var/lib/turn/key.pem +# Użyj predefiniowanego klucza DH TLS o długości 2066 bitów +dh2066 +# Logowanie do journalctl +syslog +# Użytkownik/grupa, która będzie uruchamiać usługę coturn +proc-user=turnserver +proc-group=turnserver +# Wyłącz słabe szyfrowanie +no-tlsv1 +no-tlsv1_1 +no-tlsv1_2 +``` + +4. Uruchom i włącz serwis `coturn`: + +```sh +systemctl enable coturn && systemctl start coturn +``` + +5. Opcjonalnie, jeśli używasz firewalla `ufw`, otwórz odpowiednie porty: + +- **3478** – "czysty" TURN/STUN; +- **5349** – TURN/STUN over TLS; +- **443** – TURN/STUN over TLS, który może omijać firewalle; +- **49152:65535** – zakres portów, który Coturn będzie domyślnie wykorzystywał dla przekaźnika TURN. + +```sh +# Dla Ubuntu +sudo ufw allow 3478 && \ +sudo ufw allow 443 && \ +sudo ufw allow 5349 && \ +sudo ufw allow 49152:65535/tcp && \ +sudo ufw allow 49152:65535/udp + +# Dla Fedory +sudo firewall-cmd --permanent --add-port=443/tcp && \ +sudo firewall-cmd --permanent --add-port=443/udp && \ +sudo firewall-cmd --permanent --add-port=5349/tcp && \ +sudo firewall-cmd --permanent --add-port=5349/udp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/tcp && \ +sudo firewall-cmd --permanent --add-port=49152:65535/udp && \ +sudo firewall-cmd --reload +``` + +## Konfiguracja aplikacji mobilnych + +Aby skonfigurować aplikację mobilną do korzystania z serwera: + +1. Otwórz `Ustawienia / Sieć i serwery / Serwery WebRTC ICE` i przełącz przełącznik `Konfiguruj serwery ICE`. + +2. Wprowadź wszystkie adresy serwerów w polu, po jednym na linię, na przykład jeśli serwery znajdują się na porcie 5349: + +``` +stun:stun.example.com:5349 +turn:username:password@turn.example.com:5349 +``` + +To tyle - teraz możesz wykonywać połączenia audio i wideo za pośrednictwem własnego serwera, bez udostępniania jakichkolwiek danych naszym serwerom (poza wymianą kluczy z kontaktem w szyfrowanych wiadomościach E2E). + +## Rozwiązywanie problemów + +- **Określ czy Twój serwer jest dostępny**: + + Uruchom to polecenie w terminalu: + + ```sh + ping + ``` + + Jeśli pakiety są transmitowane, serwer działa! + +- **Określ czy porty są otwarte**: + + Uruchom to polecenie w terminalu: + + ```sh + nc -zvw10 443 5349 + ``` + + Powinno się pojawić: + + ``` + Connection to 443 port [tcp/https] succeeded! + Connection to 5349 port [tcp/*] succeeded! + ``` + +- **Test połączenia STUN/TURN**: + + 1. Wejdź na [IceTest](https://icetest.info/). + + 2. W sekcji **Build up ICE Server List** dodaj: + + + + - `STUN: stun::` kliknij `Add STUN` + - `TURN: turn::`, `Username: `, `Credential: ` kliknij `Add TURN` + + Gdzie `` to 443 lub 5349. + + 3. Powinieneś zobaczyć swoje serwery w sekcji **ICE server list**. Jeśli wszystko jest skonfigurowane poprawnie, naciśnij `Start test`: + + + + 4. W sekcji **Results** powinieneś zobaczyć coś takiego: + + + + Jeśli wyniki pokazują `srflx` i `relay`, wszystko jest skonfigurowane poprawnie! + diff --git a/docs/rfcs/2024-04-01-super-peers-2.md b/docs/rfcs/2024-04-01-super-peers-2.md new file mode 100644 index 0000000000..1ebd72bc41 --- /dev/null +++ b/docs/rfcs/2024-04-01-super-peers-2.md @@ -0,0 +1,262 @@ +# Large public grups / channels + +This document describes specific design elements for the MVP of [the groups based on super-peers](./2024-03-14-super-peers.md). + +## Super-peer members + +There are two possible design approaches for super-peer members: + +1. Separate non-participating members that can only be added as a super-peers and do not have their own roles in the group, can't send their own messages or administer the group. + +2. Super-peer being a function of any member, irrespective of their role. + +While approach 1 can be simpler, it has its downsides: +- more complex migration of the existing groups - e.g., directory service cannot become a super-peer while remaining group admin (or moderator, if we add a new role). +- less usable clients - users can have desktop clients with good internet connectivity and sufficient computing resources to effectively host several medium size groups, even with SQLite database. +- it makes super-peers more like servers, being both unusable as clients and also requiring larger concurrency, and therefore increasing centralization. + +Therefore, the approach 2 looks more attractive, when super-peers must have some role in the group, and the super peers that cannot send messages will have observer role. + +As a side note, it also implies that it is beneficial to see the permission to moderate messages not as a role somewhere between Member and Admin, but as a separate privilege that admins and owners have by default for the messages from the members up to their role, but in general it's a separate member profile setting that allows to moderate messages up to a certain role. That approach would help automatic moderation as well, when moderators may be allowed to moderate messages of admins, without being able to remove members. + +Proposed protocol modifications: + +```haskell +-- this type is used in XGrpMemNew, XGrpMemIntro and XGrpMemFwd for member introductions +data MemberInfo = MemberInfo + { memberId :: MemberId, + memberRole :: GroupMemberRole, + rank :: Maybe Word8, -- new field, 0 for usual members, 1 for super-peers, allows to build additional distribution hierarhies if needed. + perms :: Maybe MemberPermissions, -- new field + v :: Maybe ChatVersionRange, + profile :: Profile + } + +-- this type is used in XGrpInv (in GroupInvitation) and XGrpLinkInv (in GroupLinkInvitation) +-- to invite members to the group +data MemberIdRole = MemberIdRole + { memberId :: MemberId, + memberRole :: GroupMemberRole, + rank :: Maybe Word8, -- new field + perms :: Maybe MemberPermissions -- new field + } + +-- new type +data MemberPermissions = MemberPermissions + { moderate :: MemberPermissionTarget -- could be extended to array in the future + } + +-- new type +data MemberPermissionTarget = MemberPermissionTarget + { maxRole :: Maybe GroupMemberRole -- if absent, can moderate all messages + } +``` + +For backwards compatibility, `admin` role implies `{moderate: {maxRole: admin}}` permission and `owner` - `{moderate: {maxRole: owner}}`, which probably can be overridden with `{moderate: {maxRole: observer}}`. + +It is also proposed to migrate `memberId` and `memberRole` to `id` and `role` in the parser (in a forward/backward compatible way), without changing serializers. + +## Group routing mode + +Irrespective of the presense of super peers in the group, the group itself has to be switched to super-peers routing at some point via a group profile update. + +```haskell +data GroupProfile = GroupProfile + { displayName :: GroupName, + fullName :: Text, + description :: Maybe Text, + image :: Maybe ImageData, + groupPreferences :: Maybe GroupPreferences, + rank :: Maybe Int, -- new field, 0 for flat groups, 1 for groups with super peers + redundancy :: Maybe GroupRedundancy, -- new field, only used when rank > 0 + consensus :: Maybe GroupConsensus -- new field, see below in + } + +-- each field defines an average number of super-peers that will deliver messages (events) related to a specific scope, +-- by default all super peers will deliver the events in that scope. +data GroupRedundancy = GroupRedundancy + { messages :: Maybe Double, -- messages and message changes, including reactions and comments + members :: Maybe Double, -- member additions and permission changes + group :: Maybe Double -- group profile and other changes + } +``` + +## Decisions about message or another event delivery + +In groups with rank 0 (current groups) all members aim to establish connections with all other members, making it hard to scale. Admins who added the members play a temporary role of forwarding messages, but only until members are connected. + +In groups with rank 1 super-peers connect to all members, but each message or event may be delivered by some rather than by all super-peers, to avoid substantial traffic increase. E.g., in groups with 2 super-peers it would be desirable to deliver each message 1.33 (4/3) times on average, while for groups with 3 super peers it can be 2 or 1.667 (5/3) times. + +The decision whether a given super peer should deliver the message would depend on these factors: +- deterministic (see below) message hash, `h`. +- receiving member ID, `r`. +- super-peer member ID, `s`. +- target message redundancy `d` (the desired number of super-peers to deliver the message). +- number of super-peers connected to member `r` (and known as connected to super-peer making the decision), `n`. +- 0-based index of the current super-peer in the sorted array of known super peer IDs, `i`. It does not require sorting all peer IDs, it's enough to count how many peers have smaller or larger IDs. + +The assumption here is that member ID is unique within the group, and that message hashes are also unique. Also, message hashes rather than sending member IDs are used to ensure that the decision is made differently for the same sender/recipient pairs, allowing recipients to identify integrity violations (e.g., if some of the super-peers decides to change the messages or fail to deliver it). + +For simplicity, all parameters are normalized to 0..1 range from their respective binary ranges. + +The algorithm to decide whether the message should be delivered by a given super-peer: + +``` +if (d >= n || n == 1) deliver; +else + prob = d/n ; delivery probability based on target delivery redundancy + point = (h + r)/2 ; as member ID and message hash are uniformly random in 0..1 range, `point` will also be uniformly random in 0..1 range + start = (1 - prob) * i / (n - 1) ; `start` for the peer with `i == 0` will be `0` and with `i == n - 1` will be `1 - prob` + end = start + prob ; `end` for peer 0 will be `prob` and for peer `n - 1` will be 1 + ; for all peers the range `start..end` will have width `prob` + if (point >= start && point < end) deliver + else skip; +``` + +This algorithm can be proven to result in target average delivery redundancy. + +It also requires all super peers to inform other super peers about: +- establishing or losing the connection with other members (e.g., when AUTH is received). +- informing about their decision to stop being super peers in advance. +- most likely using delivery receipts in communications between super-peers that would include message hashes in RCVD info. + +## Authorising administrative changes + +As members no longer send messages directly to other members, in addition to the risk of the initial MITM by admin (which is now mitigated by having multiple super-peers) there is a risk of super-peers being compromised at a later stage, and in case of administrative changes (member or group level changes) we have these options: + +1. have all members postpone these changes until they are communicated by a sufficient number of super-peers (that would require configuring consensus on the group level). +2. have admins and owners sign administrative changes with the public key included in their profile during member introduction. + +The downside of approach 1 is that administrative actions will be delayed and have to be executed not at the time the message is delivered, but at the time message is confirmed by other super-peers. That is separate and in addition to member consensus for some changes (see below). That also means that groups with one super-peer will have no defence mechanism against super-peer being compromised. That also means that groups with two super-peers will either also have no such defence (in case required consensus level is 1) or will require that both super-peers are available, and no administrative actions will be executed unless both super-peers are available. + +The downside of approach 2 is the lack of repudiation, in fact, there is a non-repudiation quality of such administrative changes as role changes, member additions and deletions, and group changes. + +Overall, while repudiation of sent messages appears as important, it seems much less important for administrative changes, and signing member and group changes while relying on Merkle DAG for message history integrity appears to be an optimal tradeoff. + +To support this functionality the `admin` and `owner` members have to add public keys to their profiles and communicate profile updates to all members (before or after group is switched to super-peers, but probably before is better). + +The middle ground here is moderation, and trade-off here is more nuanced. Practically, whether message is fully removed or marked as removed is the group policy decision, and it can be made based on whether it's more important to preserve content or to remove undesirable content without trace, and also given that super-peers are likely to be give automatic moderation capabilities anyway, preserving deniability for moderation events and relying on Merkle DAG to identify integrity violation seems a better alternative than signing moderation messages. Although it can also be a part of group policy whether to require signing moderation events. + +The change to member profile will be: + +```haskell +data Profile = Profile + { displayName :: ContactName, + fullName :: Text, + image :: Maybe ImageData, + contactLink :: Maybe ConnReqContact, + preferences :: Maybe Preferences, + authKey :: Maybe AuthKey -- new field + } + +-- this is rather ad hoc and tries to allow two things: +-- - verify that the member has the private key. +-- - allow key rotations on profile changes, without signing the whole profile change. +-- A better option could be to use certificates, although they are much large in size, +-- and also to simply sign profile changes that include key change (then prevKeySignature won't be needed) +data AuthKey = AuthKey + { key :: PublicKeyEd25519, + signature :: SignatureEd25519, -- signature of the key itself + prevKeySignature :: Maybe SignatureEd25519 + } +``` + +## Role, rank and moderation permission changes of members and group profile changes + +There are two problems with the current approach to changing permission, when a single member makes this decision: +- member whose role changes may disagree. E.g., the member may not be willing to have owner or admin role in some groups. Even more so, the member may be unable to perform super-peer functions (have rank 1). +- other owners or admins may disagree with the change or it may have been made by mistake. + +With the move to super-peers it is additionally complicated by the fact that super-peers will be forwarding these messages, and they could be compromised, thus disrupting group functioning - this risk currently exists with the existing group directory that plays admin role and in case it is compromised it can remove all members from the group. + +Part of this problem is addressed in the previous point by requiring to sign administrative changes. + +Another part is about the members agreeing to changes that affect them when additional privileges are granted, and also by requiring the consensus between group owners and admins for any privilege changes. + +There are 2 options for proposals/acceptance/approvals flow design: +1. introduce additional protocol messages for each stage, and for each type of change to complement existing messages. +2. manage these stages in orthogonal way, by adding these stages on the top level of the protocol. + +The option 2 is likely to result in a more concise protocol as it will separate approval from from the events, also allowing to include authorizations in a standardized way. This also fits well in `XGrpMsgForward` event that includes that message inside, so all authorizations will be forwarded. + +```haskell +-- fields are the number of member approvals required for actions with that role. +-- As target member acceptance is required only for privilege increases it won't be among approvals required for consensus. +-- For example, for group with consensus = {admin: 3, owners: 2}, a new member can be made admin with the decision of 3 other admins or with the decision of single owner (as owner role is higher), or the group can be removed, a new admin is added or group consensus changed with the decision of 2 admins. +-- Owners leaving without changing the consensus can result in consensus becoming unreachable - this is not different from losing a single member, and it only prevents accidental or malicious destructive actions, but does not prevent losing access. +data GroupConsensus = GroupConsensus + { admin :: Maybe Word16, -- possibly, this field is not needed + owner :: Maybe Word16 -- 1 by default + } + +-- alternatively, the type could be more flexible than that, but it can be extended if needed. + +data ChatMessage e = ChatMessage + { chatVRange :: VersionRangeChat, + msgId :: Maybe SharedMsgId, + chatMsgEvent :: ChatMsgEvent e, + stage :: Maybe MessageStage, -- new field, Nothing for broadcasted messages + bcast :: Maybe MessageBroadcast, -- new field instructing super-peers how to broadcast the message + auth :: Maybe (Either MemberApproval [MemberApproval]) -- approvals for broadcasted messages from members, Either won't be in encoding + } + +data MessageStage = MSProposed | MSApproved + +-- possibly, this could include approval stage but it is implied by the context, message needs to be approved by: +-- - the sender with the sufficient permissions +-- - the target of the change when privileges are granted +-- - other admins or owners as required by `consensus` property in group profile. +data MemberApproval = MemberApproval + { memberId :: MemberId, -- can be encoded as id? + auth :: SignatureEd25519 + } + +data MessageBroadcast = MessageBroadcast + { from :: Maybe MessageFrom, -- MFSender by default + auth :: Maybe Bool, -- whether to keep auth if present, True by default, False means to validate and remove auth + schedule :: Maybe MessageSchedule + } + +data MessageFrom = MFSender | MFApprovers + +data MessageSchedule = MessageSchedule + { deliverAt :: Maybe UTCTime, -- when received by super-peer by default + minDelay :: Maybe Int, -- seconds, added to start, 0 by default + maxDelay :: Maybe Int -- seconds, added to start, 0 by default + } + +-- e.g., to deliver at least 2 hours after received by super-peer, the MessageSchedule would be {minDelay :: 7200} +-- or, to deliver all messages 20-40 seconds after it is received, to complicate traffic correlation it would be {minDelay: 20, maxDelay: 40} +-- or, to deliver at a scheduled time {deliverAt: "2024-04-01T00:00:00Z"} +``` + +The signature is computed over deterministic message hash excluding `auth` property. This structure would also allow asking super-peers to forward the message as originating from multiple owners or admins without showing who originated the message, as long as necessary approvals are present. + +That will require extending `XGrpMsgForward` to support array of MemberId and allow UI to show multiple senders of the message: + +```haskell + | XGrpMsgForward :: [MemberId] -> ChatMessage 'Json -> UTCTime -> ChatMsgEvent 'Json +``` + +The messages send from multiple owners can have the benefit of avoiding targeted attacks on the member who originated it - it will only be known to other members sending the message, but not to other members, creating mutual responsibility for some changes and/or announcements. + +## Choosing MVP scope + +With all these ideas for improvements, the challenge is to choose some valuable MVP shippable in the shortest time. + +From the UX point of view, the most lacking parts are: + +- broadcasting messages via super-peers, including: + - protocol to accept and approve role changes is necessary, as it should not be possible to unilaterally appoint some member to be a super-peer. + - computing deterministic message hashes and signatures (and only member with the key in profile can be made super-peer). + - protocol to communicate connections with members between super-peers, yet TBD. + - algorithm to make decision whether to deliver message. +- an equivalent of Telegram channels - groups where members can only send comments and set reactions, but cannot send messages. Protocol extensions yet TBD. + +Optionally, we could include: +- Merkle tree integrity validation. +- Owners/admins consensus. +- Messages from multiple members (don't have to be owners, can be any members, irrespective of the need for consensus). +- Delayed messages. + +None of this optional list is required to launch. diff --git a/docs/rfcs/2024-04-16-ip-address-protection.md b/docs/rfcs/2024-04-16-ip-address-protection.md new file mode 100644 index 0000000000..0019ad87ca --- /dev/null +++ b/docs/rfcs/2024-04-16-ip-address-protection.md @@ -0,0 +1,81 @@ +# IP address protection, support for SMP sending proxies + +## Problem + +IP addresses of senders being visible to recipients' chosen servers, which in case of self-hosting makes it visible to recipients themselves. In case of XFTP files the issue is reversed, with IP addresses of recipients being visible to senders' chosen servers. + +## Solution + +### SMP + +- Agent to support sending proxies +- New network settings configuration "Use SMP proxies" + - Can be set to "always", "never", "for unknown servers" + - Currently configured servers to be considered as "known", including those disabled for new connections + - Initial default is "never" to allow opt-in for testing, to be changed to "for unknown servers" later + - Support in UI +- No alerts about unknown servers are required in UI +- Tor setting to not affect agent decision to use SMP proxy for each given server + +``` haskell +data UseSMPProxies + = SMPPAlways + | SMPPNever + | SMPPUnknown + deriving (Eq, Show) + +data NetworkConfig = NetworkConfig + { ... + useSMPProxies :: UseSMPProxies, + ... + } +``` + +### XFTP + +Some considerations: + +- XFTP proxying is not planned to be implemented initially + - In future it could be done via open socket and with no persistance, and would be required only for FGET command +- Currently XFTP files are automatically set for reception in some cases: + - Images and voice messages + - In background (via "set to receive") +- Agent resumes file reception (download) after app re-start, in case they weren't fully downloaded +- User may change tor setting between and during app sessions, so a file can be "accepted" when app is connected via tor, but resumed when it's no longer + +Solution: + +- Client would make a decision whether to automatically accept file, or alert user about unknown servers +- Add only_via_tor flag to agent rcv_files + pass through APIs: + - add Bool to `ReceiveFile` (with False meaning user hasn't indicated any intent regarding unknown servers) + - can either be automatically accepted, or require alerting user and asking for confirmation (see below) + - add `onlyViaTor` to `xftpReceiveFile` +- Possible scenarios: + 1. XFTP file chunks are fully available on known servers + - Do not show alert, accept automatically (images, voice messages) or without approval (other files) + - `onlyViaTor` is False + 2. Some file chunks are on unknown servers, tor is not enabled + - User accepts manually, show alert to user + - User confirms, indicating it's Ok to proceed even though IP would be visible to unknown servers + - `onlyViaTor` is False + - \* Additional user preference to never ask? -> behavior same as now + 3. Some file chunks are on unknown servers, tor is enabled + - Do not show alert, accept automatically (images, voice messages) or without approval (other files) + - `onlyViaTor` is True +- On file download: + - `onlyViaTor` equal False is ignored + - If `onlyViaTor` is True, and tor is not enabled, throw new "permanent" error + - Permanent for simplicity as this is an edge case + - File record to be removed from agent + - Could analyze whether it's remaining (not yet downloaded) chunks are on unknown servers, and only abort in this case (and instead proceed if remaining chunks are on known servers) + - This requires changing download to load data for all chunks instead of loading only current chunk + - This is another edge case of edge case, as it seems more likely that sender either used only self-hosted servers, or only preset servers + - So it seems as unnecessary complication, and it should be Ok to abort simply based on only_via_tor flag + tor setting + - Error is RFERR XFTP UNKNOWN_NO_PROXY (new constructor) +- Chat to differentiate RFERR, and make file available for re-download on UNKNOWN_NO_PROXY error, similar to when file is cancelled + - New CIFileStatus - CIFSRcvCancelledNoProxy, to differentiate in UI + - Different icon for retry, alert explaining why file was aborted + +### Trusted servers + +We also considered an idea of trusted servers, but it proved to have unnecessarily complex UX, and in case of XFTP had issues on file download continuation after restart (e.g. server "trusted" flag changing, automatically accepting with tor enabled). It requires additional consideration and may be better suited to concept of "server providers". diff --git a/package.yaml b/package.yaml index 3e187c5653..e844c52c26 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 5.6.1.1 +version: 5.7.0.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/packages/simplex-chat-webrtc/src/call.ts b/packages/simplex-chat-webrtc/src/call.ts index accbebb22d..e7788ac723 100644 --- a/packages/simplex-chat-webrtc/src/call.ts +++ b/packages/simplex-chat-webrtc/src/call.ts @@ -245,9 +245,10 @@ const processCommand = (function () { } const defaultIceServers: RTCIceServer[] = [ + {urls: ["stuns:stun.simplex.im:443"]}, {urls: ["stun:stun.simplex.im:443"]}, - {urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"}, - {urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z"}, + //{urls: ["turns:turn.simplex.im:443?transport=udp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"}, + {urls: ["turns:turn.simplex.im:443?transport=tcp"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj"}, ] function getCallConfig(encodedInsertableStreams: boolean, iceServers?: RTCIceServer[], relay?: boolean): CallConfig { @@ -320,7 +321,17 @@ const processCommand = (function () { } async function initializeCall(config: CallConfig, mediaType: CallMediaType, aesKey?: string): Promise { - const pc = new RTCPeerConnection(config.peerConnectionConfig) + let pc: RTCPeerConnection + try { + pc = new RTCPeerConnection(config.peerConnectionConfig) + } catch (e) { + console.log("Error while constructing RTCPeerConnection, will try without 'stuns' specified: " + e) + const withoutStuns = config.peerConnectionConfig.iceServers?.filter((elem) => + typeof elem.urls === "string" ? !elem.urls.startsWith("stuns:") : !elem.urls.some((url) => url.startsWith("stuns:")) + ) + config.peerConnectionConfig.iceServers = withoutStuns + pc = new RTCPeerConnection(config.peerConnectionConfig) + } const remoteStream = new MediaStream() const localCamera = VideoCamera.User const localStream = await getLocalMediaStream(mediaType, localCamera) @@ -576,6 +587,9 @@ const processCommand = (function () { // setupVideoElement(videos.remote) videos.local.srcObject = call.localStream videos.remote.srcObject = call.remoteStream + // Without doing it manually Firefox shows black screen but video can be played in Picture-in-Picture + videos.local.play() + videos.remote.play() } async function setupEncryptionWorker(call: Call) { @@ -651,7 +665,9 @@ const processCommand = (function () { codecs.splice(selectedCodecIndex, 1) codecs.unshift(selectedCodec) for (const t of call.connection.getTransceivers()) { - if (t.sender.track?.kind === "video") { + // Firefox doesn't have this function implemented: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1396922 + if (t.sender.track?.kind === "video" && t.setCodecPreferences) { t.setCodecPreferences(codecs) } } @@ -673,7 +689,18 @@ const processCommand = (function () { } return } - for (const t of call.localStream.getTracks()) t.stop() + if (!call.screenShareEnabled) { + for (const t of call.localStream.getTracks()) t.stop() + } else { + // Don't stop audio track if switching to screenshare + for (const t of call.localStream.getVideoTracks()) t.stop() + // Replace new track from screenshare with old track from recording device + for (const t of localStream.getAudioTracks()) { + t.stop() + localStream.removeTrack(t) + } + for (const t of call.localStream.getAudioTracks()) localStream.addTrack(t) + } call.localCamera = camera const audioTracks = localStream.getAudioTracks() @@ -689,6 +716,7 @@ const processCommand = (function () { replaceTracks(pc, videoTracks) call.localStream = localStream videos.local.srcObject = localStream + videos.local.play() } function replaceTracks(pc: RTCPeerConnection, tracks: MediaStreamTrack[]) { @@ -738,7 +766,9 @@ const processCommand = (function () { //}, //aspectRatio: 1.33, }, - audio: true, + audio: false, + // This works with Chrome, Edge, Opera, but not with Firefox and Safari + // systemAudio: "include" } return navigator.mediaDevices.getDisplayMedia(constraints) } diff --git a/packages/simplex-chat-webrtc/src/desktop/call.html b/packages/simplex-chat-webrtc/src/desktop/call.html index 59ca2b58b0..2600062b02 100644 --- a/packages/simplex-chat-webrtc/src/desktop/call.html +++ b/packages/simplex-chat-webrtc/src/desktop/call.html @@ -10,7 +10,6 @@ diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index d2701dc45f..0b264d3628 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."6bc4f6c94e11f59604b0d9c576e62e01bc08b4cd" = "08l00ay1ibz7skhlpfjp6z2821zpfd0kxplwdm6zc63m2f6za7cv"; + "https://github.com/simplex-chat/simplexmq.git"."c00c223f3bb295a62d8507e453bbeac61d102e3a" = "0zbsz70rjhvrlkkiwnw43v7lg6r05lp5rwk7jmnn21zfjib4l621"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index fb0635abad..aac942c6b3 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: 5.6.1.1 +version: 5.7.0.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -140,6 +140,7 @@ library Simplex.Chat.Migrations.M20240228_pq Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id Simplex.Chat.Migrations.M20240324_custom_data + Simplex.Chat.Migrations.M20240402_item_forwarded Simplex.Chat.Mobile Simplex.Chat.Mobile.File Simplex.Chat.Mobile.Shared @@ -174,6 +175,7 @@ library Simplex.Chat.Terminal.Output Simplex.Chat.Types Simplex.Chat.Types.Preferences + Simplex.Chat.Types.Shared Simplex.Chat.Types.Util Simplex.Chat.Util Simplex.Chat.View @@ -574,6 +576,7 @@ test-suite simplex-chat-test ChatTests.ChatList ChatTests.Direct ChatTests.Files + ChatTests.Forward ChatTests.Groups ChatTests.Local ChatTests.Profiles diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 7948875180..2e19dd73d9 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -80,17 +80,19 @@ import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util -import Simplex.Chat.Util (encryptFile, shuffle) +import Simplex.Chat.Util (encryptFile, liftIOEither, shuffle) +import qualified Simplex.Chat.Util as U import Simplex.FileTransfer.Client.Main (maxFileSize, maxFileSizeHard) import Simplex.FileTransfer.Client.Presets (defaultXFTPServers) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD import Simplex.FileTransfer.Protocol (FileParty (..), FilePartyI) import Simplex.Messaging.Agent as Agent -import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError) +import Simplex.Messaging.Agent.Client (AgentStatsKey (..), SubInfo (..), agentClientStore, getAgentWorkersDetails, getAgentWorkersSummary, temporaryAgentError, withLockMap) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (..), InitialAgentServers (..), createAgentStore, defaultAgentConfig) -import Simplex.Messaging.Agent.Lock +import Simplex.Messaging.Agent.Lock (withLock) import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Agent.Protocol as AP (AgentErrorType (..)) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation (..), MigrationError, SQLiteStore (dbNew), execSQL, upMigration, withConnection) @@ -227,6 +229,7 @@ newChatController connNetworkStatuses <- atomically TM.empty subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO + entityLocks <- atomically TM.empty sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty currentCalls <- atomically TM.empty @@ -263,6 +266,7 @@ newChatController connNetworkStatuses, subscriptionMode, chatLock, + entityLocks, sndFiles, rcvFiles, currentCalls, @@ -310,6 +314,40 @@ newChatController userServers :: User -> IO (NonEmpty (ProtoServerWithAuth p)) userServers user' = activeAgentServers config protocol <$> withTransaction chatStore (`getProtocolServers` user') +withChatLock :: String -> CM a -> CM a +withChatLock name action = asks chatLock >>= \l -> withLock l name action + +withEntityLock :: String -> ChatLockEntity -> CM a -> CM a +withEntityLock name entity action = do + chatLock <- asks chatLock + ls <- asks entityLocks + atomically $ unlessM (isEmptyTMVar chatLock) retry + withLockMap ls entity name action + +withInvitationLock :: String -> ByteString -> CM a -> CM a +withInvitationLock name = withEntityLock name . CLInvitation +{-# INLINE withInvitationLock #-} + +withConnectionLock :: String -> Int64 -> CM a -> CM a +withConnectionLock name = withEntityLock name . CLConnection +{-# INLINE withConnectionLock #-} + +withContactLock :: String -> ContactId -> CM a -> CM a +withContactLock name = withEntityLock name . CLContact +{-# INLINE withContactLock #-} + +withGroupLock :: String -> GroupId -> CM a -> CM a +withGroupLock name = withEntityLock name . CLGroup +{-# INLINE withGroupLock #-} + +withUserContactLock :: String -> Int64 -> CM a -> CM a +withUserContactLock name = withEntityLock name . CLUserContact +{-# INLINE withUserContactLock #-} + +withFileLock :: String -> Int64 -> CM a -> CM a +withFileLock name = withEntityLock name . CLFile +{-# INLINE withFileLock #-} + activeAgentServers :: UserProtocol p => ChatConfig -> SProtocolType p -> [ServerCfg p] -> NonEmpty (ProtoServerWithAuth p) activeAgentServers ChatConfig {defaultServers} p = fromMaybe (cfgServers p defaultServers) @@ -668,107 +706,30 @@ processChatCommand' vr = \case [] -> pure Nothing memStatuses -> pure $ Just $ map (uncurry MemberDeliveryStatus) memStatuses _ -> pure Nothing - pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses} - APISendMessage (ChatRef cType chatId) live itemTTL (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> withChatLock "sendMessage" $ case cType of - CTDirect -> do - ct@Contact {contactId, contactUsed} <- withStore $ \db -> getContact db vr user chatId - assertDirectAllowed user MDSnd ct XMsgNew_ - unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct - if isVoice mc && not (featureAllowed SCFVoice forUser ct) - then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) - else do - (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct - timed_ <- sndContactCITimed live ct itemTTL - (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ - (msg, _) <- sendDirectContactMessage user ct (XMsgNew msgContainer) - ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live - forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) - pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) - where - setupSndFileTransfer :: Contact -> CM (Maybe (FileInvitation, CIFile 'MDSnd)) - setupSndFileTransfer ct = forM file_ $ \file -> do - fileSize <- checkSndFile file - xftpSndFileTransfer user file fileSize 1 $ CGContact ct - prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> CM (MsgContainer, Maybe (CIQuote 'CTDirect)) - prepareMsg fInv_ timed_ = case quotedItemId_ of - Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - Just quotedItemId -> do - CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- - withStore $ \db -> getDirectChatItem db user chatId quotedItemId - (origQmc, qd, sent) <- quoteData qci - let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - qmc = quoteContent mc origQmc file - quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) - where - quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool) - quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote - quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) - quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) - quoteData _ = throwChatError CEInvalidQuote - CTGroup -> do - g@(Group gInfo _) <- withStore $ \db -> getGroup db vr user chatId - assertUserGroupRole gInfo GRAuthor - send g - where - send g@(Group gInfo@GroupInfo {groupId} ms) - | isVoice mc && not (groupFeatureAllowed SGFVoice gInfo) = notAllowedError GFVoice - | not (isVoice mc) && isJust file_ && not (groupFeatureAllowed SGFFiles gInfo) = notAllowedError GFFiles - | otherwise = do - (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer g (length $ filter memberCurrent ms) - timed_ <- sndGroupCITimed live gInfo itemTTL - (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ timed_ live - (msg, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) - ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ timed_ live - withStore' $ \db -> - forM_ sentToMembers $ \GroupMember {groupMemberId} -> - createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew - forM_ (timed_ >>= timedDeleteAt') $ - startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) - pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) - notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) - setupSndFileTransfer :: Group -> Int -> CM (Maybe (FileInvitation, CIFile 'MDSnd)) - setupSndFileTransfer g n = forM file_ $ \file -> do - fileSize <- checkSndFile file - xftpSndFileTransfer user file fileSize n $ CGGroup g + forwardedFromChatItem <- getForwardedFromItem user ci + pure $ CRChatItemInfo user aci ChatItemInfo {itemVersions, memberDeliveryStatuses, forwardedFromChatItem} + where + getForwardedFromItem :: User -> ChatItem c d -> CM (Maybe AChatItem) + getForwardedFromItem user ChatItem {meta = CIMeta {itemForwarded}} = case itemForwarded of + Just (CIFFContact _ _ (Just ctId) (Just fwdItemId)) -> + Just <$> withStore (\db -> getAChatItem db vr user (ChatRef CTDirect ctId) fwdItemId) + Just (CIFFGroup _ _ (Just gId) (Just fwdItemId)) -> + Just <$> withStore (\db -> getAChatItem db vr user (ChatRef CTGroup gId) fwdItemId) + _ -> pure Nothing + APISendMessage (ChatRef cType chatId) live itemTTL cm -> withUser $ \user -> case cType of + CTDirect -> + withContactLock "sendMessage" chatId $ + sendContactContentMessage user chatId live itemTTL cm Nothing + CTGroup -> + withGroupLock "sendMessage" chatId $ + sendGroupContentMessage user chatId live itemTTL cm Nothing CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - where - xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) - xftpSndFileTransfer user file fileSize n contactOrGroup = do - (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup - case contactOrGroup of - CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> - withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr - CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) - where - -- we are not sending files to pending members, same as with inline files - saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = - when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $ - withStore' $ - \db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr - saveMemberFD _ = pure () - pure (fInv, ciFile) - APICreateChatItem folderId (ComposedMessage file_ quotedItemId_ mc) -> withUser $ \user -> do - forM_ quotedItemId_ $ \_ -> throwError $ ChatError $ CECommandError "not supported" - nf <- withStore $ \db -> getNoteFolder db user folderId - createdAt <- liftIO getCurrentTime - let content = CISndMsgContent mc - let cd = CDLocalSnd nf - ciId <- createLocalChatItem user cd content createdAt - ciFile_ <- forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do - fsFilePath <- lift $ toFSFilePath filePath - fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs - chunkSize <- asks $ fileChunkSize . config - withStore' $ \db -> do - fileId <- createLocalFile CIFSSndStored db user nf ciId createdAt cf fileSize chunkSize - pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal} - let ci = mkChatItem cd ciId content ciFile_ Nothing Nothing Nothing False createdAt Nothing createdAt - pure . CRNewChatItem user $ AChatItem SCTLocal SMDSnd (LocalChat nf) ci - APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> withChatLock "updateChatItem" $ case cType of - CTDirect -> do + APICreateChatItem folderId cm -> withUser $ \user -> + createNoteFolderContentItem user folderId cm Nothing + APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of + CTDirect -> withContactLock "updateChatItem" chatId $ do ct@Contact {contactId} <- withStore $ \db -> getContact db vr user chatId assertDirectAllowed user MDSnd ct XMsgUpdate_ cci <- withStore $ \db -> getDirectCIWithReactions db user ct itemId @@ -790,7 +751,7 @@ processChatCommand' vr = \case else pure $ CRChatItemNotChanged user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) _ -> throwChatError CEInvalidChatItemUpdate CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate - CTGroup -> do + CTGroup -> withGroupLock "updateChatItem" chatId $ do Group gInfo@GroupInfo {groupId} ms <- withStore $ \db -> getGroup db vr user chatId assertUserGroupRole gInfo GRAuthor cci <- withStore $ \db -> getGroupCIWithReactions db user gInfo itemId @@ -825,10 +786,10 @@ processChatCommand' vr = \case _ -> throwChatError CEInvalidChatItemUpdate CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> withChatLock "deleteChatItem" $ case cType of - CTDirect -> do - (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}}) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId - case (mode, msgDir, itemSharedMsgId, editable) of + APIDeleteChatItem (ChatRef cType chatId) itemId mode -> withUser $ \user -> case cType of + CTDirect -> withContactLock "deleteChatItem" chatId $ do + (ct, CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, deletable}}) <- withStore $ \db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId + case (mode, msgDir, itemSharedMsgId, deletable) of (CIDMInternal, _, _, _) -> deleteDirectCI user ct ci True False (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertDirectAllowed user MDSnd ct XMsgDel_ @@ -837,10 +798,10 @@ processChatCommand' vr = \case then deleteDirectCI user ct ci True False else markDirectCIDeleted user ct ci msgId True =<< liftIO getCurrentTime (CIDMBroadcast, _, _, _) -> throwChatError CEInvalidChatItemDelete - CTGroup -> do + CTGroup -> withGroupLock "deleteChatItem" chatId $ do Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId - CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, editable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId - case (mode, msgDir, itemSharedMsgId, editable) of + CChatItem msgDir ci@ChatItem {meta = CIMeta {itemSharedMsgId, deletable}} <- withStore $ \db -> getGroupChatItem db user chatId itemId + case (mode, msgDir, itemSharedMsgId, deletable) of (CIDMInternal, _, _, _) -> deleteGroupCI user gInfo ci True False Nothing =<< liftIO getCurrentTime (CIDMBroadcast, SMDSnd, Just itemSharedMId, True) -> do assertUserGroupRole gInfo GRObserver -- can still delete messages sent earlier @@ -852,7 +813,7 @@ processChatCommand' vr = \case deleteLocalCI user nf ci True False CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" - APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withChatLock "deleteChatItem" $ do + APIDeleteMemberChatItem gId mId itemId -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId CChatItem _ ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}} <- withStore $ \db -> getGroupChatItem db user gId itemId case (chatDir, itemSharedMsgId) of @@ -862,44 +823,46 @@ processChatCommand' vr = \case (SndMessage {msgId}, _) <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId delGroupChatItem user gInfo ci msgId (Just membership) (_, _) -> throwChatError CEInvalidChatItemDelete - APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of + APIChatItemReaction (ChatRef cType chatId) itemId add reaction -> withUser $ \user -> case cType of CTDirect -> - withStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case - (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do - unless (featureAllowed SCFReactions forUser ct) $ - throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) - unless (ciReactionAllowed ci) $ - throwChatError (CECommandError "reaction not allowed - chat item has no content") - rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True - checkReactionAllowed rs - (SndMessage {msgId}, _) <- sendDirectContactMessage user ct $ XMsgReact itemSharedMId Nothing reaction add - createdAt <- liftIO getCurrentTime - reactions <- withStore' $ \db -> do - setDirectReaction db ct itemSharedMId True reaction add msgId createdAt - liftIO $ getDirectCIReactions db ct itemSharedMId - let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTDirect SMDSnd (DirectChat ct) $ CIReaction CIDirectSnd ci' createdAt reaction - pure $ CRChatItemReaction user add r - _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + withContactLock "chatItemReaction" chatId $ + withStore (\db -> (,) <$> getContact db vr user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do + unless (featureAllowed SCFReactions forUser ct) $ + throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) + unless (ciReactionAllowed ci) $ + throwChatError (CECommandError "reaction not allowed - chat item has no content") + rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True + checkReactionAllowed rs + (SndMessage {msgId}, _) <- sendDirectContactMessage user ct $ XMsgReact itemSharedMId Nothing reaction add + createdAt <- liftIO getCurrentTime + reactions <- withStore' $ \db -> do + setDirectReaction db ct itemSharedMId True reaction add msgId createdAt + liftIO $ getDirectCIReactions db ct itemSharedMId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTDirect SMDSnd (DirectChat ct) $ CIReaction CIDirectSnd ci' createdAt reaction + pure $ CRChatItemReaction user add r + _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTGroup -> - withStore (\db -> (,) <$> getGroup db vr user chatId <*> getGroupChatItem db user chatId itemId) >>= \case - (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do - unless (groupFeatureAllowed SGFReactions g) $ - throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) - unless (ciReactionAllowed ci) $ - throwChatError (CECommandError "reaction not allowed - chat item has no content") - let GroupMember {memberId = itemMemberId} = chatItemMember g ci - rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True - checkReactionAllowed rs - (SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) - createdAt <- liftIO getCurrentTime - reactions <- withStore' $ \db -> do - setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt - liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId - let ci' = CChatItem md ci {reactions} - r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction - pure $ CRChatItemReaction user add r - _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + withGroupLock "chatItemReaction" chatId $ + withStore (\db -> (,) <$> getGroup db vr user chatId <*> getGroupChatItem db user chatId itemId) >>= \case + (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do + unless (groupFeatureAllowed SGFReactions g) $ + throwChatError (CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions)) + unless (ciReactionAllowed ci) $ + throwChatError (CECommandError "reaction not allowed - chat item has no content") + let GroupMember {memberId = itemMemberId} = chatItemMember g ci + rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True + checkReactionAllowed rs + (SndMessage {msgId}, _) <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) + createdAt <- liftIO getCurrentTime + reactions <- withStore' $ \db -> do + setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt + liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction + pure $ CRChatItemReaction user add r + _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTLocal -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" CTContactConnection -> pure $ chatCmdError (Just user) "not supported" @@ -909,6 +872,111 @@ processChatCommand' vr = \case throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed") when (add && length rs >= maxMsgReactions) $ throwChatError (CECommandError "too many reactions") + APIForwardChatItem (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemId -> withUser $ \user -> case toCType of + CTDirect -> do + (cm, ciff) <- prepareForward user + withContactLock "forwardChatItem, to contact" toChatId $ + sendContactContentMessage user toChatId False Nothing cm ciff + CTGroup -> do + (cm, ciff) <- prepareForward user + withGroupLock "forwardChatItem, to group" toChatId $ + sendGroupContentMessage user toChatId False Nothing cm ciff + CTLocal -> do + (cm, ciff) <- prepareForward user + createNoteFolderContentItem user toChatId cm ciff + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + where + prepareForward :: User -> CM (ComposedMessage, Maybe CIForwardedFrom) + prepareForward user = case fromCType of + CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do + (ct, CChatItem _ ci) <- withStore $ \db -> do + ct <- getContact db vr user fromChatId + cci <- getDirectChatItem db user fromChatId itemId + pure (ct, cci) + (mc, mDir) <- forwardMC ci + file <- forwardCryptoFile ci + let ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) mDir (Just fromChatId) (Just itemId)) + pure (ComposedMessage file Nothing mc, ciff) + where + forwardName :: Contact -> ContactName + forwardName Contact {profile = LocalProfile {displayName, localAlias}} + | localAlias /= "" = localAlias + | otherwise = displayName + CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do + (gInfo, CChatItem _ ci) <- withStore $ \db -> do + gInfo <- getGroupInfo db vr user fromChatId + cci <- getGroupChatItem db user fromChatId itemId + pure (gInfo, cci) + (mc, mDir) <- forwardMC ci + file <- forwardCryptoFile ci + let ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) mDir (Just fromChatId) (Just itemId)) + pure (ComposedMessage file Nothing mc, ciff) + where + forwardName :: GroupInfo -> ContactName + forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName + CTLocal -> do + (CChatItem _ ci) <- withStore $ \db -> getLocalChatItem db user fromChatId itemId + (mc, _) <- forwardMC ci + file <- forwardCryptoFile ci + let ciff = forwardCIFF ci Nothing + pure (ComposedMessage file Nothing mc, ciff) + CTContactRequest -> throwChatError $ CECommandError "not supported" + CTContactConnection -> throwChatError $ CECommandError "not supported" + where + forwardMC :: ChatItem c d -> CM (MsgContent, MsgDirection) + forwardMC ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidForward + forwardMC ChatItem {content = CISndMsgContent fmc} = pure (fmc, MDSnd) + forwardMC ChatItem {content = CIRcvMsgContent fmc} = pure (fmc, MDRcv) + forwardMC _ = throwChatError CEInvalidForward + forwardCIFF :: ChatItem c d -> Maybe CIForwardedFrom -> Maybe CIForwardedFrom + forwardCIFF ChatItem {meta = CIMeta {itemForwarded}} ciff = case itemForwarded of + Nothing -> ciff + Just CIFFUnknown -> ciff + Just prevCIFF -> Just prevCIFF + forwardCryptoFile :: ChatItem c d -> CM (Maybe CryptoFile) + forwardCryptoFile ChatItem {file = Nothing} = pure Nothing + forwardCryptoFile ChatItem {file = Just ciFile} = case ciFile of + CIFile {fileName, fileStatus, fileSource = Just fromCF@CryptoFile {filePath}} + | ciFileLoaded fileStatus -> + chatReadVar filesFolder >>= \case + Nothing -> + ifM (doesFileExist filePath) (pure $ Just fromCF) (throwChatError CEForwardNoFile) + Just filesFolder -> do + let fsFromPath = filesFolder filePath + ifM + (doesFileExist fsFromPath) + ( do + fsNewPath <- liftIO $ filesFolder `uniqueCombine` fileName + liftIO $ B.writeFile fsNewPath "" -- create empty file + encrypt <- chatReadVar encryptLocalFiles + cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing + let toCF = CryptoFile fsNewPath cfArgs + -- to keep forwarded file in case original is deleted + liftIOEither $ runExceptT $ withExceptT (ChatError . CEInternalError . show) $ copyCryptoFile (fromCF {filePath = fsFromPath} :: CryptoFile) toCF + pure $ Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile) + ) + (throwChatError CEForwardNoFile) + _ -> throwChatError CEForwardNoFile + copyCryptoFile :: CryptoFile -> CryptoFile -> ExceptT CF.FTCryptoError IO () + copyCryptoFile fromCF@CryptoFile {filePath = fsFromPath, cryptoArgs = fromArgs} toCF@CryptoFile {cryptoArgs = toArgs} = do + fromSizeFull <- getFileSize fsFromPath + let fromSize = fromSizeFull - maybe 0 (const $ toInteger C.authTagSize) fromArgs + CF.withFile fromCF ReadMode $ \fromH -> + CF.withFile toCF WriteMode $ \toH -> do + copyChunks fromH toH fromSize + forM_ fromArgs $ \_ -> CF.hGetTag fromH + forM_ toArgs $ \_ -> liftIO $ CF.hPutTag toH + where + copyChunks :: CF.CryptoFileHandle -> CF.CryptoFileHandle -> Integer -> ExceptT CF.FTCryptoError IO () + copyChunks r w size = do + let chSize = min size U.chunkSize + chSize' = fromIntegral chSize + size' = size - chSize + ch <- liftIO $ CF.hGet r chSize' + when (B.length ch /= chSize') $ throwError $ CF.FTCEFileIOError "encrypting file: unexpected EOF" + liftIO . CF.hPut w $ LB.fromStrict ch + when (size' > 0) $ copyChunks r w size' APIUserRead userId -> withUserId userId $ \user -> withStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of @@ -959,7 +1027,7 @@ processChatCommand' vr = \case CTDirect -> do ct <- withStore $ \db -> getContact db vr user chatId filesInfo <- withStore' $ \db -> getContactFileInfo db user ct - withChatLock "deleteChat direct" . procCmd $ do + withContactLock "deleteChat direct" chatId . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo let doSendDel = contactReady ct && contactActive ct && notify @@ -971,7 +1039,7 @@ processChatCommand' vr = \case withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore $ \db -> deleteContact db user ct pure $ CRContactDeleted user ct - CTContactConnection -> withChatLock "deleteChat contactConnection" . procCmd $ do + CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId . procCmd $ do conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withStore $ \db -> getPendingContactConnection db userId chatId deleteAgentConnectionAsync user acId withStore' $ \db -> deletePendingContactConnection db userId chatId @@ -983,7 +1051,7 @@ processChatCommand' vr = \case canDelete = isOwner || not (memberCurrent membership) unless canDelete $ throwChatError $ CEGroupUserRole gInfo GROwner filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - withChatLock "deleteChat group" . procCmd $ do + withGroupLock "deleteChat group" chatId . procCmd $ do cancelFilesInProgress user filesInfo deleteFilesLocally filesInfo let doSendDel = memberActive membership && isOwner @@ -1038,28 +1106,29 @@ processChatCommand' vr = \case CTLocal -> do nf <- withStore $ \db -> getNoteFolder db user chatId filesInfo <- withStore' $ \db -> getNoteFolderFileInfo db user nf - withChatLock "clearChat local" . procCmd $ do - deleteFilesLocally filesInfo - withStore' $ \db -> deleteNoteFolderFiles db userId nf - withStore' $ \db -> deleteNoteFolderCIs db user nf - pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) + deleteFilesLocally filesInfo + withStore' $ \db -> deleteNoteFolderFiles db userId nf + withStore' $ \db -> deleteNoteFolderCIs db user nf + pure $ CRChatCleared user (AChatInfo SCTLocal $ LocalChat nf) CTContactConnection -> pure $ chatCmdError (Just user) "not supported" CTContactRequest -> pure $ chatCmdError (Just user) "not supported" - APIAcceptContact incognito connReqId -> withUser $ \_ -> withChatLock "acceptContact" $ do + APIAcceptContact incognito connReqId -> withUser $ \_ -> do (user@User {userId}, cReq@UserContactRequest {userContactLinkId}) <- withStore $ \db -> getContactRequest' db connReqId - ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId - let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl - -- [incognito] generate profile to send, create connection with incognito profile - incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing - ct <- acceptContactRequest user cReq incognitoProfile contactUsed - pure $ CRAcceptingContactRequest user ct - APIRejectContact connReqId -> withUser $ \user -> withChatLock "rejectContact" $ do - cReq@UserContactRequest {agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- + withUserContactLock "acceptContact" userContactLinkId $ do + ucl <- withStore $ \db -> getUserContactLinkById db userId userContactLinkId + let contactUsed = (\(_, groupId_, _) -> isNothing groupId_) ucl + -- [incognito] generate profile to send, create connection with incognito profile + incognitoProfile <- if incognito then Just . NewIncognito <$> liftIO generateRandomProfile else pure Nothing + ct <- acceptContactRequest user cReq incognitoProfile contactUsed + pure $ CRAcceptingContactRequest user ct + APIRejectContact connReqId -> withUser $ \user -> do + cReq@UserContactRequest {userContactLinkId, agentContactConnId = AgentConnId connId, agentInvitationId = AgentInvId invId} <- withStore $ \db -> getContactRequest db user connReqId `storeFinally` liftIO (deleteContactRequest db user connReqId) - withAgent $ \a -> rejectContact a connId invId - pure $ CRContactRequestRejected user cReq + withUserContactLock "rejectContact" userContactLinkId $ do + withAgent $ \a -> rejectContact a connId invId + pure $ CRContactRequestRejected user cReq APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withStore $ \db -> getContact db vr user contactId @@ -1067,7 +1136,7 @@ processChatCommand' vr = \case if featureAllowed SCFCalls forUser ct then do calls <- asks currentCalls - withChatLock "sendCallInvitation" $ do + withContactLock "sendCallInvitation" contactId $ do g <- asks random callId <- atomically $ CallId <$> C.randomBytes 16 g dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing @@ -1192,12 +1261,11 @@ processChatCommand' vr = \case toServerCfg server = ServerCfg {server, preset = True, tested = Nothing, enabled = True} GetUserProtoServers aProtocol -> withUser $ \User {userId} -> processChatCommand $ APIGetUserProtoServers userId aProtocol - APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ - withChatLock "setUserSMPServers" $ do - withStore $ \db -> overwriteProtocolServers db user servers - cfg <- asks config - lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ activeAgentServers cfg p servers - ok user + APISetUserProtoServers userId (APSC p (ProtoServersConfig servers)) -> withUserId userId $ \user -> withServerProtocol p $ do + withStore $ \db -> overwriteProtocolServers db user servers + cfg <- asks config + lift $ withAgent' $ \a -> setProtocolServers a (aUserId user) $ activeAgentServers cfg p servers + ok user SetUserProtoServers serversConfig -> withUser $ \User {userId} -> processChatCommand $ APISetUserProtoServers userId serversConfig APITestProtoServer userId srv@(AProtoServerWithAuth _ server) -> withUserId userId $ \user -> @@ -1230,6 +1298,7 @@ processChatCommand' vr = \case APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_ APIGetNetworkConfig -> withUser' $ \_ -> lift $ CRNetworkConfig <$> withAgent' getNetworkConfig + APISetNetworkInfo info -> lift (withAgent' (`setUserNetworkInfo` info)) >> ok_ ReconnectAllServers -> withUser' $ \_ -> lift (withAgent' reconnectAllServers) >> ok_ APISetChatSettings (ChatRef cType chatId) chatSettings -> withUser $ \user -> case cType of CTDirect -> do @@ -1300,7 +1369,7 @@ processChatCommand' vr = \case connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId pure $ CRGroupMemberSwitchAborted user g m connectionStats _ -> throwChatError CEGroupMemberNotActive - APISyncContactRatchet contactId force -> withUser $ \user -> withChatLock "syncContactRatchet" $ do + APISyncContactRatchet contactId force -> withUser $ \user -> withContactLock "syncContactRatchet" contactId $ do ct <- withStore $ \db -> getContact db vr user contactId case contactConn ct of Just conn@Connection {pqSupport} -> do @@ -1308,7 +1377,7 @@ processChatCommand' vr = \case createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing pure $ CRContactRatchetSyncStarted user ct cStats Nothing -> throwChatError $ CEContactNotActive ct - APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withChatLock "syncGroupMemberRatchet" $ do + APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> withGroupLock "syncGroupMemberRatchet" gId $ do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId case memberConnId m of Just connId -> do @@ -1397,7 +1466,7 @@ processChatCommand' vr = \case EnableGroupMember gName mName -> withMemberName gName mName $ \gId mId -> APIEnableGroupMember gId mId ChatHelp section -> pure $ CRChatHelp section Welcome -> withUser $ pure . CRWelcome - APIAddContact userId incognito -> withUserId userId $ \user -> withChatLock "addContact" . procCmd $ do + APIAddContact userId incognito -> withUserId userId $ \user -> procCmd $ do -- [incognito] generate profile for connection incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing subMode <- chatReadVar subscriptionMode @@ -1424,9 +1493,8 @@ processChatCommand' vr = \case Just conn' -> pure $ CRConnectionIncognitoUpdated user conn' Nothing -> throwChatError CEConnectionIncognitoChangeProhibited APIConnectPlan userId cReqUri -> withUserId userId $ \user -> - withChatLock "connectPlan" . procCmd $ - CRConnectionPlan user <$> connectPlan user cReqUri - APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withChatLock "connect" . procCmd $ do + CRConnectionPlan user <$> connectPlan user cReqUri + APIConnect userId incognito (Just (ACR SCMInvitation cReq)) -> withUserId userId $ \user -> withInvitationLock "connect" (strEncode cReq) . procCmd $ do subMode <- chatReadVar subscriptionMode -- [incognito] generate profile to send incognitoProfile <- if incognito then Just <$> liftIO generateRandomProfile else pure Nothing @@ -1471,7 +1539,7 @@ processChatCommand' vr = \case CRContactsList user <$> withStore' (\db -> getUserContacts db vr user) ListContacts -> withUser $ \User {userId} -> processChatCommand $ APIListContacts userId - APICreateMyAddress userId -> withUserId userId $ \user -> withChatLock "createMyAddress" . procCmd $ do + APICreateMyAddress userId -> withUserId userId $ \user -> procCmd $ do subMode <- chatReadVar subscriptionMode -- TODO v5.7 pass IPPQOn (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact Nothing IKPQOff subMode @@ -1516,6 +1584,21 @@ processChatCommand' vr = \case RejectContact cName -> withUser $ \User {userId} -> do connReqId <- withStore $ \db -> getContactRequestIdByName db userId cName processChatCommand $ APIRejectContact connReqId + ForwardMessage toChatName fromContactName forwardedMsg -> withUser $ \user -> do + contactId <- withStore $ \db -> getContactIdByName db user fromContactName + forwardedItemId <- withStore $ \db -> getDirectChatItemIdByText' db user contactId forwardedMsg + toChatRef <- getChatRef user toChatName + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTDirect contactId) forwardedItemId + ForwardGroupMessage toChatName fromGroupName fromMemberName_ forwardedMsg -> withUser $ \user -> do + groupId <- withStore $ \db -> getGroupIdByName db user fromGroupName + forwardedItemId <- withStore $ \db -> getGroupChatItemIdByText db user groupId fromMemberName_ forwardedMsg + toChatRef <- getChatRef user toChatName + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTGroup groupId) forwardedItemId + ForwardLocalMessage toChatName forwardedMsg -> withUser $ \user -> do + folderId <- withStore (`getUserNoteFolderId` user) + forwardedItemId <- withStore $ \db -> getLocalChatItemIdByText' db user folderId forwardedMsg + toChatRef <- getChatRef user toChatName + processChatCommand $ APIForwardChatItem toChatRef (ChatRef CTLocal folderId) forwardedItemId SendMessage (ChatName cType name) msg -> withUser $ \user -> do let mc = MCText msg case cType of @@ -1550,8 +1633,9 @@ processChatCommand' vr = \case let mc = MCText msg case memberContactId m of Nothing -> do - gInfo <- withStore $ \db -> getGroupInfo db vr user gId - toView $ CRNoMemberContactCreating user gInfo m + g <- withStore $ \db -> getGroupInfo db vr user gId + unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwChatError $ CECommandError "direct messages not allowed" + toView $ CRNoMemberContactCreating user g m processChatCommand (APICreateMemberContact gId mId) >>= \case cr@(CRNewMemberContact _ Contact {contactId} _ _) -> do toView cr @@ -1599,7 +1683,7 @@ processChatCommand' vr = \case combineResults _ _ (Left e) = Left e createCI :: DB.Connection -> User -> UTCTime -> (Contact, SndMessage) -> IO () createCI db user createdAt (ct, sndMsg) = - void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing False createdAt + void $ createNewSndChatItem db user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing Nothing False createdAt SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \user@User {userId} -> do contactId <- withStore $ \db -> getContactIdByName db user cName quotedItemId <- withStore $ \db -> getDirectChatItemIdByText db userId contactId msgDir quotedMsg @@ -1636,7 +1720,7 @@ processChatCommand' vr = \case pure $ CRGroupCreated user groupInfo NewGroup incognito gProfile -> withUser $ \User {userId} -> processChatCommand $ APINewGroup userId incognito gProfile - APIAddMember groupId contactId memRole -> withUser $ \user -> withChatLock "addMember" $ do + APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do -- TODO for large groups: no need to load all members to determine if contact is a member (group, contact) <- withStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId assertDirectAllowed user MDSnd contact XGrpInv_ @@ -1666,7 +1750,7 @@ processChatCommand' vr = \case Nothing -> throwChatError $ CEGroupCantResendInvitation gInfo cName | otherwise -> throwChatError $ CEGroupDuplicateMember cName APIJoinGroup groupId -> withUser $ \user@User {userId} -> do - withChatLock "joinGroup" . procCmd $ do + withGroupLock "joinGroup" groupId . procCmd $ do (invitation, ct) <- withStore $ \db -> do inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db vr user groupId (inv,) <$> getContactViaMember db vr user fromMember @@ -1697,7 +1781,7 @@ processChatCommand' vr = \case changeMemberRole user gInfo members m gEvent = do let GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberContactId, localDisplayName = cName} = m assertUserGroupRole gInfo $ maximum [GRAdmin, mRole, memRole] - withChatLock "memberRole" . procCmd $ do + withGroupLock "memberRole" groupId . procCmd $ do unless (mRole == memRole) $ do withStore' $ \db -> updateGroupMemberRole db user m memRole case mStatus of @@ -1719,7 +1803,7 @@ processChatCommand' vr = \case let GroupMember {memberId = bmMemberId, memberRole = bmRole, memberProfile = bmp} = bm assertUserGroupRole gInfo $ max GRAdmin bmRole when (blocked == blockedByAdmin bm) $ throwChatError $ CECommandError $ if blocked then "already blocked" else "already unblocked" - withChatLock "blockForAll" . procCmd $ do + withGroupLock "blockForAll" groupId . procCmd $ do let mrs = if blocked then MRSBlocked else MRSUnrestricted event = XGrpMemRestrict bmMemberId MemberRestrictions {restriction = mrs} (msg, _) <- sendGroupMessage' user gInfo remainingMembers event @@ -1741,7 +1825,7 @@ processChatCommand' vr = \case Nothing -> throwChatError CEGroupMemberNotFound Just m@GroupMember {memberId = mId, memberRole = mRole, memberStatus = mStatus, memberProfile} -> do assertUserGroupRole gInfo $ max GRAdmin mRole - withChatLock "removeMember" . procCmd $ do + withGroupLock "removeMember" groupId . procCmd $ do case mStatus of GSMemInvited -> do deleteMemberConnection user m @@ -1757,7 +1841,7 @@ processChatCommand' vr = \case APILeaveGroup groupId -> withUser $ \user@User {userId} -> do Group gInfo@GroupInfo {membership} members <- withStore $ \db -> getGroup db vr user groupId filesInfo <- withStore' $ \db -> getGroupFileInfo db user gInfo - withChatLock "leaveGroup" . procCmd $ do + withGroupLock "leaveGroup" groupId . procCmd $ do cancelFilesInProgress user filesInfo (msg, _) <- sendGroupMessage' user gInfo members XGrpLeave ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndGroupEvent SGEUserLeft) @@ -1807,7 +1891,7 @@ processChatCommand' vr = \case updateGroupProfileByName gName $ \p -> p {description} ShowGroupDescription gName -> withUser $ \user -> CRGroupDescription user <$> withStore (\db -> getGroupInfoByName db vr user gName) - APICreateGroupLink groupId mRole -> withUser $ \user -> withChatLock "createGroupLink" $ do + APICreateGroupLink groupId mRole -> withUser $ \user -> withGroupLock "createGroupLink" groupId $ do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId assertUserGroupRole gInfo GRAdmin when (mRole > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole @@ -1817,14 +1901,14 @@ processChatCommand' vr = \case (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMContact (Just crClientData) IKPQOff subMode withStore $ \db -> createGroupLink db user gInfo connId cReq groupLinkId mRole subMode pure $ CRGroupLinkCreated user gInfo cReq mRole - APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withChatLock "groupLinkMemberRole " $ do + APIGroupLinkMemberRole groupId mRole' -> withUser $ \user -> withGroupLock "groupLinkMemberRole" groupId $ do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId (groupLinkId, groupLink, mRole) <- withStore $ \db -> getGroupLink db user gInfo assertUserGroupRole gInfo GRAdmin when (mRole' > GRMember) $ throwChatError $ CEGroupMemberInitialRole gInfo mRole' when (mRole' /= mRole) $ withStore' $ \db -> setGroupLinkMemberRole db user groupLinkId mRole' pure $ CRGroupLink user gInfo groupLink mRole' - APIDeleteGroupLink groupId -> withUser $ \user -> withChatLock "deleteGroupLink" $ do + APIDeleteGroupLink groupId -> withUser $ \user -> withGroupLock "deleteGroupLink" groupId $ do gInfo <- withStore $ \db -> getGroupInfo db vr user groupId deleteGroupLink' user gInfo pure $ CRGroupLinkDeleted user gInfo @@ -1835,7 +1919,7 @@ processChatCommand' vr = \case APICreateMemberContact gId gMemberId -> withUser $ \user -> do (g, m) <- withStore $ \db -> (,) <$> getGroupInfo db vr user gId <*> getGroupMember db vr user gId gMemberId assertUserGroupRole g GRAuthor - unless (groupFeatureAllowed SGFDirectMessages g) $ throwChatError $ CECommandError "direct messages not allowed" + unless (groupFeatureMemberAllowed SGFDirectMessages (membership g) g) $ throwChatError $ CECommandError "direct messages not allowed" case memberConn m of Just mConn@Connection {peerChatVRange} -> do unless (maxVersion peerChatVRange >= groupDirectInvVersion) $ throwChatError CEPeerChatVRangeIncompatible @@ -1932,19 +2016,19 @@ processChatCommand' vr = \case ForwardImage chatName fileId -> forwardFile chatName fileId SendImage SendFileDescription _chatName _f -> pure $ chatCmdError Nothing "TODO" ReceiveFile fileId encrypted_ rcvInline_ filePath_ -> withUser $ \_ -> - withChatLock "receiveFile" . procCmd $ do + withFileLock "receiveFile" fileId . procCmd $ do (user, ft) <- withStore (`getRcvFileTransferById` fileId) encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles ft' <- (if encrypt then setFileToEncrypt else pure) ft receiveFile' user ft' rcvInline_ filePath_ SetFileToReceive fileId encrypted_ -> withUser $ \_ -> do - withChatLock "setFileToReceive" . procCmd $ do + withFileLock "setFileToReceive" fileId . procCmd $ do encrypt <- (`fromMaybe` encrypted_) <$> chatReadVar encryptLocalFiles cfArgs <- if encrypt then Just <$> (atomically . CF.randomArgs =<< asks random) else pure Nothing withStore' $ \db -> setRcvFileToReceive db fileId cfArgs ok_ CancelFile fileId -> withUser $ \user@User {userId} -> - withChatLock "cancelFile" . procCmd $ + withFileLock "cancelFile" fileId . procCmd $ withStore (\db -> getFileTransfer db user fileId) >>= \case FTSnd ftm@FileTransferMeta {xftpSndFile, cancelled} fts | cancelled -> throwChatError $ CEFileCancel fileId "file already cancelled" @@ -2016,9 +2100,12 @@ processChatCommand' vr = \case ct@Contact {userPreferences} <- withStore $ \db -> getContactByName db vr user cName let prefs' = setPreference f allowed_ $ Just userPreferences updateContactPrefs user ct prefs' - SetGroupFeature (AGF f) gName enabled -> + SetGroupFeature (AGFNR f) gName enabled -> updateGroupProfileByName gName $ \p -> p {groupPreferences = Just . setGroupPreference f enabled $ groupPreferences p} + SetGroupFeatureRole (AGFR f) gName enabled role -> + updateGroupProfileByName gName $ \p -> + p {groupPreferences = Just . setGroupPreferenceRole f enabled role $ groupPreferences p} SetUserTimedMessages onOff -> withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing @@ -2074,8 +2161,19 @@ processChatCommand' vr = \case pure $ CRVersionInfo {versionInfo, chatMigrations, agentMigrations} DebugLocks -> lift $ do chatLockName <- atomically . tryReadTMVar =<< asks chatLock + chatEntityLocks <- getLocks =<< asks entityLocks agentLocks <- withAgent' debugAgentLocks - pure CRDebugLocks {chatLockName, agentLocks} + pure CRDebugLocks {chatLockName, chatEntityLocks, agentLocks} + where + getLocks ls = atomically $ M.mapKeys enityLockString . M.mapMaybe id <$> (mapM tryReadTMVar =<< readTVar ls) + enityLockString cle = case cle of + CLInvitation bs -> "Invitation " <> B.unpack bs + CLConnection connId -> "Connection " <> show connId + CLContact ctId -> "Contact " <> show ctId + CLGroup gId -> "Group " <> show gId + CLUserContact ucId -> "UserContact " <> show ucId + CLFile fId -> "File " <> show fId + DebugEvent event -> toView event >> ok_ GetAgentWorkers -> lift $ CRAgentWorkersSummary <$> withAgent' getAgentWorkersSummary GetAgentWorkersDetails -> lift $ CRAgentWorkersDetails <$> withAgent' getAgentWorkersDetails GetAgentStats -> lift $ CRAgentStats . map stat <$> withAgent' getAgentStats @@ -2101,7 +2199,6 @@ processChatCommand' vr = \case -- in a modified CLI app or core - the hook should return Either ChatResponse ChatCommand CustomChatCommand _cmd -> withUser $ \user -> pure $ chatCmdError (Just user) "not supported" where - withChatLock name action = asks chatLock >>= \l -> withLock l name action -- below code would make command responses asynchronous where they can be slow -- in View.hs `r'` should be defined as `id` in this case -- procCmd :: m ChatResponse -> m ChatResponse @@ -2167,7 +2264,7 @@ processChatCommand' vr = \case CTLocal -> withStore $ \db -> getLocalChatItemIdByText' db user cId msg _ -> throwChatError $ CECommandError "not supported" connectViaContact :: User -> IncognitoEnabled -> ConnectionRequestUri 'CMContact -> CM ChatResponse - connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withChatLock "connectViaContact" $ do + connectViaContact user@User {userId} incognito cReq@(CRContactUri ConnReqUriData {crClientData}) = withInvitationLock "connectViaContact" (strEncode cReq) $ do let groupLinkId = crClientData >>= decodeJSON >>= \(CRDataGroup gli) -> Just gli cReqHash = ConnReqUriHash . C.sha256Hash $ strEncode cReq case groupLinkId of @@ -2198,7 +2295,7 @@ processChatCommand' vr = \case pure $ CRSentInvitation user conn incognitoProfile connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> ConnectionRequestUri 'CMContact -> CM ChatResponse connectContactViaAddress user incognito ct cReq = - withChatLock "connectViaContact" $ do + withInvitationLock "connectContactViaAddress" (strEncode cReq) $ do newXContactId <- XContactId <$> drgRandomBytes 16 pqSup <- chatReadVar pqExperimentalEnabled (connId, incognitoProfile, subMode, chatV) <- requestContact user incognito cReq newXContactId False pqSup @@ -2265,8 +2362,9 @@ processChatCommand' vr = \case -- [incognito] filter out contacts with whom user has incognito connections addChangedProfileContact :: User -> Contact -> [ChangedProfileContact] -> [ChangedProfileContact] addChangedProfileContact user' ct changedCts = case contactSendConn_ ct' of - Right conn | not (connIncognito conn) && mergedProfile' /= mergedProfile -> - ChangedProfileContact ct ct' mergedProfile' conn : changedCts + Right conn + | not (connIncognito conn) && mergedProfile' /= mergedProfile -> + ChangedProfileContact ct ct' mergedProfile' conn : changedCts _ -> changedCts where mergedProfile = userProfileToSend user Nothing (Just ct) False @@ -2289,7 +2387,7 @@ processChatCommand' vr = \case let mergedProfile = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct) False mergedProfile' = userProfileToSend user (fromLocalProfile <$> incognitoProfile) (Just ct') False when (mergedProfile' /= mergedProfile) $ - withChatLock "updateProfile" $ do + withContactLock "updateProfile" (contactId' ct) $ do void (sendDirectContactMessage user ct' $ XInfo mergedProfile') `catchChatError` (toView . CRChatError (Just user)) lift . when (directOrUsed ct') $ createSndFeatureItems user ct ct' pure $ CRContactPrefsUpdated user ct ct' @@ -2334,7 +2432,7 @@ processChatCommand' vr = \case user <- getUserByContactId db ctId (user,) <$> getContact db vr user ctId calls <- asks currentCalls - withChatLock "currentCall" $ + withContactLock "currentCall" ctId $ atomically (TM.lookup ctId calls) >>= \case Nothing -> throwChatError CENoCurrentCall Just call@Call {contactId} @@ -2535,6 +2633,104 @@ processChatCommand' vr = \case let aciContent = ACIContent SMDRcv $ CIRcvGroupInvitation ciGroupInv {status = newStatus} memRole updateDirectChatItemView user ct itemId aciContent False Nothing _ -> pure () -- prohibited + sendContactContentMessage :: User -> ContactId -> Bool -> Maybe Int -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse + sendContactContentMessage user contactId live itemTTL (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do + ct@Contact {contactUsed} <- withStore $ \db -> getContact db vr user contactId + assertDirectAllowed user MDSnd ct XMsgNew_ + unless contactUsed $ withStore' $ \db -> updateContactUsed db user ct + if isVoice mc && not (featureAllowed SCFVoice forUser ct) + then pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (chatFeatureNameText CFVoice)) + else do + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer ct + timed_ <- sndContactCITimed live ct itemTTL + (msgContainer, quotedItem_) <- prepareMsg fInv_ timed_ + (msg, _) <- sendDirectContactMessage user ct (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ itemForwarded timed_ live + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTDirect contactId, chatItemId' ci) + pure $ CRNewChatItem user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci) + where + setupSndFileTransfer :: Contact -> CM (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer ct = forM file_ $ \file -> do + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize 1 $ CGContact ct + prepareMsg :: Maybe FileInvitation -> Maybe CITimed -> CM (MsgContainer, Maybe (CIQuote 'CTDirect)) + prepareMsg fInv_ timed_ = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Just quotedItemId, Nothing) -> do + CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- + withStore $ \db -> getDirectChatItem db user contactId quotedItemId + (origQmc, qd, sent) <- quoteData qci + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} + qmc = quoteContent mc origQmc file + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) + (Just _, Just _) -> throwChatError CEInvalidQuote + where + quoteData :: ChatItem c d -> CM (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidQuote + quoteData ChatItem {content = CISndMsgContent qmc} = pure (qmc, CIQDirectSnd, True) + quoteData ChatItem {content = CIRcvMsgContent qmc} = pure (qmc, CIQDirectRcv, False) + quoteData _ = throwChatError CEInvalidQuote + sendGroupContentMessage :: User -> GroupId -> Bool -> Maybe Int -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse + sendGroupContentMessage user groupId live itemTTL (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do + g@(Group gInfo _) <- withStore $ \db -> getGroup db vr user groupId + assertUserGroupRole gInfo GRAuthor + send g + where + send g@(Group gInfo@GroupInfo {membership} ms) = + case prohibitedGroupContent gInfo membership mc file_ of + Just f -> notAllowedError f + Nothing -> do + (fInv_, ciFile_) <- L.unzip <$> setupSndFileTransfer g (length $ filter memberCurrent ms) + timed_ <- sndGroupCITimed live gInfo itemTTL + (msgContainer, quotedItem_) <- prepareGroupMsg user gInfo mc quotedItemId_ itemForwarded fInv_ timed_ live + (msg, sentToMembers) <- sendGroupMessage user gInfo ms (XMsgNew msgContainer) + ci <- saveSndChatItem' user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ itemForwarded timed_ live + withStore' $ \db -> + forM_ sentToMembers $ \GroupMember {groupMemberId} -> + createGroupSndStatus db (chatItemId' ci) groupMemberId CISSndNew + forM_ (timed_ >>= timedDeleteAt') $ + startProximateTimedItemThread user (ChatRef CTGroup groupId, chatItemId' ci) + pure $ CRNewChatItem user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci) + notAllowedError f = pure $ chatCmdError (Just user) ("feature not allowed " <> T.unpack (groupFeatureNameText f)) + setupSndFileTransfer :: Group -> Int -> CM (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer g n = forM file_ $ \file -> do + fileSize <- checkSndFile file + xftpSndFileTransfer user file fileSize n $ CGGroup g + xftpSndFileTransfer :: User -> CryptoFile -> Integer -> Int -> ContactOrGroup -> CM (FileInvitation, CIFile 'MDSnd) + xftpSndFileTransfer user file fileSize n contactOrGroup = do + (fInv, ciFile, ft) <- xftpSndFileTransfer_ user file fileSize n $ Just contactOrGroup + case contactOrGroup of + CGContact Contact {activeConn} -> forM_ activeConn $ \conn -> + withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft dummyFileDescr + CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user)) + where + -- we are not sending files to pending members, same as with inline files + saveMemberFD m@GroupMember {activeConn = Just conn@Connection {connStatus}} = + when ((connStatus == ConnReady || connStatus == ConnSndReady) && not (connDisabled conn)) $ + withStore' $ + \db -> createSndFTDescrXFTP db user (Just m) conn ft dummyFileDescr + saveMemberFD _ = pure () + pure (fInv, ciFile) + createNoteFolderContentItem :: User -> NoteFolderId -> ComposedMessage -> Maybe CIForwardedFrom -> CM ChatResponse + createNoteFolderContentItem user folderId (ComposedMessage file_ quotedItemId_ mc) itemForwarded = do + forM_ quotedItemId_ $ \_ -> throwError $ ChatError $ CECommandError "not supported" + nf <- withStore $ \db -> getNoteFolder db user folderId + createdAt <- liftIO getCurrentTime + let content = CISndMsgContent mc + let cd = CDLocalSnd nf + ciId <- createLocalChatItem user cd content itemForwarded createdAt + ciFile_ <- forM file_ $ \cf@CryptoFile {filePath, cryptoArgs} -> do + fsFilePath <- lift $ toFSFilePath filePath + fileSize <- liftIO $ CF.getFileContentsSize $ CryptoFile fsFilePath cryptoArgs + chunkSize <- asks $ fileChunkSize . config + withStore' $ \db -> do + fileId <- createLocalFile CIFSSndStored db user nf ciId createdAt cf fileSize chunkSize + pure CIFile {fileId, fileName = takeFileName filePath, fileSize, fileSource = Just cf, fileStatus = CIFSSndStored, fileProtocol = FPLocal} + let ci = mkChatItem cd ciId content ciFile_ Nothing Nothing itemForwarded Nothing False createdAt Nothing createdAt + pure . CRNewChatItem user $ AChatItem SCTLocal SMDSnd (LocalChat nf) ci toggleNtf :: User -> GroupMember -> Bool -> CM () toggleNtf user m ntfOn = @@ -2549,10 +2745,11 @@ data ChangedProfileContact = ChangedProfileContact conn :: Connection } -prepareGroupMsg :: User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe FileInvitation -> Maybe CITimed -> Bool -> CM (MsgContainer, Maybe (CIQuote 'CTGroup)) -prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ fInv_ timed_ live = case quotedItemId_ of - Nothing -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) - Just quotedItemId -> do +prepareGroupMsg :: User -> GroupInfo -> MsgContent -> Maybe ChatItemId -> Maybe CIForwardedFrom -> Maybe FileInvitation -> Maybe CITimed -> Bool -> CM (MsgContainer, Maybe (CIQuote 'CTGroup)) +prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ itemForwarded fInv_ timed_ live = case (quotedItemId_, itemForwarded) of + (Nothing, Nothing) -> pure (MCSimple (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Nothing, Just _) -> pure (MCForward (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Nothing) + (Just quotedItemId, Nothing) -> do CChatItem _ qci@ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, formattedText, file} <- withStore $ \db -> getGroupChatItem db user groupId quotedItemId (origQmc, qd, sent, GroupMember {memberId}) <- quoteData qci membership @@ -2560,6 +2757,7 @@ prepareGroupMsg user GroupInfo {groupId, membership} mc quotedItemId_ fInv_ time qmc = quoteContent mc origQmc file quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fInv_ (ttl' <$> timed_) (justTrue live)), Just quotedItem) + (Just _, Just _) -> throwChatError CEInvalidQuote where quoteData :: ChatItem c d -> GroupMember -> CM (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) quoteData ChatItem {meta = CIMeta {itemDeleted = Just _}} _ = throwChatError CEInvalidQuote @@ -2598,7 +2796,7 @@ assertDirectAllowed user dir ct event = unless (allowedChatEvent || anyDirectOrUsed ct) . unlessM directMessagesAllowed $ throwChatError (CEDirectMessagesProhibited dir ct) where - directMessagesAllowed = any (groupFeatureAllowed' SGFDirectMessages) <$> withStore' (\db -> getContactGroupPreferences db user ct) + directMessagesAllowed = any (uncurry $ groupFeatureMemberAllowed' SGFDirectMessages) <$> withStore' (\db -> getContactGroupPreferences db user ct) allowedChatEvent = case event of XMsgNew_ -> False XMsgUpdate_ -> False @@ -2608,6 +2806,13 @@ assertDirectAllowed user dir ct event = XCallInv_ -> False _ -> True +prohibitedGroupContent :: GroupInfo -> GroupMember -> MsgContent -> Maybe f -> Maybe GroupFeature +prohibitedGroupContent gInfo m mc file_ + | isVoice mc && not (groupFeatureMemberAllowed SGFVoice m gInfo) = Just GFVoice + | not (isVoice mc) && isJust file_ && not (groupFeatureMemberAllowed SGFFiles m gInfo) = Just GFFiles + | not (groupFeatureMemberAllowed SGFSimplexLinks m gInfo) && containsFormat isSimplexLink (parseMarkdown $ msgContentText mc) = Just GFSimplexLinks + | otherwise = Nothing + roundedFDCount :: Int -> Int roundedFDCount n | n <= 0 = 4 @@ -2988,21 +3193,16 @@ deleteGroupLink_ user gInfo conn = do agentSubscriber :: CM' () agentSubscriber = do q <- asks $ subQ . smpAgent - l <- asks chatLock - forever $ atomically (readTBQueue q) >>= process l + forever $ atomically (readTBQueue q) >>= process where - process :: Lock -> (ACorrId, EntityId, APartyCmd 'Agent) -> CM' () - process l (corrId, entId, APC e msg) = run $ case e of + process :: (ACorrId, EntityId, APartyCmd 'Agent) -> CM' () + process (corrId, entId, APC e msg) = run $ case e of SAENone -> processAgentMessageNoConn msg SAEConn -> processAgentMessage corrId entId msg SAERcvFile -> processAgentMsgRcvFile corrId entId msg SAESndFile -> processAgentMsgSndFile corrId entId msg where - run action = do - let name = "agentSubscriber entity=" <> show e <> " entId=" <> str entId <> " msg=" <> str (aCommandTag msg) - withLock' l name $ action `catchChatError'` (toView' . CRChatError Nothing) - str :: StrEncoding a => a -> String - str = B.unpack . strEncode + run action = action `catchChatError'` (toView' . CRChatError Nothing) type AgentBatchSubscribe = AgentClient -> [ConnId] -> ExceptT AgentErrorType IO (Map ConnId (Either AgentErrorType ())) @@ -3150,8 +3350,7 @@ subscribeUserConnections vr onlyNeeded agentBatchSubscribe user = do forM_ err_ $ toView . CRSndFileSubError user ft void . forkIO $ do threadDelay 1000000 - l <- asks chatLock - when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withLock l "subscribe sendFileChunk" $ + when (fileStatus == FSConnected) . unlessM (isFileActive fileId sndFiles) . withChatLock "subscribe sendFileChunk" $ sendFileChunk user ft rcvFileSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId RcvFileTransfer -> CM () rcvFileSubsToView rs = mapM_ (toView . uncurry (CRRcvFileSubError user)) . filterErrors . resultsFor rs @@ -3317,11 +3516,13 @@ processAgentMessage _ connId (DEL_RCVQ srv qId err_) = processAgentMessage _ connId DEL_CONN = toView $ CRAgentConnDeleted (AgentConnId connId) processAgentMessage corrId connId msg = do - vr <- chatVersionRange - -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here - critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case - Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) - _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) + lockEntity <- critical (withStore (`getChatLockEntity` AgentConnId connId)) + withEntityLock "processAgentMessage" lockEntity $ do + vr <- chatVersionRange + -- getUserByAConnId never throws logical errors, only SEDBBusyError can be thrown here + critical (withStore' (`getUserByAConnId` AgentConnId connId)) >>= \case + Just user -> processAgentMessageConn vr user corrId connId msg `catchChatError` (toView . CRChatError (Just user)) + _ -> throwChatError $ CENoConnectionUser (AgentConnId connId) -- CRITICAL error will be shown to the user as alert with restart button in Android/desktop apps. -- SEDBBusyError will only be thrown on IO exceptions or SQLError during DB queries, @@ -3358,18 +3559,18 @@ processAgentMessageNoConn = \case toView $ event srv cs processAgentMsgSndFile :: ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> CM () -processAgentMsgSndFile _corrId aFileId msg = - withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case - Just user -> process user `catchChatError` (toView . CRChatError (Just user)) - _ -> do - lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) - throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId +processAgentMsgSndFile _corrId aFileId msg = do + fileId <- withStore (`getXFTPSndFileDBId` AgentSndFileId aFileId) + withFileLock "processAgentMsgSndFile" fileId $ + withStore' (`getUserByASndFileId` AgentSndFileId aFileId) >>= \case + Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + _ -> do + lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) + throwChatError $ CENoSndFileUser $ AgentSndFileId aFileId where - process :: User -> CM () - process user = do - (ft@FileTransferMeta {fileId, xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> do - fileId <- getXFTPSndFileDBId db user $ AgentSndFileId aFileId - getSndFileTransfer db user fileId + process :: User -> FileTransferId -> CM () + process user fileId = do + (ft@FileTransferMeta {xftpRedirectFor, cancelled}, sfts) <- withStore $ \db -> getSndFileTransfer db user fileId vr <- chatVersionRange unless cancelled $ case msg of SFPROG sndProgress sndTotal -> do @@ -3386,11 +3587,11 @@ processAgentMsgSndFile _corrId aFileId msg = lift $ withAgent' (`xftpDeleteSndFileInternal` aFileId) withStore' $ \db -> createExtraSndFTDescrs db user fileId (map fileDescrText rfds) case rfds of - [] -> sendFileError "no receiver descriptions" fileId vr ft + [] -> sendFileError "no receiver descriptions" vr ft rfd : _ -> case [fd | fd@(FD.ValidFileDescription FD.FileDescription {chunks = [_]}) <- rfds] of [] -> case xftpRedirectFor of Nothing -> xftpSndFileRedirect user fileId rfd >>= toView . CRSndFileRedirectStartXFTP user ft - Just _ -> sendFileError "Prohibit chaining redirects" fileId vr ft + Just _ -> sendFileError "Prohibit chaining redirects" vr ft rfds' -> do -- we have 1 chunk - use it as URI whether it is redirect or not ft' <- maybe (pure ft) (\fId -> withStore $ \db -> getFileTransferMeta db user fId) xftpRedirectFor @@ -3439,7 +3640,7 @@ processAgentMsgSndFile _corrId aFileId msg = | temporaryAgentError e -> throwChatError $ CEXFTPSndFile fileId (AgentSndFileId aFileId) e | otherwise -> - sendFileError (tshow e) fileId vr ft + sendFileError (tshow e) vr ft where fileDescrText :: FilePartyI p => ValidFileDescription p -> T.Text fileDescrText = safeDecodeUtf8 . strEncode @@ -3457,8 +3658,8 @@ processAgentMsgSndFile _corrId aFileId msg = case L.nonEmpty fds of Just fds' -> loopSend fds' Nothing -> pure msgDeliveryId - sendFileError :: Text -> Int64 -> (PQSupport -> VersionRangeChat) -> FileTransferMeta -> CM () - sendFileError err fileId vr ft = do + sendFileError :: Text -> (PQSupport -> VersionRangeChat) -> FileTransferMeta -> CM () + sendFileError err vr ft = do logError $ "Sent file error: " <> err ci <- withStore $ \db -> do liftIO $ updateFileCancelled db user fileId CIFSSndError @@ -3480,18 +3681,18 @@ splitFileDescr rfdText = do else fileDescr <| splitParts (partNo + 1) partSize rest processAgentMsgRcvFile :: ACorrId -> RcvFileId -> ACommand 'Agent 'AERcvFile -> CM () -processAgentMsgRcvFile _corrId aFileId msg = - withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case - Just user -> process user `catchChatError` (toView . CRChatError (Just user)) - _ -> do - lift $ withAgent' (`xftpDeleteRcvFile` aFileId) - throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId +processAgentMsgRcvFile _corrId aFileId msg = do + fileId <- withStore (`getXFTPRcvFileDBId` AgentRcvFileId aFileId) + withFileLock "processAgentMsgRcvFile" fileId $ + withStore' (`getUserByARcvFileId` AgentRcvFileId aFileId) >>= \case + Just user -> process user fileId `catchChatError` (toView . CRChatError (Just user)) + _ -> do + lift $ withAgent' (`xftpDeleteRcvFile` aFileId) + throwChatError $ CENoRcvFileUser $ AgentRcvFileId aFileId where - process :: User -> CM () - process user = do - ft@RcvFileTransfer {fileId} <- withStore $ \db -> do - fileId <- getXFTPRcvFileDBId db $ AgentRcvFileId aFileId - getRcvFileTransfer db user fileId + process :: User -> FileTransferId -> CM () + process user fileId = do + ft <- withStore $ \db -> getRcvFileTransfer db user fileId vr <- chatVersionRange unless (rcvFileCompleteOrCancelled ft) $ case msg of RFPROG rcvProgress rcvTotal -> do @@ -3597,7 +3798,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- probably this branch is never executed, so there should be no reason -- to save message if contact hasn't been created yet - chat item isn't created anyway withAckMessage' agentConnId meta $ - void $ saveDirectRcvMSG conn meta msgBody + void $ + saveDirectRcvMSG conn meta msgBody SENT msgId -> sentMsgDeliveryEvent conn msgId OK -> @@ -3634,7 +3836,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = (conn'', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveDirectRcvMSG conn' msgMeta msgBody let ct'' = ct' {activeConn = Just conn''} :: Contact assertDirectAllowed user MDRcv ct'' $ toCMEventTag event - updateChatLock "direct message" event case event of XMsgNew mc -> newContentMessage ct'' mc msg msgMeta XMsgFileDescr sharedMsgId fileDescr -> messageFileDescription ct'' sharedMsgId fileDescr @@ -3994,7 +4195,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let CIMeta {itemTs, itemSharedMsgId, itemTimed} = meta quotedItemId_ = quoteItemId =<< quotedItem fInv_ = fst <$> fInvDescr_ - (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ fInv_ itemTimed False + (msgContainer, _) <- prepareGroupMsg user gInfo mc quotedItemId_ Nothing fInv_ itemTimed False let senderVRange = memberChatVRange' sender xMsgNewChatMsg = ChatMessage {chatVRange = senderVRange, msgId = itemSharedMsgId, chatMsgEvent = XMsgNew msgContainer} fileDescrEvents <- case (snd <$> fInvDescr_, itemSharedMsgId) of @@ -4053,7 +4254,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = processEvent :: MsgEncodingI e => ChatMessage e -> CM () processEvent chatMsg = do (m', conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveGroupRcvMsg user groupId m conn msgMeta msgBody chatMsg - updateChatLock "groupMessage" event case event of XMsgNew mc -> memberCanSend m' $ newGroupContentMessage gInfo m' mc msg brokerTs False XMsgFileDescr sharedMsgId fileDescr -> memberCanSend m' $ groupMessageFileDescription gInfo m' sharedMsgId fileDescr @@ -4389,13 +4589,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CRConnectionDisabled connEntity _ -> pure () - updateChatLock :: MsgEncodingI enc => String -> ChatMsgEvent enc -> CM () - updateChatLock name event = do - l <- asks chatLock - atomically $ tryReadTMVar l >>= mapM_ (swapTMVar l . (<> s)) - where - s = " " <> name <> "=" <> B.unpack (strEncode $ toCMEventTag event) - -- TODO v5.7 / v6.0 - together with deprecating old group protocol establishing direct connections? -- we could save command records only for agent APIs we process continuations for (INV) withCompletedCommand :: forall e. AEntityI e => Connection -> ACommand 'Agent e -> (CommandData -> CM ()) -> CM () @@ -4433,9 +4626,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- This prevents losing the message that failed to be processed. Left (ChatErrorStore SEDBBusyError {message}) | showCritical -> throwError $ ChatErrorAgent (CRITICAL True message) Nothing Left e -> ackMsg msgMeta Nothing >> throwError e - where - ackMsg :: MsgMeta -> Maybe MsgReceiptInfo -> CM () - ackMsg MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a "" cId msgId rcpt + where + ackMsg :: MsgMeta -> Maybe MsgReceiptInfo -> CM () + ackMsg MsgMeta {recipient = (msgId, _)} rcpt = withAgent $ \a -> ackMessageAsync a "" cId msgId rcpt sentMsgDeliveryEvent :: Connection -> AgentMsgId -> CM () sentMsgDeliveryEvent Connection {connId} msgId = @@ -4622,18 +4815,19 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = updateRcvChatItem = do cci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId case cci of - CChatItem SMDRcv ci@ChatItem {meta = CIMeta {itemLive}, content = CIRcvMsgContent oldMC} -> do - let changed = mc /= oldMC - if changed || fromMaybe False itemLive - then do - ci' <- withStore' $ \db -> do - when changed $ - addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) - reactions <- getDirectCIReactions db ct sharedMsgId - updateDirectChatItem' db user contactId ci {reactions} content live $ Just msgId - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' - else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + CChatItem SMDRcv ci@ChatItem {meta = CIMeta {itemForwarded, itemLive}, content = CIRcvMsgContent oldMC} + | isNothing itemForwarded -> do + let changed = mc /= oldMC + if changed || fromMaybe False itemLive + then do + ci' <- withStore' $ \db -> do + when changed $ + addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc) + reactions <- getDirectCIReactions db ct sharedMsgId + updateDirectChatItem' db user contactId ci {reactions} content live $ Just msgId + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci' + else toView $ CRChatItemNotChanged user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) _ -> messageError "x.msg.update: contact attempted invalid message update" messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> CM () @@ -4704,14 +4898,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> UTCTime -> Bool -> CM () newGroupContentMessage gInfo m@GroupMember {memberId, memberRole} mc msg@RcvMessage {sharedMsgId_} brokerTs forwarded | blockedByAdmin m = createBlockedByAdmin - | isVoice content && not (groupFeatureAllowed SGFVoice gInfo) = rejected GFVoice - | not (isVoice content) && isJust fInv_ && not (groupFeatureAllowed SGFFiles gInfo) = rejected GFFiles - | otherwise = - withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case - Just ciModeration -> do - applyModeration ciModeration - withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ - Nothing -> createContentItem + | otherwise = case prohibitedGroupContent gInfo m content fInv_ of + Just f -> rejected f + Nothing -> + withStore' (\db -> getCIModeration db vr user gInfo memberId sharedMsgId_) >>= \case + Just ciModeration -> do + applyModeration ciModeration + withStore' $ \db -> deleteCIModeration db gInfo memberId sharedMsgId_ + Nothing -> createContentItem where rejected f = void $ newChatItem (CIRcvGroupFeatureRejected f) Nothing Nothing False timed' = if forwarded then rcvCITimed_ (Just Nothing) itemTTL else rcvGroupCITimed gInfo itemTTL @@ -5154,8 +5348,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createGroupFeatureItems g@GroupInfo {fullGroupPreferences} m = forM_ allGroupFeatures $ \(AGF f) -> do let p = getGroupPreference f fullGroupPreferences - (_, param) = groupFeatureState p - createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param) Nothing + (_, param, role) = groupFeatureState p + createInternalChatItem user (CDGroupRcv g m) (CIRcvGroupFeature (toGroupFeature f) (toGroupPreference p) param role) Nothing xInfoProbe :: ContactOrMember -> Probe -> CM () xInfoProbe cgm2 probe = do @@ -5666,7 +5860,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xGrpDirectInv :: GroupInfo -> GroupMember -> Connection -> ConnReqInvitation -> Maybe MsgContent -> RcvMessage -> UTCTime -> CM () xGrpDirectInv g m mConn connReq mContent_ msg brokerTs = do - unless (groupFeatureAllowed SGFDirectMessages g) $ messageError "x.grp.direct.inv: direct messages not allowed" + unless (groupFeatureMemberAllowed SGFDirectMessages m g) $ messageError "x.grp.direct.inv: direct messages not allowed" let GroupMember {memberContactId} = m subMode <- chatReadVar subscriptionMode case memberContactId of @@ -6394,17 +6588,17 @@ saveGroupFwdRcvMsg user groupId forwardingMember refAuthorMember@GroupMember {me _ -> throwError e saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) -saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing False +saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False -saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) -saveSndChatItem' user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem itemTimed live = do +saveSndChatItem' :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDSnd) +saveSndChatItem' user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem itemForwarded itemTimed live = do createdAt <- liftIO getCurrentTime ciId <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt - ciId <- createNewSndChatItem db user cd msg content quotedItem itemTimed live createdAt + ciId <- createNewSndChatItem db user cd msg content quotedItem itemForwarded itemTimed live createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt pure ciId - pure $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemTimed live createdAt Nothing createdAt + pure $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) itemForwarded itemTimed live createdAt Nothing createdAt saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = @@ -6413,18 +6607,18 @@ saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> CM (ChatItem c 'MDRcv) saveRcvChatItem' user cd msg@RcvMessage {forwardedByMember} sharedMsgId_ brokerTs content ciFile itemTimed live = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem) <- withStore' $ \db -> do + (ciId, quotedItem, itemForwarded) <- withStore' $ \db -> do when (ciRequiresAttention content) $ updateChatTs db user cd createdAt - (ciId, quotedItem) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt + r@(ciId, _, _) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt - pure (ciId, quotedItem) - pure $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemTimed live brokerTs forwardedByMember createdAt + pure r + pure $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live brokerTs forwardedByMember createdAt -mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d -mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs forwardedByMember currentTs = +mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d +mkChatItem cd ciId content file quotedItem sharedMsgId itemForwarded itemTimed live itemTs forwardedByMember currentTs = let itemText = ciContentToText content itemStatus = ciCreateStatus content - meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs + meta = mkCIMeta ciId content itemText itemStatus sharedMsgId itemForwarded Nothing False itemTimed (justTrue live) currentTs itemTs forwardedByMember currentTs currentTs in ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: MsgDirectionI d => User -> Contact -> ChatItem 'CTDirect d -> Bool -> Bool -> CM ChatResponse @@ -6646,14 +6840,14 @@ createContactsFeatureItems user cts chatDir ciFeature ciOffer getPref = do cup = getContactUserPreference f cups cup' = getContactUserPreference f cups' -createGroupFeatureChangedItems :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> (GroupFeature -> GroupPreference -> Maybe Int -> CIContent d) -> GroupInfo -> GroupInfo -> CM () +createGroupFeatureChangedItems :: MsgDirectionI d => User -> ChatDirection 'CTGroup d -> (GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent d) -> GroupInfo -> GroupInfo -> CM () createGroupFeatureChangedItems user cd ciContent GroupInfo {fullGroupPreferences = gps} GroupInfo {fullGroupPreferences = gps'} = forM_ allGroupFeatures $ \(AGF f) -> do let state = groupFeatureState $ getGroupPreference f gps pref' = getGroupPreference f gps' - state'@(_, int') = groupFeatureState pref' + state'@(_, param', role') = groupFeatureState pref' when (state /= state') $ - createInternalChatItem user cd (ciContent (toGroupFeature f) (toGroupPreference pref') int') Nothing + createInternalChatItem user cd (ciContent (toGroupFeature f) (toGroupPreference pref') param' role') Nothing sameGroupProfileInfo :: GroupProfile -> GroupProfile -> Bool sameGroupProfileInfo p p' = p {groupPreferences = Nothing} == p' {groupPreferences = Nothing} @@ -6685,17 +6879,17 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt - let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing False itemTs Nothing createdAt + let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci -createLocalChatItem :: MsgDirectionI d => User -> ChatDirection 'CTLocal d -> CIContent d -> UTCTime -> CM ChatItemId -createLocalChatItem user cd content createdAt = do +createLocalChatItem :: MsgDirectionI d => User -> ChatDirection 'CTLocal d -> CIContent d -> Maybe CIForwardedFrom -> UTCTime -> CM ChatItemId +createLocalChatItem user cd content itemForwarded createdAt = do gVar <- asks random withStore $ \db -> do liftIO $ updateChatTs db user cd createdAt createWithRandomId gVar $ \sharedMsgId -> let smi_ = Just (SharedMsgId sharedMsgId) - in createNewChatItem_ db user cd Nothing smi_ content (Nothing, Nothing, Nothing, Nothing, Nothing) Nothing False createdAt Nothing createdAt + in createNewChatItem_ db user cd Nothing smi_ content (Nothing, Nothing, Nothing, Nothing, Nothing) itemForwarded Nothing False createdAt Nothing createdAt withUser' :: (User -> CM ChatResponse) -> CM ChatResponse withUser' action = @@ -6822,6 +7016,7 @@ chatCommandP = "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), + "/_forward " *> (APIForwardChatItem <$> chatRefP <* A.space <*> chatRefP <* A.space <*> A.decimal), "/_read user " *> (APIUserRead <$> A.decimal), "/read user" $> UserRead, "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), @@ -6873,6 +7068,7 @@ chatCommandP = "/ttl " *> (SetChatItemTTL <$> ciTTL), "/_ttl " *> (APIGetChatItemTTL <$> A.decimal), "/ttl" $> GetChatItemTTL, + "/_network info " *> (APISetNetworkInfo <$> jsonP), "/_network " *> (APISetNetworkConfig <$> jsonP), ("/network " <|> "/net ") *> (APISetNetworkConfig <$> netCfgP), ("/network" <|> "/net") $> APIGetNetworkConfig, @@ -6963,6 +7159,10 @@ chatCommandP = "/_set incognito :" *> (APISetConnectionIncognito <$> A.decimal <* A.space <*> onOffP), ("/connect" <|> "/c") *> (Connect <$> incognitoP <* A.space <*> ((Just <$> strP) <|> A.takeTill isSpace $> Nothing)), ("/connect" <|> "/c") *> (AddContact <$> incognitoP), + ForwardMessage <$> chatNameP <* " <- @" <*> displayName <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <* A.space <* A.char '@' <*> (Just <$> displayName) <* A.space <*> msgTextP, + ForwardGroupMessage <$> chatNameP <* " <- #" <*> displayName <*> pure Nothing <* A.space <*> msgTextP, + ForwardLocalMessage <$> chatNameP <* " <- * " <*> msgTextP, SendMessage <$> chatNameP <* A.space <*> msgTextP, "/* " *> (SendMessage (ChatName CTLocal "") <$> msgTextP), "@#" *> (SendMemberContactMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> msgTextP), @@ -7011,20 +7211,22 @@ chatCommandP = "/show profile image" $> ShowProfileImage, ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> profileNames), ("/profile" <|> "/p") $> ShowProfile, - "/set voice #" *> (SetGroupFeature (AGF SGFVoice) <$> displayName <*> (A.space *> strP)), + "/set voice #" *> (SetGroupFeatureRole (AGFR SGFVoice) <$> displayName <*> _strP <*> optional memberRole), "/set voice @" *> (SetContactFeature (ACF SCFVoice) <$> displayName <*> optional (A.space *> strP)), "/set voice " *> (SetUserFeature (ACF SCFVoice) <$> strP), - "/set files #" *> (SetGroupFeature (AGF SGFFiles) <$> displayName <*> (A.space *> strP)), - "/set history #" *> (SetGroupFeature (AGF SGFHistory) <$> displayName <*> (A.space *> strP)), + "/set files #" *> (SetGroupFeatureRole (AGFR SGFFiles) <$> displayName <*> _strP <*> optional memberRole), + "/set history #" *> (SetGroupFeature (AGFNR SGFHistory) <$> displayName <*> (A.space *> strP)), + "/set reactions #" *> (SetGroupFeature (AGFNR SGFReactions) <$> displayName <*> (A.space *> strP)), "/set calls @" *> (SetContactFeature (ACF SCFCalls) <$> displayName <*> optional (A.space *> strP)), "/set calls " *> (SetUserFeature (ACF SCFCalls) <$> strP), - "/set delete #" *> (SetGroupFeature (AGF SGFFullDelete) <$> displayName <*> (A.space *> strP)), + "/set delete #" *> (SetGroupFeature (AGFNR SGFFullDelete) <$> displayName <*> (A.space *> strP)), "/set delete @" *> (SetContactFeature (ACF SCFFullDelete) <$> displayName <*> optional (A.space *> strP)), "/set delete " *> (SetUserFeature (ACF SCFFullDelete) <$> strP), - "/set direct #" *> (SetGroupFeature (AGF SGFDirectMessages) <$> displayName <*> (A.space *> strP)), + "/set direct #" *> (SetGroupFeatureRole (AGFR SGFDirectMessages) <$> displayName <*> _strP <*> optional memberRole), "/set disappear #" *> (SetGroupTimedMessages <$> displayName <*> (A.space *> timedTTLOnOffP)), "/set disappear @" *> (SetContactTimedMessages <$> displayName <*> optional (A.space *> timedMessagesEnabledP)), "/set disappear " *> (SetUserTimedMessages <$> (("yes" $> True) <|> ("no" $> False))), + "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayName <*> _strP <*> optional memberRole), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -7047,6 +7249,7 @@ chatCommandP = ("/quit" <|> "/q" <|> "/exit") $> QuitChat, ("/version" <|> "/v") $> ShowVersion, "/debug locks" $> DebugLocks, + "/debug event " *> (DebugEvent <$> jsonP), "/get stats" $> GetAgentStats, "/reset stats" $> ResetAgentStats, "/get subs" $> GetAgentSubs, @@ -7112,7 +7315,7 @@ chatCommandP = let groupPreferences = Just (emptyGroupPrefs :: GroupPreferences) - { directMessages = Just DirectMessagesGroupPreference {enable = FEOn}, + { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } pure GroupProfile {displayName = gName, fullName, description = Nothing, image = Nothing, groupPreferences} diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0291793843..00c2e153f1 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -59,13 +59,14 @@ import Simplex.Chat.Messages.CIContent import Simplex.Chat.Protocol import Simplex.Chat.Remote.AppVersion import Simplex.Chat.Remote.Types -import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings) +import Simplex.Chat.Store (AutoAccept, ChatLockEntity, StoreError (..), UserContactLink, UserMsgReceiptSettings) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo) -import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure) +import Simplex.Messaging.Agent.Client (AgentLocks, AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, UserNetworkInfo) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Protocol @@ -165,7 +166,7 @@ defaultChatHooks = ChatHooks { preCmdHook = \_ -> pure . Right, eventHook = \_ -> pure - } + } data DefaultAgentServers = DefaultAgentServers { smp :: NonEmpty SMPServerWithAuth, @@ -208,6 +209,7 @@ data ChatController = ChatController connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, + entityLocks :: TMap ChatLockEntity Lock, sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), currentCalls :: TMap ContactId Call, @@ -290,6 +292,7 @@ data ChatCommand | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} + | APIForwardChatItem {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemId :: ChatItemId} | APIUserRead UserId | UserRead | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) @@ -344,6 +347,7 @@ data ChatCommand | GetChatItemTTL | APISetNetworkConfig NetworkConfig | APIGetNetworkConfig + | APISetNetworkInfo UserNetworkInfo | ReconnectAllServers | APISetChatSettings ChatRef ChatSettings | APISetMemberSettings GroupId GroupMemberId GroupMemberSettings @@ -406,6 +410,9 @@ data ChatCommand | AddressAutoAccept (Maybe AutoAccept) | AcceptContact IncognitoEnabled ContactName | RejectContact ContactName + | ForwardMessage {toChatName :: ChatName, fromContactName :: ContactName, forwardedMsg :: Text} + | ForwardGroupMessage {toChatName :: ChatName, fromGroupName :: GroupName, fromMemberName_ :: Maybe ContactName, forwardedMsg :: Text} + | ForwardLocalMessage {toChatName :: ChatName, forwardedMsg :: Text} | SendMessage ChatName Text | SendMemberContactMessage GroupName ContactName Text | SendLiveMessage ChatName Text @@ -460,7 +467,8 @@ data ChatCommand | ShowProfileImage | SetUserFeature AChatFeature FeatureAllowed -- UserId (not used in UI) | SetContactFeature AChatFeature ContactName (Maybe FeatureAllowed) - | SetGroupFeature AGroupFeature GroupName GroupFeatureEnabled + | SetGroupFeature AGroupFeatureNoRole GroupName GroupFeatureEnabled + | SetGroupFeatureRole AGroupFeatureRole GroupName GroupFeatureEnabled (Maybe GroupMemberRole) | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) @@ -485,15 +493,16 @@ data ChatCommand | QuitChat | ShowVersion | DebugLocks + | DebugEvent ChatResponse | GetAgentStats | ResetAgentStats | GetAgentSubs | GetAgentSubsDetails | GetAgentWorkers | GetAgentWorkersDetails - -- The parser will return this command for strings that start from "//". - -- This command should be processed in preCmdHook - | CustomChatCommand ByteString + | -- The parser will return this command for strings that start from "//". + -- This command should be processed in preCmdHook + CustomChatCommand ByteString deriving (Show) allowRemoteCommand :: ChatCommand -> Bool -- XXX: consider using Relay/Block/ForceLocal @@ -731,7 +740,7 @@ data ChatResponse | CRContactPQEnabled {user :: User, contact :: Contact, pqEnabled :: PQEncryption} | CRSQLResult {rows :: [Text]} | CRSlowSQLQueries {chatQueries :: [SlowSQLQuery], agentQueries :: [SlowSQLQuery]} - | CRDebugLocks {chatLockName :: Maybe String, agentLocks :: AgentLocks} + | CRDebugLocks {chatLockName :: Maybe String, chatEntityLocks :: Map String String, agentLocks :: AgentLocks} | CRAgentStats {agentStats :: [[String]]} | CRAgentWorkersDetails {agentWorkersDetails :: AgentWorkersDetails} | CRAgentWorkersSummary {agentWorkersSummary :: AgentWorkersSummary} @@ -1111,6 +1120,8 @@ data ChatErrorType | CEFallbackToSMPProhibited {fileId :: FileTransferId} | CEInlineFileProhibited {fileId :: FileTransferId} | CEInvalidQuote + | CEInvalidForward + | CEForwardNoFile | CEInvalidChatItemUpdate | CEInvalidChatItemDelete | CEHasCurrentCall @@ -1353,7 +1364,7 @@ handleDBErrors = [ E.Handler $ \(e :: SQLError) -> let se = SQL.sqlError e busy = se == SQL.ErrorBusy || se == SQL.ErrorLocked - in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, + in pure . Left . ChatErrorStore $ if busy then SEDBBusyError $ show se else SEDBException $ show e, E.Handler $ \(E.SomeException e) -> pure . Left . ChatErrorStore . SEDBException $ show e ] diff --git a/src/Simplex/Chat/Markdown.hs b/src/Simplex/Chat/Markdown.hs index 2eabb48166..d3b9ea52f1 100644 --- a/src/Simplex/Chat/Markdown.hs +++ b/src/Simplex/Chat/Markdown.hs @@ -144,6 +144,15 @@ markdownToList (m1 :|: m2) = markdownToList m1 <> markdownToList m2 parseMarkdown :: Text -> Markdown parseMarkdown s = fromRight (unmarked s) $ A.parseOnly (markdownP <* A.endOfInput) s +containsFormat :: (Format -> Bool) -> Markdown -> Bool +containsFormat p (Markdown f _) = maybe False p f +containsFormat p (m1 :|: m2) = containsFormat p m1 || containsFormat p m2 + +isSimplexLink :: Format -> Bool +isSimplexLink = \case + SimplexLink {} -> True; + _ -> False + markdownP :: Parser Markdown markdownP = mconcat <$> A.many' fragmentP where diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b29543cf74..a6d5761b5f 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -339,10 +339,12 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta itemText :: Text, itemStatus :: CIStatus d, itemSharedMsgId :: Maybe SharedMsgId, + itemForwarded :: Maybe CIForwardedFrom, itemDeleted :: Maybe (CIDeleted c), itemEdited :: Bool, itemTimed :: Maybe CITimed, itemLive :: Maybe Bool, + deletable :: Bool, editable :: Bool, forwardedByMember :: Maybe GroupMemberId, createdAt :: UTCTime, @@ -350,15 +352,16 @@ data CIMeta (c :: ChatType) (d :: MsgDirection) = CIMeta } deriving (Show) -mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d -mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = - let editable = case itemContent of +mkCIMeta :: forall c d. ChatTypeI c => ChatItemId -> CIContent d -> Text -> CIStatus d -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe (CIDeleted c) -> Bool -> Maybe CITimed -> Maybe Bool -> UTCTime -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> UTCTime -> CIMeta c d +mkCIMeta itemId itemContent itemText itemStatus itemSharedMsgId itemForwarded itemDeleted itemEdited itemTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt = + let deletable = case itemContent of CISndMsgContent _ -> case chatTypeI @c of SCTLocal -> isNothing itemDeleted _ -> diffUTCTime currentTs itemTs < nominalDay && isNothing itemDeleted _ -> False - in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemDeleted, itemEdited, itemTimed, itemLive, editable, forwardedByMember, createdAt, updatedAt} + editable = deletable && isNothing itemForwarded + in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, itemForwarded, itemDeleted, itemEdited, itemTimed, itemLive, deletable, editable, forwardedByMember, createdAt, updatedAt} dummyMeta :: ChatItemId -> UTCTime -> Text -> CIMeta c 'MDSnd dummyMeta itemId ts itemText = @@ -368,10 +371,12 @@ dummyMeta itemId ts itemText = itemText, itemStatus = CISSndNew, itemSharedMsgId = Nothing, + itemForwarded = Nothing, itemDeleted = Nothing, itemEdited = False, itemTimed = Nothing, itemLive = Nothing, + deletable = False, editable = False, forwardedByMember = Nothing, createdAt = ts, @@ -548,6 +553,21 @@ ciFileEnded = \case CIFSRcvError -> True CIFSInvalid {} -> True +ciFileLoaded :: CIFileStatus d -> Bool +ciFileLoaded = \case + CIFSSndStored -> True + CIFSSndTransfer {} -> True + CIFSSndComplete -> True + CIFSSndCancelled -> True + CIFSSndError -> True + CIFSRcvInvitation -> False + CIFSRcvAccepted -> False + CIFSRcvTransfer {} -> False + CIFSRcvCancelled -> False + CIFSRcvComplete -> True + CIFSRcvError -> False + CIFSInvalid {} -> False + data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) deriving instance Show ACIFileStatus @@ -981,11 +1001,43 @@ itemDeletedTs = \case CIBlockedByAdmin ts -> ts CIModerated ts _ -> ts +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} + deriving (Show) + +cmForwardedFrom :: AChatMsgEvent -> Maybe CIForwardedFrom +cmForwardedFrom = \case + ACME _ (XMsgNew (MCForward _)) -> Just CIFFUnknown + _ -> Nothing + +data CIForwardedFromTag + = CIFFUnknown_ + | CIFFContact_ + | CIFFGroup_ + +instance FromField CIForwardedFromTag where fromField = fromTextField_ textDecode + +instance ToField CIForwardedFromTag where toField = toField . textEncode + +instance TextEncoding CIForwardedFromTag where + textDecode = \case + "unknown" -> Just CIFFUnknown_ + "contact" -> Just CIFFContact_ + "group" -> Just CIFFGroup_ + _ -> Nothing + textEncode = \case + CIFFUnknown_ -> "unknown" + CIFFContact_ -> "contact" + CIFFGroup_ -> "group" + data ChatItemInfo = ChatItemInfo { itemVersions :: [ChatItemVersion], - memberDeliveryStatuses :: Maybe [MemberDeliveryStatus] + memberDeliveryStatuses :: Maybe [MemberDeliveryStatus], + forwardedFromChatItem :: Maybe AChatItem } - deriving (Eq, Show) + deriving (Show) data ChatItemVersion = ChatItemVersion { chatItemVersionId :: Int64, @@ -1043,6 +1095,8 @@ instance ChatTypeI c => ToJSON (CIDeleted c) where toJSON = J.toJSON . jsonCIDeleted toEncoding = J.toEncoding . jsonCIDeleted +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CIFF") ''CIForwardedFrom) + $(JQ.deriveJSON defaultJSON ''CITimed) $(JQ.deriveJSON (enumJSON $ dropPrefix "SSP") ''SndCIStatusProgress) @@ -1066,8 +1120,6 @@ $(JQ.deriveJSON defaultJSON ''MemberDeliveryStatus) $(JQ.deriveJSON defaultJSON ''ChatItemVersion) -$(JQ.deriveJSON defaultJSON ''ChatItemInfo) - instance (ChatTypeI c, MsgDirectionI d) => FromJSON (CIMeta c d) where parseJSON = $(JQ.mkParseJSON defaultJSON ''CIMeta) @@ -1157,6 +1209,8 @@ instance ChatTypeI c => ToJSON (CChatItem c) where toJSON (CChatItem _ ci) = J.toJSON ci toEncoding (CChatItem _ ci) = J.toEncoding ci +$(JQ.deriveJSON defaultJSON ''ChatItemInfo) + $(JQ.deriveJSON defaultJSON ''ChatStats) instance ChatTypeI c => ToJSON (Chat c) where diff --git a/src/Simplex/Chat/Messages/CIContent.hs b/src/Simplex/Chat/Messages/CIContent.hs index 0e95570b85..13aa7ace10 100644 --- a/src/Simplex/Chat/Messages/CIContent.hs +++ b/src/Simplex/Chat/Messages/CIContent.hs @@ -28,6 +28,7 @@ import Simplex.Chat.Messages.CIContent.Events import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (MsgErrorType (..), RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Crypto.Ratchet (PQEncryption, pattern PQEncOn, pattern PQEncOff) @@ -42,6 +43,8 @@ $(JQ.deriveJSON (enumJSON $ dropPrefix "MD") ''MsgDirection) instance FromField AMsgDirection where fromField = fromIntField_ $ fmap fromMsgDirection . msgDirectionIntP +instance FromField MsgDirection where fromField = fromIntField_ msgDirectionIntP + instance ToField MsgDirection where toField = toField . msgDirectionInt data SMsgDirection (d :: MsgDirection) where @@ -134,8 +137,8 @@ data CIContent (d :: MsgDirection) where CISndChatFeature :: ChatFeature -> PrefEnabled -> Maybe Int -> CIContent 'MDSnd CIRcvChatPreference :: ChatFeature -> FeatureAllowed -> Maybe Int -> CIContent 'MDRcv CISndChatPreference :: ChatFeature -> FeatureAllowed -> Maybe Int -> CIContent 'MDSnd - CIRcvGroupFeature :: GroupFeature -> GroupPreference -> Maybe Int -> CIContent 'MDRcv - CISndGroupFeature :: GroupFeature -> GroupPreference -> Maybe Int -> CIContent 'MDSnd + CIRcvGroupFeature :: GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent 'MDRcv + CISndGroupFeature :: GroupFeature -> GroupPreference -> Maybe Int -> Maybe GroupMemberRole -> CIContent 'MDSnd CIRcvChatFeatureRejected :: ChatFeature -> CIContent 'MDRcv CIRcvGroupFeatureRejected :: GroupFeature -> CIContent 'MDRcv CISndModerated :: CIContent 'MDSnd @@ -255,8 +258,8 @@ ciContentToText = \case CISndChatFeature feature enabled param -> featureStateText feature enabled param CIRcvChatPreference feature allowed param -> prefStateText feature allowed param CISndChatPreference feature allowed param -> "you " <> prefStateText feature allowed param - CIRcvGroupFeature feature pref param -> groupPrefStateText feature pref param - CISndGroupFeature feature pref param -> groupPrefStateText feature pref param + CIRcvGroupFeature feature pref param role -> groupPrefStateText feature pref param role + CISndGroupFeature feature pref param role -> groupPrefStateText feature pref param role CIRcvChatFeatureRejected feature -> chatFeatureNameText feature <> ": received, prohibited" CIRcvGroupFeatureRejected feature -> groupFeatureNameText feature <> ": received, prohibited" CISndModerated -> ciModeratedText @@ -413,8 +416,8 @@ data JSONCIContent | JCISndChatFeature {feature :: ChatFeature, enabled :: PrefEnabled, param :: Maybe Int} | JCIRcvChatPreference {feature :: ChatFeature, allowed :: FeatureAllowed, param :: Maybe Int} | JCISndChatPreference {feature :: ChatFeature, allowed :: FeatureAllowed, param :: Maybe Int} - | JCIRcvGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} - | JCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} + | JCIRcvGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int, memberRole_ :: Maybe GroupMemberRole} + | JCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int, memberRole_ :: Maybe GroupMemberRole} | JCIRcvChatFeatureRejected {feature :: ChatFeature} | JCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} | JCISndModerated @@ -447,8 +450,8 @@ jsonCIContent = \case CISndChatFeature feature enabled param -> JCISndChatFeature {feature, enabled, param} CIRcvChatPreference feature allowed param -> JCIRcvChatPreference {feature, allowed, param} CISndChatPreference feature allowed param -> JCISndChatPreference {feature, allowed, param} - CIRcvGroupFeature groupFeature preference param -> JCIRcvGroupFeature {groupFeature, preference, param} - CISndGroupFeature groupFeature preference param -> JCISndGroupFeature {groupFeature, preference, param} + CIRcvGroupFeature groupFeature preference param memberRole_ -> JCIRcvGroupFeature {groupFeature, preference, param, memberRole_} + CISndGroupFeature groupFeature preference param memberRole_ -> JCISndGroupFeature {groupFeature, preference, param, memberRole_} CIRcvChatFeatureRejected feature -> JCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> JCIRcvGroupFeatureRejected {groupFeature} CISndModerated -> JCISndModerated @@ -481,8 +484,8 @@ aciContentJSON = \case JCISndChatFeature {feature, enabled, param} -> ACIContent SMDSnd $ CISndChatFeature feature enabled param JCIRcvChatPreference {feature, allowed, param} -> ACIContent SMDRcv $ CIRcvChatPreference feature allowed param JCISndChatPreference {feature, allowed, param} -> ACIContent SMDSnd $ CISndChatPreference feature allowed param - JCIRcvGroupFeature {groupFeature, preference, param} -> ACIContent SMDRcv $ CIRcvGroupFeature groupFeature preference param - JCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param + JCIRcvGroupFeature {groupFeature, preference, param, memberRole_} -> ACIContent SMDRcv $ CIRcvGroupFeature groupFeature preference param memberRole_ + JCISndGroupFeature {groupFeature, preference, param, memberRole_} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param memberRole_ JCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature JCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature JCISndModerated -> ACIContent SMDSnd CISndModerated @@ -516,8 +519,8 @@ data DBJSONCIContent | DBJCISndChatFeature {feature :: ChatFeature, enabled :: PrefEnabled, param :: Maybe Int} | DBJCIRcvChatPreference {feature :: ChatFeature, allowed :: FeatureAllowed, param :: Maybe Int} | DBJCISndChatPreference {feature :: ChatFeature, allowed :: FeatureAllowed, param :: Maybe Int} - | DBJCIRcvGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} - | DBJCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int} + | DBJCIRcvGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int, memberRole_ :: Maybe GroupMemberRole} + | DBJCISndGroupFeature {groupFeature :: GroupFeature, preference :: GroupPreference, param :: Maybe Int, memberRole_ :: Maybe GroupMemberRole} | DBJCIRcvChatFeatureRejected {feature :: ChatFeature} | DBJCIRcvGroupFeatureRejected {groupFeature :: GroupFeature} | DBJCISndModerated @@ -550,8 +553,8 @@ dbJsonCIContent = \case CISndChatFeature feature enabled param -> DBJCISndChatFeature {feature, enabled, param} CIRcvChatPreference feature allowed param -> DBJCIRcvChatPreference {feature, allowed, param} CISndChatPreference feature allowed param -> DBJCISndChatPreference {feature, allowed, param} - CIRcvGroupFeature groupFeature preference param -> DBJCIRcvGroupFeature {groupFeature, preference, param} - CISndGroupFeature groupFeature preference param -> DBJCISndGroupFeature {groupFeature, preference, param} + CIRcvGroupFeature groupFeature preference param memberRole_ -> DBJCIRcvGroupFeature {groupFeature, preference, param, memberRole_} + CISndGroupFeature groupFeature preference param memberRole_ -> DBJCISndGroupFeature {groupFeature, preference, param, memberRole_} CIRcvChatFeatureRejected feature -> DBJCIRcvChatFeatureRejected {feature} CIRcvGroupFeatureRejected groupFeature -> DBJCIRcvGroupFeatureRejected {groupFeature} CISndModerated -> DBJCISndModerated @@ -584,8 +587,8 @@ aciContentDBJSON = \case DBJCISndChatFeature {feature, enabled, param} -> ACIContent SMDSnd $ CISndChatFeature feature enabled param DBJCIRcvChatPreference {feature, allowed, param} -> ACIContent SMDRcv $ CIRcvChatPreference feature allowed param DBJCISndChatPreference {feature, allowed, param} -> ACIContent SMDSnd $ CISndChatPreference feature allowed param - DBJCIRcvGroupFeature {groupFeature, preference, param} -> ACIContent SMDRcv $ CIRcvGroupFeature groupFeature preference param - DBJCISndGroupFeature {groupFeature, preference, param} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param + DBJCIRcvGroupFeature {groupFeature, preference, param, memberRole_} -> ACIContent SMDRcv $ CIRcvGroupFeature groupFeature preference param memberRole_ + DBJCISndGroupFeature {groupFeature, preference, param, memberRole_} -> ACIContent SMDSnd $ CISndGroupFeature groupFeature preference param memberRole_ DBJCIRcvChatFeatureRejected {feature} -> ACIContent SMDRcv $ CIRcvChatFeatureRejected feature DBJCIRcvGroupFeatureRejected {groupFeature} -> ACIContent SMDRcv $ CIRcvGroupFeatureRejected groupFeature DBJCISndModerated -> ACIContent SMDSnd CISndModerated diff --git a/src/Simplex/Chat/Messages/CIContent/Events.hs b/src/Simplex/Chat/Messages/CIContent/Events.hs index 7ce5f73cde..74f7d94399 100644 --- a/src/Simplex/Chat/Messages/CIContent/Events.hs +++ b/src/Simplex/Chat/Messages/CIContent/Events.hs @@ -7,6 +7,7 @@ module Simplex.Chat.Messages.CIContent.Events where import Data.Aeson (FromJSON (..), ToJSON (..)) import qualified Data.Aeson.TH as J import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (RatchetSyncState (..), SwitchPhase (..)) import Simplex.Messaging.Parsers (dropPrefix, singleFieldJSON, sumTypeJSON) import Simplex.Messaging.Crypto.Ratchet (PQEncryption) diff --git a/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs b/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs new file mode 100644 index 0000000000..850c8be2d9 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20240402_item_forwarded.hs @@ -0,0 +1,36 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20240402_item_forwarded where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20240402_item_forwarded :: Query +m20240402_item_forwarded = + [sql| +ALTER TABLE chat_items ADD COLUMN fwd_from_tag TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_chat_name TEXT; +ALTER TABLE chat_items ADD COLUMN fwd_from_msg_dir INTEGER; +ALTER TABLE chat_items ADD COLUMN fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL; +ALTER TABLE chat_items ADD COLUMN fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL; +ALTER TABLE chat_items ADD COLUMN fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL; + +CREATE INDEX idx_chat_items_fwd_from_contact_id ON chat_items(fwd_from_contact_id); +CREATE INDEX idx_chat_items_fwd_from_group_id ON chat_items(fwd_from_group_id); +CREATE INDEX idx_chat_items_fwd_from_chat_item_id ON chat_items(fwd_from_chat_item_id); +|] + +down_m20240402_item_forwarded :: Query +down_m20240402_item_forwarded = + [sql| +DROP INDEX idx_chat_items_fwd_from_contact_id; +DROP INDEX idx_chat_items_fwd_from_group_id; +DROP INDEX idx_chat_items_fwd_from_chat_item_id; + +ALTER TABLE chat_items DROP COLUMN fwd_from_tag; +ALTER TABLE chat_items DROP COLUMN fwd_from_chat_name; +ALTER TABLE chat_items DROP COLUMN fwd_from_msg_dir; +ALTER TABLE chat_items DROP COLUMN fwd_from_contact_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_group_id; +ALTER TABLE chat_items DROP COLUMN fwd_from_chat_item_id; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 11cbd8ae89..f2d8e59ca7 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -382,7 +382,13 @@ CREATE TABLE chat_items( item_deleted_ts TEXT, forwarded_by_group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, item_content_tag TEXT, - note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE + note_folder_id INTEGER DEFAULT NULL REFERENCES note_folders ON DELETE CASCADE, + fwd_from_tag TEXT, + fwd_from_chat_name TEXT, + fwd_from_msg_dir INTEGER, + fwd_from_contact_id INTEGER REFERENCES contacts ON DELETE SET NULL, + fwd_from_group_id INTEGER REFERENCES groups ON DELETE SET NULL, + fwd_from_chat_item_id INTEGER REFERENCES chat_items ON DELETE SET NULL ); CREATE TABLE chat_item_messages( chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, @@ -860,3 +866,10 @@ CREATE INDEX idx_chat_items_notes_item_status on chat_items( item_status ); CREATE INDEX idx_files_redirect_file_id on files(redirect_file_id); +CREATE INDEX idx_chat_items_fwd_from_contact_id ON chat_items( + fwd_from_contact_id +); +CREATE INDEX idx_chat_items_fwd_from_group_id ON chat_items(fwd_from_group_id); +CREATE INDEX idx_chat_items_fwd_from_chat_item_id ON chat_items( + fwd_from_chat_item_id +); diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index e2810dafa9..6cdc52a499 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -45,10 +45,11 @@ import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Call import Simplex.Chat.Types +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util import Simplex.Messaging.Agent.Protocol (VersionSMPA, pqdrSMPAgentVersion) import Simplex.Messaging.Compression (compress1, decompressBatch) -import Simplex.Messaging.Crypto.Ratchet (PQSupport (..), pattern PQSupportOn, pattern PQSupportOff) +import Simplex.Messaging.Crypto.Ratchet (PQSupport (..), pattern PQSupportOff, pattern PQSupportOn) import Simplex.Messaging.Encoding import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, fromTextField_, fstToLower, parseAll, sumTypeJSON, taggedObjectJSON) @@ -587,6 +588,7 @@ parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) + <|> (MCForward <$> ((v .: "forward" :: JT.Parser J.Object) *> mc)) <|> MCSimple <$> mc where mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" <*> v .:? "ttl" <*> v .:? "live" diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 819c1cd670..75602c1e18 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -50,7 +50,7 @@ import Simplex.Chat.Store.Files import Simplex.Chat.Store.Remote import Simplex.Chat.Store.Shared import Simplex.Chat.Types -import Simplex.Chat.Util (liftIOEither, encryptFile) +import Simplex.Chat.Util (encryptFile, liftIOEither) import Simplex.FileTransfer.Description (FileDigest (..)) import Simplex.Messaging.Agent import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP)) @@ -76,7 +76,7 @@ minRemoteCtrlVersion = AppVersion [5, 6, 0, 0] -- when acting as controller minRemoteHostVersion :: AppVersion -minRemoteHostVersion = AppVersion [5, 6, 0, 0] +minRemoteHostVersion = AppVersion [5, 7, 0, 0] currentAppVersion :: AppVersion currentAppVersion = AppVersion SC.version diff --git a/src/Simplex/Chat/Remote/RevHTTP.hs b/src/Simplex/Chat/Remote/RevHTTP.hs index 44f3e50b0d..4df5bcac2a 100644 --- a/src/Simplex/Chat/Remote/RevHTTP.hs +++ b/src/Simplex/Chat/Remote/RevHTTP.hs @@ -21,9 +21,9 @@ attachRevHTTP2Client disconnected = attachHTTP2Client config ANY_ADDR_V4 "0" dis attachHTTP2Server :: TLS -> (HTTP2Request -> IO ()) -> IO () attachHTTP2Server tls processRequest = - runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId r sendResponse -> do + runHTTP2ServerWith defaultHTTP2BufferSize ($ tls) $ \sessionId sessionALPN r sendResponse -> do reqBody <- getHTTP2Body r doNotPrefetchHead - processRequest HTTP2Request {sessionId, request = r, reqBody, sendResponse} + processRequest HTTP2Request {sessionId, sessionALPN, request = r, reqBody, sendResponse} -- | Suppress storing initial chunk in bodyHead, forcing clients and servers to stream chunks doNotPrefetchHead :: Int diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 91021713b1..4b0591fb3a 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -1,6 +1,7 @@ module Simplex.Chat.Store ( SQLiteStore, StoreError (..), + ChatLockEntity (..), UserMsgReceiptSettings (..), UserContactLink (..), AutoAccept (..), diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 6584aabb0a..0e543eacf2 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -3,11 +3,13 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeOperators #-} {-# OPTIONS_GHC -fno-warn-ambiguous-fields #-} module Simplex.Chat.Store.Connections - ( getConnectionEntity, + ( getChatLockEntity, + getConnectionEntity, getConnectionEntityByConnReq, getContactConnEntityByConnReqHash, getConnectionsToSubscribe, @@ -37,6 +39,31 @@ import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Util (eitherToMaybe) +getChatLockEntity :: DB.Connection -> AgentConnId -> ExceptT StoreError IO ChatLockEntity +getChatLockEntity db agentConnId = do + ((connId, connType) :. (contactId, groupMemberId, sndFileId, rcvFileId, userContactLinkId)) <- + ExceptT . firstRow id (SEConnectionNotFound agentConnId) $ + DB.query + db + [sql| + SELECT connection_id, conn_type, contact_id, group_member_id, snd_file_id, rcv_file_id, user_contact_link_id + FROM connections + WHERE agent_conn_id = ? + |] + (Only agentConnId) + let err = throwError $ SEInternalError $ "connection " <> show connType <> " without entity" + case connType of + ConnMember -> maybe err (fmap CLGroup . getMemberGroupId) groupMemberId + ConnContact -> pure $ maybe (CLConnection connId) CLContact contactId + ConnSndFile -> maybe err (pure . CLFile) sndFileId + ConnRcvFile -> maybe err (pure . CLFile) rcvFileId + ConnUserContact -> maybe err (pure . CLUserContact) userContactLinkId + where + getMemberGroupId :: GroupMemberId -> ExceptT StoreError IO GroupId + getMemberGroupId groupMemberId = + ExceptT . firstRow fromOnly (SEInternalError "group member connection group_id not found") $ + DB.query db "SELECT group_id FROM group_members WHERE group_member_id = ?" (Only groupMemberId) + getConnectionEntity :: DB.Connection -> (PQSupport -> VersionRangeChat) -> User -> AgentConnId -> ExceptT StoreError IO ConnectionEntity getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do c@Connection {connType, entityId} <- getConnection_ diff --git a/src/Simplex/Chat/Store/Files.hs b/src/Simplex/Chat/Store/Files.hs index e77681bb9b..8ac54c7e9b 100644 --- a/src/Simplex/Chat/Store/Files.hs +++ b/src/Simplex/Chat/Store/Files.hs @@ -336,10 +336,10 @@ setSndFTAgentDeleted db User {userId} fileId = do "UPDATE files SET agent_snd_file_deleted = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) -getXFTPSndFileDBId :: DB.Connection -> User -> AgentSndFileId -> ExceptT StoreError IO FileTransferId -getXFTPSndFileDBId db User {userId} aSndFileId = +getXFTPSndFileDBId :: DB.Connection -> AgentSndFileId -> ExceptT StoreError IO FileTransferId +getXFTPSndFileDBId db aSndFileId = ExceptT . firstRow fromOnly (SESndFileNotFoundXFTP aSndFileId) $ - DB.query db "SELECT file_id FROM files WHERE user_id = ? AND agent_snd_file_id = ?" (userId, aSndFileId) + DB.query db "SELECT file_id FROM files WHERE agent_snd_file_id = ?" (Only aSndFileId) getXFTPRcvFileDBId :: DB.Connection -> AgentRcvFileId -> ExceptT StoreError IO FileTransferId getXFTPRcvFileDBId db aRcvFileId = diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 832b928012..cd62f17f4c 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -124,6 +124,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) +import Data.Bifunctor (second) import Data.Either (rights) import Data.Int (Int64) import Data.List (partition, sortOn) @@ -139,6 +140,7 @@ import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB @@ -668,13 +670,13 @@ getGroupSummary db User {userId} groupId = do (userId, groupId, GSMemRemoved, GSMemLeft, GSMemUnknown, GSMemInvited) pure GroupSummary {currentMembers = fromMaybe 0 currentMembers_} -getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [FullGroupPreferences] +getContactGroupPreferences :: DB.Connection -> User -> Contact -> IO [(GroupMemberRole, FullGroupPreferences)] getContactGroupPreferences db User {userId} Contact {contactId} = do - map (mergeGroupPreferences . fromOnly) + map (second mergeGroupPreferences) <$> DB.query db [sql| - SELECT gp.preferences + SELECT m.member_role, gp.preferences FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members m USING (group_id) diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index fe89d7f506..2b31a215da 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -145,8 +145,8 @@ import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, MsgMeta (..), UserI import Simplex.Messaging.Agent.Store.SQLite (firstRow, firstRow', maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Crypto as C -import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) +import Simplex.Messaging.Crypto.Ratchet (PQSupport) import Simplex.Messaging.Util (eitherToMaybe) import UnliftIO.STM @@ -330,9 +330,9 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti (chatTs, userId, noteFolderId) _ -> pure () -createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId -createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem timed live createdAt = - createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow timed live createdAt Nothing createdAt +createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId +createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = + createNewChatItem_ db user chatDirection createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live createdAt Nothing createdAt where createdByMsgId = if msgId == 0 then Nothing else Just msgId quoteRow :: NewQuoteRow @@ -346,12 +346,13 @@ createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciCon 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 -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c)) +createNewRcvChatItem :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> CIContent 'MDRcv -> Maybe CITimed -> Bool -> UTCTime -> UTCTime -> IO (ChatItemId, Maybe (CIQuote c), Maybe CIForwardedFrom) createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forwardedByMember} sharedMsgId_ ciContent timed live itemTs createdAt = do - ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow timed live itemTs forwardedByMember createdAt + ciId <- createNewChatItem_ db user chatDirection (Just msgId) sharedMsgId_ ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt quotedItem <- mapM (getChatItemQuote_ db user chatDirection) quotedMsg - pure (ciId, quotedItem) + pure (ciId, quotedItem, itemForwarded) where + itemForwarded = cmForwardedFrom chatMsgEvent quotedMsg = cmToQuotedMsg chatMsgEvent quoteRow :: NewQuoteRow quoteRow = case quotedMsg of @@ -364,13 +365,13 @@ createNewRcvChatItem db user chatDirection RcvMessage {msgId, chatMsgEvent, forw createNewChatItemNoMsg :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> CIContent d -> UTCTime -> UTCTime -> IO ChatItemId createNewChatItemNoMsg db user chatDirection ciContent itemTs = - createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing False itemTs Nothing + createNewChatItem_ db user chatDirection Nothing Nothing ciContent quoteRow Nothing Nothing False itemTs Nothing where quoteRow :: NewQuoteRow quoteRow = (Nothing, Nothing, Nothing, Nothing, Nothing) -createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId -createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow timed live itemTs forwardedByMember createdAt = do +createNewChatItem_ :: forall c d. MsgDirectionI d => DB.Connection -> User -> ChatDirection c d -> Maybe MessageId -> Maybe SharedMsgId -> CIContent d -> NewQuoteRow -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> Maybe GroupMemberId -> UTCTime -> IO ChatItemId +createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent quoteRow itemForwarded timed live itemTs forwardedByMember createdAt = do DB.execute db [sql| @@ -381,10 +382,12 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q item_sent, item_ts, item_content, item_content_tag, item_text, item_status, shared_msg_id, forwarded_by_group_member_id, created_at, updated_at, item_live, timed_ttl, timed_delete_at, -- quote - quoted_shared_msg_id, quoted_sent_at, quoted_content, quoted_sent, quoted_member_id - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + 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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] - ((userId, msgId_) :. idsRow :. itemRow :. quoteRow) + ((userId, msgId_) :. idsRow :. itemRow :. quoteRow :. forwardedFromRow) ciId <- insertedRowId db forM_ msgId_ $ \msgId -> insertChatItemMessage_ db ciId msgId createdAt pure ciId @@ -399,6 +402,16 @@ createNewChatItem_ db User {userId} chatDirection msgId_ sharedMsgId ciContent q CDGroupSnd GroupInfo {groupId} -> (Nothing, Just groupId, Nothing, Nothing) CDLocalRcv NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) CDLocalSnd NoteFolder {noteFolderId} -> (Nothing, Nothing, Nothing, Just noteFolderId) + forwardedFromRow :: (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) + forwardedFromRow = case itemForwarded of + Nothing -> + (Nothing, Nothing, Nothing, Nothing, Nothing, Nothing) + Just CIFFUnknown -> + (Just CIFFUnknown_, Nothing, Nothing, Nothing, Nothing, Nothing) + 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) ciTimedRow :: Maybe CITimed -> (Maybe Int, Maybe UTCTime) ciTimedRow (Just CITimed {ttl, deleteAt}) = (Just ttl, deleteAt) @@ -794,7 +807,7 @@ getLocalChatPreview_ db user (LocalChatPD _ noteFolderId lastItemId_ stats) = do -- this function can be changed so it never fails, not only avoid failure on invalid json toLocalChatItem :: UTCTime -> ChatItemRow -> Either StoreError (CChatItem 'CTLocal) -toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = +toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -826,7 +839,8 @@ toLocalChatItem currentTs ((itemId, itemTs, AMsgDirection msgDir, itemContentTex DBCINotDeleted -> Nothing _ -> Just (CIDeleted @CTLocal deletedTs) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + itemForwarded = toCIForwardedFrom forwardedFromRow + in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1391,7 +1405,14 @@ type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, type ChatItemModeRow = (Maybe Int, Maybe UTCTime, Maybe Bool) -type ChatItemRow = (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) :. ChatItemModeRow :. MaybeCIFIleRow +type ChatItemForwardedFromRow = (Maybe CIForwardedFromTag, Maybe Text, Maybe MsgDirection, Maybe Int64, Maybe Int64, Maybe Int64) + +type ChatItemRow = + (Int64, ChatItemTs, AMsgDirection, Text, Text, ACIStatus, Maybe SharedMsgId) + :. (Int, Maybe UTCTime, Maybe Bool, UTCTime, UTCTime) + :. ChatItemForwardedFromRow + :. ChatItemModeRow + :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) @@ -1406,7 +1427,7 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir -- this function can be changed so it never fails, not only avoid failure on invalid json toDirectChatItem :: UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = +toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. quoteRow) = chatItem $ fromRight invalid $ dbParseACIContent itemContentText where invalid = ACIContent msgDir $ CIInvalidJSON itemContentText @@ -1438,10 +1459,19 @@ toDirectChatItem currentTs (((itemId, itemTs, AMsgDirection msgDir, itemContentT DBCINotDeleted -> Nothing _ -> Just (CIDeleted @CTDirect deletedTs) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt + itemForwarded = toCIForwardedFrom forwardedFromRow + in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs Nothing createdAt updatedAt ciTimed :: Maybe CITimed 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 + (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 + _ -> Nothing + type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow toGroupQuote :: QuoteRow -> Maybe GroupMember -> Maybe (CIQuote 'CTGroup) @@ -1454,7 +1484,7 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction -- this function can be changed so it never fails, not only avoid failure on invalid json toGroupChatItem :: UTCTime -> Int64 -> ChatItemRow :. Only (Maybe GroupMemberId) :. MaybeGroupMemberRow :. GroupQuoteRow :. MaybeGroupMemberRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do +toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, itemContentText, itemText, itemStatus, sharedMsgId) :. (itemDeleted, deletedTs, itemEdited, createdAt, updatedAt) :. forwardedFromRow :. (timedTTL, timedDeleteAt, itemLive) :. (fileId_, fileName_, fileSize_, filePath, fileKey, fileNonce, fileStatus_, fileProtocol_)) :. Only forwardedByMember :. memberRow_ :. (quoteRow :. quotedMemberRow_) :. deletedByGroupMemberRow_) = do chatItem $ fromRight invalid $ dbParseACIContent itemContentText where member_ = toMaybeGroupMember userContactId memberRow_ @@ -1491,7 +1521,8 @@ toGroupChatItem currentTs userContactId (((itemId, itemTs, AMsgDirection msgDir, DBCIBlockedByAdmin -> Just (CIBlockedByAdmin deletedTs) _ -> Just (maybe (CIDeleted @CTGroup deletedTs) (CIModerated deletedTs) deletedByGroupMember_) itemEdited' = fromMaybe False itemEdited - in mkCIMeta itemId content itemText status sharedMsgId itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt + itemForwarded = toCIForwardedFrom forwardedFromRow + in mkCIMeta itemId content itemText status sharedMsgId itemForwarded itemDeleted' itemEdited' ciTimed itemLive currentTs itemTs forwardedByMember createdAt updatedAt ciTimed :: Maybe CITimed ciTimed = timedTTL >>= \ttl -> Just CITimed {ttl, deleteAt = timedDeleteAt} @@ -1726,7 +1757,10 @@ getDirectChatItem db User {userId} contactId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, 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.timed_ttl, i.timed_delete_at, i.item_live, -- 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, -- DirectQuote @@ -1840,7 +1874,7 @@ updateGroupChatItemModerated db User {userId} GroupInfo {groupId} ci m@GroupMemb WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (deletedTs, groupMemberId, toContent, toText, currentTs, userId, groupId, itemId) - pure $ ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False}, formattedText = Nothing} + pure $ ci {content = toContent, meta = (meta ci) {itemText = toText, itemDeleted = Just (CIModerated (Just deletedTs) m), editable = False, deletable = False}, formattedText = Nothing} updateGroupCIBlockedByAdmin :: DB.Connection -> User -> GroupInfo -> ChatItem 'CTGroup d -> UTCTime -> IO (ChatItem 'CTGroup d) updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = do @@ -1857,7 +1891,7 @@ updateGroupCIBlockedByAdmin db User {userId} GroupInfo {groupId} ci deletedTs = WHERE user_id = ? AND group_id = ? AND chat_item_id = ? |] (DBCIBlockedByAdmin, deletedTs, currentTs, userId, groupId, itemId) - pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False}, formattedText = Nothing} + pure $ ci {meta = (meta ci) {itemDeleted = Just (CIBlockedByAdmin $ Just deletedTs), editable = False, deletable = False}, formattedText = Nothing} pattern DBCINotDeleted :: Int pattern DBCINotDeleted = 0 @@ -1966,7 +2000,10 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, 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.timed_ttl, i.timed_delete_at, i.item_live, -- 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, -- CIMeta forwardedByMember @@ -2067,7 +2104,10 @@ getLocalChatItem db User {userId} folderId itemId = ExceptT $ do [sql| SELECT -- ChatItem - i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_deleted_ts, i.item_edited, i.created_at, i.updated_at, i.timed_ttl, i.timed_delete_at, i.item_live, + i.chat_item_id, i.item_ts, i.item_sent, i.item_content, i.item_text, i.item_status, 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.timed_ttl, i.timed_delete_at, i.item_live, -- 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 FROM chat_items i diff --git a/src/Simplex/Chat/Store/Migrations.hs b/src/Simplex/Chat/Store/Migrations.hs index e351f0f27a..7a3fb75da3 100644 --- a/src/Simplex/Chat/Store/Migrations.hs +++ b/src/Simplex/Chat/Store/Migrations.hs @@ -104,6 +104,7 @@ import Simplex.Chat.Migrations.M20240226_users_restrict import Simplex.Chat.Migrations.M20240228_pq import Simplex.Chat.Migrations.M20240313_drop_agent_ack_cmd_id import Simplex.Chat.Migrations.M20240324_custom_data +import Simplex.Chat.Migrations.M20240402_item_forwarded import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -207,7 +208,8 @@ schemaMigrations = ("20240226_users_restrict", m20240226_users_restrict, Just down_m20240226_users_restrict), ("20240228_pq", m20240228_pq, Just down_m20240228_pq), ("20240313_drop_agent_ack_cmd_id", m20240313_drop_agent_ack_cmd_id, Just down_m20240313_drop_agent_ack_cmd_id), - ("20240324_custom_data", m20240324_custom_data, Just down_m20240324_custom_data) + ("20240324_custom_data", m20240324_custom_data, Just down_m20240324_custom_data), + ("20240402_item_forwarded", m20240402_item_forwarded, Just down_m20240402_item_forwarded) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 512c857b23..0e2445572c 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -81,6 +81,7 @@ import Simplex.Chat.Store.Direct import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol (ACorrId, ConnId, UserId) import Simplex.Messaging.Agent.Store.SQLite (firstRow, maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index 88540134fe..ef7cda4802 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -45,6 +45,15 @@ import Simplex.Messaging.Util (allFinally) import Simplex.Messaging.Version import UnliftIO.STM +data ChatLockEntity + = CLInvitation ByteString + | CLConnection Int64 + | CLContact ContactId + | CLGroup GroupId + | CLUserContact Int64 + | CLFile Int64 + deriving (Eq, Ord) + -- These error type constructors must be added to mobile apps data StoreError = SEDuplicateName diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index 7b96abc1ce..5c36994190 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -86,7 +86,10 @@ runInputLoop ct@ChatTerminal {termState, liveMessageState} cc = forever $ do Right SendLiveMessage {} -> True Right SendFile {} -> True Right SendMessageQuote {} -> True + Right ForwardMessage {} -> True + Right ForwardLocalMessage {} -> True Right SendGroupMessageQuote {} -> True + Right ForwardGroupMessage {} -> True Right SendMessageBroadcast {} -> True _ -> False startLiveMessage :: Either a ChatCommand -> ChatResponse -> IO () diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index e419f8c4cb..f7174a635b 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -30,7 +30,6 @@ import qualified Data.Aeson.TH as JQ import qualified Data.Aeson.Types as JT import qualified Data.Attoparsec.ByteString.Char8 as A import Data.ByteString.Char8 (ByteString, pack, unpack) -import qualified Data.ByteString.Char8 as B import Data.Int (Int64) import Data.Maybe (isJust) import Data.Text (Text) @@ -45,6 +44,7 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok import Database.SQLite.Simple.ToField (ToField (..)) import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util import Simplex.FileTransfer.Description (FileDigest) import Simplex.Messaging.Agent.Protocol (ACommandTag (..), ACorrId, AParty (..), APartyCmdTag (..), ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId, RcvFileId, SAEntity (..), SndFileId, UserId) @@ -439,9 +439,13 @@ featureAllowed feature forWhom Contact {mergedPreferences} = let ContactUserPreference {enabled} = getContactUserPreference feature mergedPreferences in forWhom enabled -groupFeatureAllowed :: GroupFeatureI f => SGroupFeature f -> GroupInfo -> Bool +groupFeatureAllowed :: GroupFeatureNoRoleI f => SGroupFeature f -> GroupInfo -> Bool groupFeatureAllowed feature gInfo = groupFeatureAllowed' feature $ fullGroupPreferences gInfo +groupFeatureMemberAllowed :: GroupFeatureRoleI f => SGroupFeature f -> GroupMember -> GroupInfo -> Bool +groupFeatureMemberAllowed feature GroupMember {memberRole} = + groupFeatureMemberAllowed' feature memberRole . fullGroupPreferences + mergeUserChatPrefs :: User -> Contact -> FullPreferences mergeUserChatPrefs user ct = mergeUserChatPrefs' user (contactConnIncognito ct) (userPreferences ct) @@ -796,41 +800,6 @@ fromInvitedBy userCtId = \case IBContact ctId -> Just ctId IBUser -> Just userCtId -data GroupMemberRole - = GRObserver -- connects to all group members and receives all messages, can't send messages - | GRAuthor -- reserved, unused - | GRMember -- + can send messages to all group members - | GRAdmin -- + add/remove members, change member role (excl. Owners) - | GROwner -- + delete and change group information, add/remove/change roles for Owners - deriving (Eq, Show, Ord) - -instance FromField GroupMemberRole where fromField = fromBlobField_ strDecode - -instance ToField GroupMemberRole where toField = toField . strEncode - -instance StrEncoding GroupMemberRole where - strEncode = \case - GROwner -> "owner" - GRAdmin -> "admin" - GRMember -> "member" - GRAuthor -> "author" - GRObserver -> "observer" - strDecode = \case - "owner" -> Right GROwner - "admin" -> Right GRAdmin - "member" -> Right GRMember - "author" -> Right GRAuthor - "observer" -> Right GRObserver - r -> Left $ "bad GroupMemberRole " <> B.unpack r - strP = strDecode <$?> A.takeByteString - -instance FromJSON GroupMemberRole where - parseJSON = strParseJSON "GroupMemberRole" - -instance ToJSON GroupMemberRole where - toJSON = strToJSON - toEncoding = strToJEncoding - data GroupMemberSettings = GroupMemberSettings { showMessages :: Bool } diff --git a/src/Simplex/Chat/Types/Preferences.hs b/src/Simplex/Chat/Types/Preferences.hs index 2286ae8f40..4cf9f862d2 100644 --- a/src/Simplex/Chat/Types/Preferences.hs +++ b/src/Simplex/Chat/Types/Preferences.hs @@ -10,6 +10,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE StandaloneDeriving #-} +{-# LANGUAGE StrictData #-} {-# LANGUAGE TemplateHaskell #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeFamilyDependencies #-} @@ -31,6 +32,7 @@ import qualified Data.Text as T import Database.SQLite.Simple.FromField (FromField (..)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Records.Compat +import Simplex.Chat.Types.Shared import Simplex.Chat.Types.Util import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, fromTextField_, sumTypeJSON) @@ -148,6 +150,7 @@ data GroupFeature | GFReactions | GFVoice | GFFiles + | GFSimplexLinks | GFHistory deriving (Show) @@ -158,14 +161,23 @@ data SGroupFeature (f :: GroupFeature) where SGFReactions :: SGroupFeature 'GFReactions SGFVoice :: SGroupFeature 'GFVoice SGFFiles :: SGroupFeature 'GFFiles + SGFSimplexLinks :: SGroupFeature 'GFSimplexLinks SGFHistory :: SGroupFeature 'GFHistory deriving instance Show (SGroupFeature f) data AGroupFeature = forall f. GroupFeatureI f => AGF (SGroupFeature f) +data AGroupFeatureNoRole = forall f. GroupFeatureNoRoleI f => AGFNR (SGroupFeature f) + +data AGroupFeatureRole = forall f. GroupFeatureRoleI f => AGFR (SGroupFeature f) + deriving instance Show AGroupFeature +deriving instance Show AGroupFeatureNoRole + +deriving instance Show AGroupFeatureRole + groupFeatureNameText :: GroupFeature -> Text groupFeatureNameText = \case GFTimedMessages -> "Disappearing messages" @@ -174,15 +186,21 @@ groupFeatureNameText = \case GFReactions -> "Message reactions" GFVoice -> "Voice messages" GFFiles -> "Files and media" + GFSimplexLinks -> "SimpleX links" GFHistory -> "Recent history" groupFeatureNameText' :: SGroupFeature f -> Text groupFeatureNameText' = groupFeatureNameText . toGroupFeature -groupFeatureAllowed' :: GroupFeatureI f => SGroupFeature f -> FullGroupPreferences -> Bool +groupFeatureAllowed' :: GroupFeatureNoRoleI f => SGroupFeature f -> FullGroupPreferences -> Bool groupFeatureAllowed' feature prefs = getField @"enable" (getGroupPreference feature prefs) == FEOn +groupFeatureMemberAllowed' :: GroupFeatureRoleI f => SGroupFeature f -> GroupMemberRole -> FullGroupPreferences -> Bool +groupFeatureMemberAllowed' feature role prefs = + let pref = getGroupPreference feature prefs + in getField @"enable" pref == FEOn && maybe True (role >=) (getField @"role" pref) + allGroupFeatures :: [AGroupFeature] allGroupFeatures = [ AGF SGFTimedMessages, @@ -191,17 +209,19 @@ allGroupFeatures = AGF SGFReactions, AGF SGFVoice, AGF SGFFiles, + AGF SGFSimplexLinks, AGF SGFHistory ] groupPrefSel :: SGroupFeature f -> GroupPreferences -> Maybe (GroupFeaturePreference f) -groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, history} = case f of +groupPrefSel f GroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete SGFReactions -> reactions SGFVoice -> voice SGFFiles -> files + SGFSimplexLinks -> simplexLinks SGFHistory -> history toGroupFeature :: SGroupFeature f -> GroupFeature @@ -212,6 +232,7 @@ toGroupFeature = \case SGFReactions -> GFReactions SGFVoice -> GFVoice SGFFiles -> GFFiles + SGFSimplexLinks -> GFSimplexLinks SGFHistory -> GFHistory class GroupPreferenceI p where @@ -224,13 +245,14 @@ instance GroupPreferenceI (Maybe GroupPreferences) where getGroupPreference pt prefs = fromMaybe (getGroupPreference pt defaultGroupPrefs) (groupPrefSel pt =<< prefs) instance GroupPreferenceI FullGroupPreferences where - getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, history} = case f of + getGroupPreference f FullGroupPreferences {timedMessages, directMessages, fullDelete, reactions, voice, files, simplexLinks, history} = case f of SGFTimedMessages -> timedMessages SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete SGFReactions -> reactions SGFVoice -> voice SGFFiles -> files + SGFSimplexLinks -> simplexLinks SGFHistory -> history {-# INLINE getGroupPreference #-} @@ -242,17 +264,25 @@ data GroupPreferences = GroupPreferences reactions :: Maybe ReactionsGroupPreference, voice :: Maybe VoiceGroupPreference, files :: Maybe FilesGroupPreference, + simplexLinks :: Maybe SimplexLinksGroupPreference, history :: Maybe HistoryGroupPreference } deriving (Eq, Show) -setGroupPreference :: forall f. GroupFeatureI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences +setGroupPreference :: forall f. GroupFeatureNoRoleI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupPreferences -> GroupPreferences setGroupPreference f enable prefs_ = setGroupPreference_ f pref prefs where prefs = mergeGroupPreferences prefs_ pref :: GroupFeaturePreference f pref = setField @"enable" (getGroupPreference f prefs) enable +setGroupPreferenceRole :: forall f. GroupFeatureRoleI f => SGroupFeature f -> GroupFeatureEnabled -> Maybe GroupMemberRole -> Maybe GroupPreferences -> GroupPreferences +setGroupPreferenceRole f enable role prefs_ = setGroupPreference_ f pref prefs + where + prefs = mergeGroupPreferences prefs_ + pref :: GroupFeaturePreference f + pref = setField @"role" (setField @"enable" (getGroupPreference f prefs) enable) role + setGroupPreference' :: SGroupFeature f -> GroupFeaturePreference f -> Maybe GroupPreferences -> GroupPreferences setGroupPreference' f pref prefs_ = setGroupPreference_ f pref prefs where @@ -267,6 +297,7 @@ setGroupPreference_ f pref prefs = SGFReactions -> prefs {reactions = pref} SGFVoice -> prefs {voice = pref} SGFFiles -> prefs {files = pref} + SGFSimplexLinks -> prefs {simplexLinks = pref} SGFHistory -> prefs {history = pref} setGroupTimedMessagesPreference :: TimedMessagesGroupPreference -> Maybe GroupPreferences -> GroupPreferences @@ -295,6 +326,7 @@ data FullGroupPreferences = FullGroupPreferences reactions :: ReactionsGroupPreference, voice :: VoiceGroupPreference, files :: FilesGroupPreference, + simplexLinks :: SimplexLinksGroupPreference, history :: HistoryGroupPreference } deriving (Eq, Show) @@ -346,16 +378,17 @@ defaultGroupPrefs :: FullGroupPreferences defaultGroupPrefs = FullGroupPreferences { timedMessages = TimedMessagesGroupPreference {enable = FEOff, ttl = Just 86400}, - directMessages = DirectMessagesGroupPreference {enable = FEOff}, + directMessages = DirectMessagesGroupPreference {enable = FEOff, role = Nothing}, fullDelete = FullDeleteGroupPreference {enable = FEOff}, reactions = ReactionsGroupPreference {enable = FEOn}, - voice = VoiceGroupPreference {enable = FEOn}, - files = FilesGroupPreference {enable = FEOn}, + voice = VoiceGroupPreference {enable = FEOn, role = Nothing}, + files = FilesGroupPreference {enable = FEOn, role = Nothing}, + simplexLinks = SimplexLinksGroupPreference {enable = FEOn, role = Nothing}, history = HistoryGroupPreference {enable = FEOff} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing Nothing Nothing Nothing data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, @@ -431,7 +464,7 @@ data TimedMessagesGroupPreference = TimedMessagesGroupPreference deriving (Eq, Show) data DirectMessagesGroupPreference = DirectMessagesGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data FullDeleteGroupPreference = FullDeleteGroupPreference @@ -443,11 +476,15 @@ data ReactionsGroupPreference = ReactionsGroupPreference deriving (Eq, Show) data VoiceGroupPreference = VoiceGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data FilesGroupPreference = FilesGroupPreference - {enable :: GroupFeatureEnabled} + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} + deriving (Eq, Show) + +data SimplexLinksGroupPreference = SimplexLinksGroupPreference + {enable :: GroupFeatureEnabled, role :: Maybe GroupMemberRole} deriving (Eq, Show) data HistoryGroupPreference = HistoryGroupPreference @@ -458,6 +495,11 @@ class (Eq (GroupFeaturePreference f), HasField "enable" (GroupFeaturePreference type GroupFeaturePreference (f :: GroupFeature) = p | p -> f sGroupFeature :: SGroupFeature f groupPrefParam :: GroupFeaturePreference f -> Maybe Int + groupPrefRole :: GroupFeaturePreference f -> Maybe GroupMemberRole + +class GroupFeatureI f => GroupFeatureNoRoleI f + +class (GroupFeatureI f, HasField "role" (GroupFeaturePreference f) (Maybe GroupMemberRole)) => GroupFeatureRoleI f instance HasField "enable" GroupPreference GroupFeatureEnabled where hasField p@GroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -480,6 +522,9 @@ instance HasField "enable" VoiceGroupPreference GroupFeatureEnabled where instance HasField "enable" FilesGroupPreference GroupFeatureEnabled where hasField p@FilesGroupPreference {enable} = (\e -> p {enable = e}, enable) +instance HasField "enable" SimplexLinksGroupPreference GroupFeatureEnabled where + hasField p@SimplexLinksGroupPreference {enable} = (\e -> p {enable = e}, enable) + instance HasField "enable" HistoryGroupPreference GroupFeatureEnabled where hasField p@HistoryGroupPreference {enable} = (\e -> p {enable = e}, enable) @@ -487,42 +532,84 @@ instance GroupFeatureI 'GFTimedMessages where type GroupFeaturePreference 'GFTimedMessages = TimedMessagesGroupPreference sGroupFeature = SGFTimedMessages groupPrefParam TimedMessagesGroupPreference {ttl} = ttl + groupPrefRole _ = Nothing instance GroupFeatureI 'GFDirectMessages where type GroupFeaturePreference 'GFDirectMessages = DirectMessagesGroupPreference sGroupFeature = SGFDirectMessages groupPrefParam _ = Nothing + groupPrefRole DirectMessagesGroupPreference {role} = role instance GroupFeatureI 'GFFullDelete where type GroupFeaturePreference 'GFFullDelete = FullDeleteGroupPreference sGroupFeature = SGFFullDelete groupPrefParam _ = Nothing + groupPrefRole _ = Nothing instance GroupFeatureI 'GFReactions where type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference sGroupFeature = SGFReactions groupPrefParam _ = Nothing + groupPrefRole _ = Nothing instance GroupFeatureI 'GFVoice where type GroupFeaturePreference 'GFVoice = VoiceGroupPreference sGroupFeature = SGFVoice groupPrefParam _ = Nothing + groupPrefRole VoiceGroupPreference {role} = role instance GroupFeatureI 'GFFiles where type GroupFeaturePreference 'GFFiles = FilesGroupPreference sGroupFeature = SGFFiles groupPrefParam _ = Nothing + groupPrefRole FilesGroupPreference {role} = role + +instance GroupFeatureI 'GFSimplexLinks where + type GroupFeaturePreference 'GFSimplexLinks = SimplexLinksGroupPreference + sGroupFeature = SGFSimplexLinks + groupPrefParam _ = Nothing + groupPrefRole SimplexLinksGroupPreference {role} = role instance GroupFeatureI 'GFHistory where type GroupFeaturePreference 'GFHistory = HistoryGroupPreference sGroupFeature = SGFHistory groupPrefParam _ = Nothing + groupPrefRole _ = Nothing -groupPrefStateText :: HasField "enable" p GroupFeatureEnabled => GroupFeature -> p -> Maybe Int -> Text -groupPrefStateText feature pref param = +instance GroupFeatureNoRoleI 'GFTimedMessages + +instance GroupFeatureNoRoleI 'GFFullDelete + +instance GroupFeatureNoRoleI 'GFReactions + +instance GroupFeatureNoRoleI 'GFHistory + +instance HasField "role" DirectMessagesGroupPreference (Maybe GroupMemberRole) where + hasField p@DirectMessagesGroupPreference {role} = (\r -> p {role = r}, role) + +instance HasField "role" VoiceGroupPreference (Maybe GroupMemberRole) where + hasField p@VoiceGroupPreference {role} = (\r -> p {role = r}, role) + +instance HasField "role" FilesGroupPreference (Maybe GroupMemberRole) where + hasField p@FilesGroupPreference {role} = (\r -> p {role = r}, role) + +instance HasField "role" SimplexLinksGroupPreference (Maybe GroupMemberRole) where + hasField p@SimplexLinksGroupPreference {role} = (\r -> p {role = r}, role) + +instance GroupFeatureRoleI 'GFDirectMessages + +instance GroupFeatureRoleI 'GFVoice + +instance GroupFeatureRoleI 'GFFiles + +instance GroupFeatureRoleI 'GFSimplexLinks + +groupPrefStateText :: HasField "enable" p GroupFeatureEnabled => GroupFeature -> p -> Maybe Int -> Maybe GroupMemberRole -> Text +groupPrefStateText feature pref param role = let enabled = getField @"enable" pref paramText = if enabled == FEOn then groupParamText_ feature param else "" - in groupFeatureNameText feature <> ": " <> safeDecodeUtf8 (strEncode enabled) <> paramText + roleText = maybe "" (\r -> " for " <> safeDecodeUtf8 (strEncode r) <> "s") role + in groupFeatureNameText feature <> ": " <> safeDecodeUtf8 (strEncode enabled) <> paramText <> roleText groupParamText_ :: GroupFeature -> Maybe Int -> Text groupParamText_ feature param = case feature of @@ -532,7 +619,7 @@ groupParamText_ feature param = case feature of groupPreferenceText :: forall f. GroupFeatureI f => GroupFeaturePreference f -> Text groupPreferenceText pref = let feature = toGroupFeature $ sGroupFeature @f - in groupPrefStateText feature pref $ groupPrefParam pref + in groupPrefStateText feature pref (groupPrefParam pref) (groupPrefRole pref) timedTTLText :: Int -> Text timedTTLText 0 = "0 sec" @@ -602,7 +689,7 @@ instance StrEncoding GroupFeatureEnabled where "on" -> Right FEOn "off" -> Right FEOff r -> Left $ "bad GroupFeatureEnabled " <> B.unpack r - strP = strDecode <$?> A.takeByteString + strP = strDecode <$?> A.takeTill (== ' ') instance FromJSON GroupFeatureEnabled where parseJSON = strParseJSON "GroupFeatureEnabled" @@ -611,11 +698,13 @@ instance ToJSON GroupFeatureEnabled where toJSON = strToJSON toEncoding = strToJEncoding -groupFeatureState :: GroupFeatureI f => GroupFeaturePreference f -> (GroupFeatureEnabled, Maybe Int) +groupFeatureState :: GroupFeatureI f => GroupFeaturePreference f -> (GroupFeatureEnabled, Maybe Int, Maybe GroupMemberRole) groupFeatureState p = let enable = getField @"enable" p - param = if enable == FEOn then groupPrefParam p else Nothing - in (enable, param) + (param, role) + | enable == FEOn = (groupPrefParam p, groupPrefRole p) + | otherwise = (Nothing, Nothing) + in (enable, param, role) mergePreferences :: Maybe Preferences -> Maybe Preferences -> FullPreferences mergePreferences contactPrefs userPreferences = @@ -641,6 +730,7 @@ mergeGroupPreferences groupPreferences = reactions = pref SGFReactions, voice = pref SGFVoice, files = pref SGFFiles, + simplexLinks = pref SGFSimplexLinks, history = pref SGFHistory } where @@ -656,6 +746,7 @@ toGroupPreferences groupPreferences = reactions = pref SGFReactions, voice = pref SGFVoice, files = pref SGFFiles, + simplexLinks = pref SGFSimplexLinks, history = pref SGFHistory } where @@ -762,6 +853,8 @@ $(J.deriveJSON defaultJSON ''VoiceGroupPreference) $(J.deriveJSON defaultJSON ''FilesGroupPreference) +$(J.deriveJSON defaultJSON ''SimplexLinksGroupPreference) + $(J.deriveJSON defaultJSON ''HistoryGroupPreference) $(J.deriveJSON defaultJSON ''GroupPreferences) diff --git a/src/Simplex/Chat/Types/Shared.hs b/src/Simplex/Chat/Types/Shared.hs new file mode 100644 index 0000000000..f44457160f --- /dev/null +++ b/src/Simplex/Chat/Types/Shared.hs @@ -0,0 +1,48 @@ +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} + +module Simplex.Chat.Types.Shared where + +import Data.Aeson (FromJSON (..), ToJSON (..)) +import qualified Data.Attoparsec.ByteString.Char8 as A +import qualified Data.ByteString.Char8 as B +import Database.SQLite.Simple.FromField (FromField (..)) +import Database.SQLite.Simple.ToField (ToField (..)) +import Simplex.Chat.Types.Util +import Simplex.Messaging.Encoding.String +import Simplex.Messaging.Util ((<$?>)) + +data GroupMemberRole + = GRObserver -- connects to all group members and receives all messages, can't send messages + | GRAuthor -- reserved, unused + | GRMember -- + can send messages to all group members + | GRAdmin -- + add/remove members, change member role (excl. Owners) + | GROwner -- + delete and change group information, add/remove/change roles for Owners + deriving (Eq, Show, Ord) + +instance FromField GroupMemberRole where fromField = fromBlobField_ strDecode + +instance ToField GroupMemberRole where toField = toField . strEncode + +instance StrEncoding GroupMemberRole where + strEncode = \case + GROwner -> "owner" + GRAdmin -> "admin" + GRMember -> "member" + GRAuthor -> "author" + GRObserver -> "observer" + strDecode = \case + "owner" -> Right GROwner + "admin" -> Right GRAdmin + "member" -> Right GRMember + "author" -> Right GRAuthor + "observer" -> Right GRObserver + r -> Left $ "bad GroupMemberRole " <> B.unpack r + strP = strDecode <$?> A.takeByteString + +instance FromJSON GroupMemberRole where + parseJSON = strParseJSON "GroupMemberRole" + +instance ToJSON GroupMemberRole where + toJSON = strToJSON + toEncoding = strToJEncoding diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 65a6626308..89d0136478 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -49,6 +49,7 @@ import Simplex.Chat.Store (AutoAccept (..), StoreError (..), UserContactLink (.. import Simplex.Chat.Styled import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import qualified Simplex.FileTransfer.Transport as XFTPTransport import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) import Simplex.Messaging.Agent.Env.SQLite (NetworkConfig (..)) @@ -351,8 +352,9 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe <> (" :: avg: " <> sShow timeAvg <> " ms") <> (" :: " <> plain (T.unwords $ T.lines query)) in ("Chat queries" : map viewQuery chatQueries) <> [""] <> ("Agent queries" : map viewQuery agentQueries) - CRDebugLocks {chatLockName, agentLocks} -> + CRDebugLocks {chatLockName, chatEntityLocks, agentLocks} -> [ maybe "no chat lock" (("chat lock: " <>) . plain) chatLockName, + plain $ "chat entity locks: " <> LB.unpack (J.encode chatEntityLocks), plain $ "agent locks: " <> LB.unpack (J.encode agentLocks) ] CRAgentStats stats -> map (plain . intercalate ",") stats @@ -534,60 +536,68 @@ viewChats ts tz = concatMap chatPreview . reverse _ -> [] viewChatItem :: forall c d. MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, content, quotedItem, file} doShow ts tz = +viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, forwardedByMember}, content, quotedItem, file} doShow ts tz = withGroupMsgForwarded . withItemDeleted <$> viewCI where viewCI = case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of - CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc + CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc CISndGroupEvent {} -> showSndItemProhibited to _ -> showSndItem to where to = ttyToContact' c CIDirectRcv -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvGroupEvent {} -> showRcvItemProhibited from _ -> showRcvItem from where from = ttyFromContact c where - quote = maybe [] (directQuote chatDir) quotedItem + context = + maybe + (maybe [] forwardedFrom itemForwarded) + (directQuote chatDir) + quotedItem GroupChat g -> case chatDir of CIGroupSnd -> case content of - CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to quote mc + CISndMsgContent mc -> hideLive meta $ withSndFile to $ sndMsg to context mc CISndGroupInvitation {} -> showSndItemProhibited to _ -> showSndItem to where to = ttyToGroup g CIGroupRcv m -> case content of - CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvGroupInvitation {} -> showRcvItemProhibited from - CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) quote meta [plainContent content] False - CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) quote meta [plainContent content] False + CIRcvModerated {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False + CIRcvBlocked {} -> receivedWithTime_ ts tz (ttyFromGroup g m) context meta [plainContent content] False _ -> showRcvItem from where from = ttyFromGroup g m where - quote = maybe [] (groupQuote g) quotedItem + context = + maybe + (maybe [] forwardedFrom itemForwarded) + (groupQuote g) + quotedItem LocalChat _ -> case chatDir of CILocalSnd -> case content of - CISndMsgContent mc -> hideLive meta $ withLocalFile to $ sndMsg to quote mc + CISndMsgContent mc -> hideLive meta $ withLocalFile to $ sndMsg to context mc CISndGroupEvent {} -> showSndItemProhibited to _ -> showSndItem to where to = "* " CILocalRcv -> case content of - CIRcvMsgContent mc -> withLocalFile from $ rcvMsg from quote mc + CIRcvMsgContent mc -> withLocalFile from $ rcvMsg from context mc CIRcvIntegrityError err -> viewRcvIntegrityError from err ts tz meta CIRcvGroupEvent {} -> showRcvItemProhibited from _ -> showRcvItem from where from = "* " where - quote = [] + context = maybe [] forwardedFrom itemForwarded ContactRequest {} -> [] ContactConnection {} -> [] withItemDeleted item = case chatItemDeletedText ci (chatInfoMembership chat) of @@ -602,10 +612,10 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, withFile view dir l = maybe l (\f -> l <> view dir f ts tz meta) file sndMsg = msg viewSentMessage rcvMsg = msg viewReceivedMessage - msg view dir quote mc = case (msgContentText mc, file, quote) of + msg view dir context mc = case (msgContentText mc, file, context) of ("", Just _, []) -> [] - ("", Just CIFile {fileName}, _) -> view dir quote (MCText $ T.pack fileName) ts tz meta - _ -> view dir quote mc ts tz meta + ("", Just CIFile {fileName}, _) -> view dir context (MCText $ T.pack fileName) ts tz meta + _ -> view dir context mc ts tz meta showSndItem to = showItem $ sentWithTime_ ts tz [to <> plainContent content] meta showRcvItem from = showItem $ receivedWithTime_ ts tz from [] meta [plainContent content] False showSndItemProhibited to = showItem $ sentWithTime_ ts tz [to <> plainContent content <> " " <> prohibited] meta @@ -615,11 +625,12 @@ viewChatItem chat ci@ChatItem {chatDir, meta = meta@CIMeta {forwardedByMember}, 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} tz = +viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTimed, createdAt}}) ChatItemInfo {itemVersions, forwardedFromChatItem} tz = ["sent at: " <> ts itemTs] <> receivedAt <> toBeDeletedAt <> versions + <> forwardedFrom' where ts = styleTime . localTs tz receivedAt = case msgDir of @@ -632,7 +643,21 @@ viewChatItemInfo (AChatItem _ msgDir _ ChatItem {meta = CIMeta {itemTs, itemTime if null itemVersions then [] else ["message history:"] <> concatMap version itemVersions - version ChatItemVersion {msgContent, itemVersionTs} = prependFirst (ts itemVersionTs <> styleTime ": ") $ ttyMsgContent msgContent + where + version ChatItemVersion {msgContent, itemVersionTs} = prependFirst (ts itemVersionTs <> styleTime ": ") $ ttyMsgContent msgContent + forwardedFrom' = + case forwardedFromChatItem of + Just fwdACI@(AChatItem _ fwdMsgDir fwdChatInfo _) -> + [plain $ "forwarded from: " <> maybe "" (<> ", ") fwdDir_ <> fwdItemId] + where + fwdDir_ = case (fwdMsgDir, fwdChatInfo) of + (SMDSnd, DirectChat ct) -> Just $ "you @" <> viewContactName ct + (SMDRcv, DirectChat ct) -> Just $ "@" <> viewContactName ct + (SMDSnd, GroupChat gInfo) -> Just $ "you #" <> viewGroupName gInfo + (SMDRcv, GroupChat gInfo) -> Just $ "#" <> viewGroupName gInfo + _ -> Nothing + fwdItemId = "chat item id: " <> (T.pack . show $ aChatItemId fwdACI) + _ -> [] localTs :: TimeZone -> UTCTime -> String localTs tz ts = do @@ -664,37 +689,45 @@ viewDeliveryReceipt = \case MRBadMsgHash -> ttyError' "⩗!" viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] -viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemEdited, itemLive}, content, quotedItem} liveItems ts tz = case chat of +viewItemUpdate chat ChatItem {chatDir, meta = meta@CIMeta {itemForwarded, itemEdited, itemLive}, content, quotedItem} liveItems ts tz = case chat of DirectChat c -> case chatDir of CIDirectRcv -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from quote mc ts tz meta + | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta _ -> [] where from = if itemEdited then ttyFromContactEdited c else ttyFromContact c CIDirectSnd -> case content of - CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts tz meta + CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where to = if itemEdited then ttyToContactEdited' c else ttyToContact' c where - quote = maybe [] (directQuote chatDir) quotedItem + context = + maybe + (maybe [] forwardedFrom itemForwarded) + (directQuote chatDir) + quotedItem GroupChat g -> case chatDir of CIGroupRcv m -> case content of CIRcvMsgContent mc | itemLive == Just True && not liveItems -> [] - | otherwise -> viewReceivedUpdatedMessage from quote mc ts tz meta + | otherwise -> viewReceivedUpdatedMessage from context mc ts tz meta _ -> [] where from = if itemEdited then ttyFromGroupEdited g m else ttyFromGroup g m CIGroupSnd -> case content of - CISndMsgContent mc -> hideLive meta $ viewSentMessage to quote mc ts tz meta + CISndMsgContent mc -> hideLive meta $ viewSentMessage to context mc ts tz meta _ -> [] where to = if itemEdited then ttyToGroupEdited g else ttyToGroup g where - quote = maybe [] (groupQuote g) quotedItem + context = + maybe + (maybe [] forwardedFrom itemForwarded) + (groupQuote g) + quotedItem _ -> [] hideLive :: CIMeta c d -> [StyledString] -> [StyledString] @@ -776,6 +809,14 @@ directQuote _ CIQuote {content = qmc, chatDir = quoteDir} = groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString] groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir +forwardedFrom :: CIForwardedFrom -> [StyledString] +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] + sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember sentByMember GroupInfo {membership} = \case CIQGroupSnd -> Just membership @@ -834,7 +875,9 @@ viewChatCleared :: AChatInfo -> [StyledString] viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of DirectChat ct -> [ttyContact' ct <> ": all messages are removed locally ONLY"] GroupChat gi -> [ttyGroup' gi <> ": all messages are removed locally ONLY"] - _ -> [] + LocalChat _ -> ["notes: all messages are removed"] + ContactRequest _ -> [] + ContactConnection _ -> [] viewContactsList :: [Contact] -> [StyledString] viewContactsList = @@ -1482,17 +1525,17 @@ viewReceivedUpdatedMessage :: StyledString -> [StyledString] -> MsgContent -> Cu viewReceivedUpdatedMessage = viewReceivedMessage_ True viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewReceivedMessage_ updated from quote mc ts tz meta = receivedWithTime_ ts tz from quote meta (ttyMsgContent mc) updated +viewReceivedMessage_ updated from context mc ts tz meta = receivedWithTime_ ts tz from context meta (ttyMsgContent mc) updated viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> TimeZone -> UTCTime -> [StyledString] viewReceivedReaction from styledMsg reactionText ts tz reactionTs = prependFirst (ttyMsgTime ts tz reactionTs <> " " <> from) (styledMsg <> [" " <> reactionText]) receivedWithTime_ :: CurrentTime -> TimeZone -> StyledString -> [StyledString] -> CIMeta c d -> [StyledString] -> Bool -> [StyledString] -receivedWithTime_ ts tz from quote CIMeta {itemId, itemTs, itemEdited, itemDeleted, itemLive} styledMsg updated = do - prependFirst (ttyMsgTime ts tz itemTs <> " " <> from) (quote <> prependFirst (indent <> live) styledMsg) +receivedWithTime_ ts tz from context CIMeta {itemId, itemTs, itemEdited, itemDeleted, itemLive} styledMsg updated = do + prependFirst (ttyMsgTime ts tz itemTs <> " " <> from) (context <> prependFirst (indent <> live) styledMsg) where - indent = if null quote then "" else " " + indent = if null context then "" else " " live | itemEdited || isJust itemDeleted = "" | otherwise = case itemLive of @@ -1520,9 +1563,9 @@ recent now tz time = do || (localNow < currentDay12 && localTime >= previousDay18 && localTimeDay < localNowDay) viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CurrentTime -> TimeZone -> CIMeta c d -> [StyledString] -viewSentMessage to quote mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive} = sentWithTime_ ts tz (prependFirst to $ quote <> prependFirst (indent <> live) (ttyMsgContent mc)) meta +viewSentMessage to context mc ts tz meta@CIMeta {itemEdited, itemDeleted, itemLive} = sentWithTime_ ts tz (prependFirst to $ context <> prependFirst (indent <> live) (ttyMsgContent mc)) meta where - indent = if null quote then "" else " " + indent = if null context then "" else " " live | itemEdited || isJust itemDeleted = "" | otherwise = case itemLive of @@ -1595,7 +1638,7 @@ standaloneUploadComplete FileTransferMeta {fileId, fileName} = \case [] -> [fileTransferStr fileId fileName <> " upload complete."] uris -> fileTransferStr fileId fileName <> " upload complete. download with:" - : map plain uris + : map plain uris sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName @@ -1924,6 +1967,8 @@ viewChatError logLevel testView = \case CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"] CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."] CEInvalidQuote -> ["cannot reply to this message"] + CEInvalidForward -> ["cannot forward this message"] + CEForwardNoFile -> ["cannot forward this message, file not found"] CEInvalidChatItemUpdate -> ["cannot update this item"] CEInvalidChatItemDelete -> ["cannot delete this item"] CEHasCurrentCall -> ["call already in progress"] diff --git a/tests/Bots/DirectoryTests.hs b/tests/Bots/DirectoryTests.hs index b78d36f489..fbabccfb54 100644 --- a/tests/Bots/DirectoryTests.hs +++ b/tests/Bots/DirectoryTests.hs @@ -19,7 +19,8 @@ import Simplex.Chat.Bot.KnownContacts import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Core import Simplex.Chat.Options (CoreChatOpts (..)) -import Simplex.Chat.Types (GroupMemberRole (..), Profile (..)) +import Simplex.Chat.Types (Profile (..)) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import System.FilePath (()) import Test.Hspec hiding (it) diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index 77d256a240..e8f3838eb6 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -3,6 +3,7 @@ module ChatTests where import ChatTests.ChatList import ChatTests.Direct import ChatTests.Files +import ChatTests.Forward import ChatTests.Groups import ChatTests.Local import ChatTests.Profiles @@ -11,6 +12,7 @@ import Test.Hspec hiding (it) chatTests :: SpecWith FilePath chatTests = do describe "direct tests" chatDirectTests + describe "forward tests" chatForwardTests describe "group tests" chatGroupTests describe "local chats tests" chatLocalChatsTests describe "file tests" chatFileTests diff --git a/tests/ChatTests/Files.hs b/tests/ChatTests/Files.hs index 1e72df9156..572d9294a9 100644 --- a/tests/ChatTests/Files.hs +++ b/tests/ChatTests/Files.hs @@ -617,20 +617,8 @@ testXFTPWithRelativePaths = withXFTPServer $ do -- agent is passed xftp work directory only on chat start, -- so for test we work around by stopping and starting chat - alice ##> "/_stop" - alice <## "chat stopped" - alice #$> ("/_files_folder ./tests/fixtures", id, "ok") - alice #$> ("/_temp_folder ./tests/tmp/alice_xftp", id, "ok") - alice ##> "/_start" - alice <## "chat started" - - bob ##> "/_stop" - bob <## "chat stopped" - bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok") - bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok") - bob ##> "/_start" - bob <## "chat started" - + setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" + setRelativePaths bob "./tests/tmp/bob_files" "./tests/tmp/bob_xftp" connectUsers alice bob alice #> "/f @bob test.pdf" @@ -787,7 +775,8 @@ testXFTPCancelRcvRepeat = bob ##> "/fr 1 ./tests/tmp" bob <### [ "saving file 1 from alice to ./tests/tmp/testfile_1", - "started receiving file 1 (testfile) from alice" + "started receiving file 1 (testfile) from alice", + StartsWith "chat db error: SERcvFileNotFoundXFTP" ] bob <## "completed receiving file 1 (testfile) from alice" diff --git a/tests/ChatTests/Forward.hs b/tests/ChatTests/Forward.hs new file mode 100644 index 0000000000..d49c6df955 --- /dev/null +++ b/tests/ChatTests/Forward.hs @@ -0,0 +1,592 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} + +module ChatTests.Forward where + +import ChatClient +import ChatTests.Utils +import Control.Concurrent (threadDelay) +import qualified Data.ByteString.Char8 as B +import System.Directory (copyFile, doesFileExist) +import Test.Hspec hiding (it) + +chatForwardTests :: SpecWith FilePath +chatForwardTests = do + describe "forward messages" $ do + it "from contact to contact" testForwardContactToContact + it "from contact to group" testForwardContactToGroup + it "from contact to notes" testForwardContactToNotes + it "from group to contact" testForwardGroupToContact + it "from group to group" testForwardGroupToGroup + it "from group to notes" testForwardGroupToNotes + it "from notes to contact" testForwardNotesToContact + it "from notes to group" testForwardNotesToGroup + it "from notes to notes" testForwardNotesToNotes -- TODO forward between different folders when supported + describe "interactions with forwarded messages" $ do + it "preserve original forward info" testForwardPreserveInfo + it "received forwarded message is saved with new forward info" testForwardRcvMsgNewInfo + it "quoted message is not included" testForwardQuotedMsg + it "editing is prohibited" testForwardEditProhibited + it "delete for other" testForwardDeleteForOther + describe "forward files" $ do + it "from contact to contact" testForwardFileNoFilesFolder + it "with relative paths: from contact to contact" testForwardFileContactToContact + it "with relative paths: from group to notes" testForwardFileGroupToNotes + it "with relative paths: from notes to group" testForwardFileNotesToGroup + +testForwardContactToContact :: HasCallStack => FilePath -> IO () +testForwardContactToContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + connectUsers bob cath + + alice #> "@bob hi" + bob <# "alice> hi" + msgId <- lastItemId alice + bob #> "@alice hey" + alice <# "bob> hey" + + alice ##> ("/_forward @3 @2 " <> msgId) + alice <# "@cath <- you @bob" + alice <## " hi" + cath <# "alice> -> forwarded" + cath <## " hi" + + alice `send` "@cath <- @bob hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + + -- read chat + alice ##> "/tail @cath 2" + alice <# "@cath <- you @bob" + alice <## " hi" + alice <# "@cath <- @bob" + alice <## " hey" + + cath ##> "/tail @alice 2" + cath <# "alice> -> forwarded" + cath <## " hi" + cath <# "alice> -> forwarded" + cath <## " hey" + + -- item info + alice ##> "/item info @cath hey" + alice <##. "sent at: " + alice <## "message history:" + alice .<## ": hey" + alice <##. "forwarded from: @bob, chat item id:" + +testForwardContactToGroup :: HasCallStack => FilePath -> IO () +testForwardContactToGroup = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + createGroup2 "team" alice cath + + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + + alice `send` "#team <- @bob hi" + alice <# "#team <- you @bob" + alice <## " hi" + cath <# "#team alice> -> forwarded" + cath <## " hi" + + alice `send` "#team <- @bob hey" + alice <# "#team <- @bob" + alice <## " hey" + cath <# "#team alice> -> forwarded" + cath <## " hey" + +testForwardContactToNotes :: HasCallStack => FilePath -> IO () +testForwardContactToNotes = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createCCNoteFolder alice + connectUsers alice bob + + alice #> "@bob hi" + bob <# "alice> hi" + bob #> "@alice hey" + alice <# "bob> hey" + + alice `send` "* <- @bob hi" + alice <# "* <- you @bob" + alice <## " hi" + + alice `send` "* <- @bob hey" + alice <# "* <- @bob" + alice <## " hey" + +testForwardGroupToContact :: HasCallStack => FilePath -> IO () +testForwardGroupToContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + connectUsers alice cath + + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + alice `send` "@cath <- #team hi" + alice <# "@cath <- you #team" + alice <## " hi" + cath <# "alice> -> forwarded" + cath <## " hi" + + alice `send` "@cath <- #team @bob hey" + alice <# "@cath <- #team" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + +testForwardGroupToGroup :: HasCallStack => FilePath -> IO () +testForwardGroupToGroup = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup2 "team" alice bob + createGroup2 "club" alice cath + + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + threadDelay 1000000 + + alice `send` "#club <- #team hi" + alice <# "#club <- you #team" + alice <## " hi" + cath <# "#club alice> -> forwarded" + cath <## " hi" + + threadDelay 1000000 + + alice `send` "#club <- #team hey" + alice <# "#club <- #team" + alice <## " hey" + cath <# "#club alice> -> forwarded" + cath <## " hey" + + -- read chat + alice ##> "/tail #club 2" + alice <# "#club <- you #team" + alice <## " hi" + alice <# "#club <- #team" + alice <## " hey" + + cath ##> "/tail #club 2" + cath <# "#club alice> -> forwarded" + cath <## " hi" + cath <# "#club alice> -> forwarded" + cath <## " hey" + +testForwardGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardGroupToNotes = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + createCCNoteFolder alice + createGroup2 "team" alice bob + + alice #> "#team hi" + bob <# "#team alice> hi" + bob #> "#team hey" + alice <# "#team bob> hey" + + alice `send` "* <- #team hi" + alice <# "* <- you #team" + alice <## " hi" + + alice `send` "* <- #team hey" + alice <# "* <- #team" + alice <## " hey" + +testForwardNotesToContact :: HasCallStack => FilePath -> IO () +testForwardNotesToContact = + testChat2 aliceProfile cathProfile $ + \alice cath -> do + createCCNoteFolder alice + connectUsers alice cath + + alice /* "hi" + + alice `send` "@cath <- * hi" + alice <# "@cath hi" + cath <# "alice> hi" + +testForwardNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardNotesToGroup = + testChat2 aliceProfile cathProfile $ + \alice cath -> do + createCCNoteFolder alice + createGroup2 "team" alice cath + + alice /* "hi" + + alice `send` "#team <- * hi" + alice <# "#team hi" + cath <# "#team alice> hi" + +testForwardNotesToNotes :: HasCallStack => FilePath -> IO () +testForwardNotesToNotes tmp = + withNewTestChat tmp "alice" aliceProfile $ \alice -> do + createCCNoteFolder alice + + alice /* "hi" + + alice `send` "* <- * hi" + alice <# "* hi" + + alice ##> "/tail * 2" + alice <# "* hi" + alice <# "* hi" + +testForwardPreserveInfo :: HasCallStack => FilePath -> IO () +testForwardPreserveInfo = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createCCNoteFolder alice + connectUsers alice bob + connectUsers alice cath + createGroup2 "team" alice dan + + bob #> "@alice hey" + alice <# "bob> hey" + + alice `send` "* <- @bob hey" + alice <# "* <- @bob" + alice <## " hey" + + alice `send` "@cath <- * hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + + alice `send` "#team <- @cath hey" + alice <# "#team <- @bob" + alice <## " hey" + dan <# "#team alice> -> forwarded" + dan <## " hey" + +testForwardRcvMsgNewInfo :: HasCallStack => FilePath -> IO () +testForwardRcvMsgNewInfo = + testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + connectUsers bob dan + createCCNoteFolder alice + connectUsers alice bob + connectUsers alice cath + + dan #> "@bob hey" + bob <# "dan> hey" + + bob `send` "@alice <- @dan hey" + bob <# "@alice <- @dan" + bob <## " hey" + alice <# "bob> -> forwarded" + alice <## " hey" + + alice `send` "* <- @bob hey" + alice <# "* <- @bob" + alice <## " hey" + + alice `send` "@cath <- * hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + +testForwardQuotedMsg :: HasCallStack => FilePath -> IO () +testForwardQuotedMsg = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + + alice #> "@bob hi" + bob <# "alice> hi" + bob `send` "> @alice (hi) hey" + bob <# "@alice > hi" + bob <## " hey" + alice <# "bob> > hi" + alice <## " hey" + + alice `send` "@cath <- @bob hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + + -- read chat + alice ##> "/tail @cath 1" + alice <# "@cath <- @bob" + alice <## " hey" + + cath ##> "/tail @alice 1" + cath <# "alice> -> forwarded" + cath <## " hey" + +testForwardEditProhibited :: HasCallStack => FilePath -> IO () +testForwardEditProhibited = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + + bob #> "@alice hey" + alice <# "bob> hey" + + alice `send` "@cath <- @bob hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + + msgId <- lastItemId alice + alice ##> ("/_update item @3 " <> msgId <> " text hey edited") + alice <## "cannot update this item" + +testForwardDeleteForOther :: HasCallStack => FilePath -> IO () +testForwardDeleteForOther = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + connectUsers alice bob + connectUsers alice cath + + bob #> "@alice hey" + alice <# "bob> hey" + + alice `send` "@cath <- @bob hey" + alice <# "@cath <- @bob" + alice <## " hey" + cath <# "alice> -> forwarded" + cath <## " hey" + + msgId <- lastItemId alice + alice ##> ("/_delete item @3 " <> msgId <> " broadcast") + alice <## "message marked deleted" + cath <# "alice> [marked deleted] hey" + +testForwardFileNoFilesFolder :: HasCallStack => FilePath -> IO () +testForwardFileNoFilesFolder = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + connectUsers alice bob + connectUsers bob cath + + -- send original file + alice ##> "/_send @2 json {\"filePath\": \"./tests/fixtures/test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}" + alice <# "@bob hi" + alice <# "/f @bob ./tests/fixtures/test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> hi" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + + bob ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for bob", + bob + <### [ "saving file 1 from alice to ./tests/tmp/test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + ] + bob <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/test.pdf" + dest `shouldBe` src + + -- forward file + bob `send` "@cath <- @alice hi" + bob <# "@cath <- @alice" + bob <## " hi" + bob <# "/f @cath ./tests/tmp/test.pdf" + bob <## "use /fc 2 to cancel sending" + cath <# "bob> -> forwarded" + cath <## " hi" + cath <# "bob> sends file test.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + + cath ##> "/fr 1 ./tests/tmp" + concurrentlyN_ + [ bob <## "completed uploading file 2 (test.pdf) for cath", + cath + <### [ "saving file 1 from bob to ./tests/tmp/test_1.pdf", + "started receiving file 1 (test.pdf) from bob" + ] + ] + cath <## "completed receiving file 1 (test.pdf) from bob" + + dest2 <- B.readFile "./tests/tmp/test_1.pdf" + dest2 `shouldBe` src + +testForwardFileContactToContact :: HasCallStack => FilePath -> IO () +testForwardFileContactToContact = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" + setRelativePaths bob "./tests/tmp/bob_files" "./tests/tmp/bob_xftp" + setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" + connectUsers alice bob + connectUsers bob cath + + -- send original file + alice ##> "/_send @2 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}" + alice <# "@bob hi" + alice <# "/f @bob test.pdf" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> hi" + bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + + bob ##> "/fr 1" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for bob", + bob + <### [ "saving file 1 from alice to test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + ] + bob <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/bob_files/test.pdf" + dest `shouldBe` src + + -- forward file + bob `send` "@cath <- @alice hi" + bob <# "@cath <- @alice" + bob <## " hi" + bob <# "/f @cath test_1.pdf" + bob <## "use /fc 2 to cancel sending" + cath <# "bob> -> forwarded" + cath <## " hi" + cath <# "bob> sends file test_1.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + + cath ##> "/fr 1" + concurrentlyN_ + [ bob <## "completed uploading file 2 (test_1.pdf) for cath", + cath + <### [ "saving file 1 from bob to test_1.pdf", + "started receiving file 1 (test_1.pdf) from bob" + ] + ] + cath <## "completed receiving file 1 (test_1.pdf) from bob" + + src2 <- B.readFile "./tests/tmp/bob_files/test_1.pdf" + src2 `shouldBe` dest + dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 `shouldBe` src2 + + -- deleting original file doesn't delete forwarded file + checkActionDeletesFile "./tests/tmp/bob_files/test.pdf" $ do + bob ##> "/clear alice" + bob <## "alice: all messages are removed locally ONLY" + fwdFileExists <- doesFileExist "./tests/tmp/bob_files/test_1.pdf" + fwdFileExists `shouldBe` True + +testForwardFileGroupToNotes :: HasCallStack => FilePath -> IO () +testForwardFileGroupToNotes = + testChat2 aliceProfile cathProfile $ + \alice cath -> withXFTPServer $ do + setRelativePaths alice "./tests/fixtures" "./tests/tmp/alice_xftp" + setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" + createGroup2 "team" alice cath + createCCNoteFolder cath + + -- send original file + alice ##> "/_send #1 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}" + alice <# "#team hi" + alice <# "/f #team test.pdf" + alice <## "use /fc 1 to cancel sending" + cath <# "#team alice> hi" + cath <# "#team alice> sends file test.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + + cath ##> "/fr 1" + concurrentlyN_ + [ alice <## "completed uploading file 1 (test.pdf) for #team", + cath + <### [ "saving file 1 from alice to test.pdf", + "started receiving file 1 (test.pdf) from alice" + ] + ] + cath <## "completed receiving file 1 (test.pdf) from alice" + + src <- B.readFile "./tests/fixtures/test.pdf" + dest <- B.readFile "./tests/tmp/cath_files/test.pdf" + dest `shouldBe` src + + -- forward file + cath `send` "* <- #team hi" + cath <# "* <- #team" + cath <## " hi" + cath <# "* file 2 (test_1.pdf)" + + dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 `shouldBe` dest + + -- deleting original file doesn't delete forwarded file + checkActionDeletesFile "./tests/tmp/cath_files/test.pdf" $ do + cath ##> "/clear #team" + cath <## "#team: all messages are removed locally ONLY" + fwdFileExists <- doesFileExist "./tests/tmp/cath_files/test_1.pdf" + fwdFileExists `shouldBe` True + +testForwardFileNotesToGroup :: HasCallStack => FilePath -> IO () +testForwardFileNotesToGroup = + testChat2 aliceProfile cathProfile $ + \alice cath -> withXFTPServer $ do + setRelativePaths alice "./tests/tmp/alice_files" "./tests/tmp/alice_xftp" + setRelativePaths cath "./tests/tmp/cath_files" "./tests/tmp/cath_xftp" + copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_files/test.pdf" + createCCNoteFolder alice + createGroup2 "team" alice cath + + -- create original file + alice ##> "/_create *1 json {\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"hi\"}}" + alice <# "* hi" + alice <# "* file 1 (test.pdf)" + + -- forward file + alice `send` "#team <- * hi" + alice <# "#team hi" + alice <# "/f #team test_1.pdf" + alice <## "use /fc 2 to cancel sending" + cath <# "#team alice> hi" + cath <# "#team alice> sends file test_1.pdf (266.0 KiB / 272376 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + + cath ##> "/fr 1" + concurrentlyN_ + [ alice <## "completed uploading file 2 (test_1.pdf) for #team", + cath + <### [ "saving file 1 from alice to test_1.pdf", + "started receiving file 1 (test_1.pdf) from alice" + ] + ] + cath <## "completed receiving file 1 (test_1.pdf) from alice" + + src <- B.readFile "./tests/tmp/alice_files/test.pdf" + src2 <- B.readFile "./tests/tmp/alice_files/test_1.pdf" + src2 `shouldBe` src + dest2 <- B.readFile "./tests/tmp/cath_files/test_1.pdf" + dest2 `shouldBe` src2 + + -- deleting original file doesn't delete forwarded file + checkActionDeletesFile "./tests/tmp/alice_files/test.pdf" $ do + alice ##> "/clear *" + alice <## "notes: all messages are removed" + fwdFileExists <- doesFileExist "./tests/tmp/alice_files/test_1.pdf" + fwdFileExists `shouldBe` True diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 77bac11145..4108f799df 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -15,7 +15,8 @@ import qualified Data.Text as T import Simplex.Chat.Controller (ChatConfig (..)) import Simplex.Chat.Protocol (supportedChatVRange) import Simplex.Chat.Store (agentStoreFile, chatStoreFile) -import Simplex.Chat.Types (GroupMemberRole (..), VersionRangeChat) +import Simplex.Chat.Types (VersionRangeChat) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import Simplex.Messaging.Crypto.Ratchet (pattern PQSupportOff) import System.Directory (copyFile) @@ -1509,6 +1510,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "Message reactions: on" alice <## "Voice messages: on" alice <## "Files and media: on" + alice <## "SimpleX links: on" alice <## "Recent history: on" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do @@ -2501,6 +2503,7 @@ testPlanHostContactDeletedGroupLinkKnown = testPlanGroupLinkOwn :: HasCallStack => FilePath -> IO () testPlanGroupLinkOwn tmp = withNewTestChatCfg tmp testCfgGroupLinkViaContact "alice" aliceProfile $ \alice -> do + threadDelay 100000 alice ##> "/g team" alice <## "group #team is created" alice <## "to add members use /a team or /create link #team" @@ -5184,7 +5187,11 @@ testGroupHistoryWelcomeMessage = cath ##> "/_get chat #1 count=100" r <- chat <$> getTermLine cath - r `shouldContain` [(0, "hello"), (0, "hey!"), (0, "welcome to team")] + -- sometimes there are "connected" and feature items in between, + -- so we filter them out; `shouldContain` then checks order is correct + let expected = [(0, "hello"), (0, "hey!"), (0, "welcome to team")] + r' = filter (`elem` expected) r + r' `shouldContain` expected -- message delivery works after sending history alice #> "#team 1" diff --git a/tests/ChatTests/Local.hs b/tests/ChatTests/Local.hs index 40ebe51b83..5562d517ac 100644 --- a/tests/ChatTests/Local.hs +++ b/tests/ChatTests/Local.hs @@ -150,6 +150,7 @@ testFiles tmp = withNewTestChat tmp "alice" aliceProfile $ \alice -> do doesFileExist stored `shouldReturn` True alice ##> "/clear *" + alice <## "notes: all messages are removed" alice ##> "/fs 1" alice <## "file 1 not found" alice ##> "/tail" @@ -180,6 +181,7 @@ testOtherFiles = bob ##> "/tail *" bob <# "* test" bob ##> "/clear *" + bob <## "notes: all messages are removed" bob ##> "/tail *" bob ##> "/fs 1" bob <## "receiving file 1 (test.jpg) complete, path: test.jpg" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 7996fde3ad..a6cc491456 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -13,7 +13,8 @@ import qualified Data.Attoparsec.ByteString.Char8 as A import qualified Data.ByteString.Char8 as B import qualified Data.Text as T import Simplex.Chat.Store.Shared (createContact) -import Simplex.Chat.Types (ConnStatus (..), GroupMemberRole (..), Profile (..)) +import Simplex.Chat.Types (ConnStatus (..), Profile (..)) +import Simplex.Chat.Types.Shared (GroupMemberRole (..)) import Simplex.Messaging.Encoding.String (StrEncoding (..)) import System.Directory (copyFile, createDirectoryIfMissing) import Test.Hspec hiding (it) @@ -68,6 +69,10 @@ chatProfileTests = do it "enable timed messages in group" testEnableTimedMessagesGroup xit'' "timed messages enabled globally, contact turns on" testTimedMessagesEnabledGlobally it "update multiple user preferences for multiple contacts" testUpdateMultipleUserPrefs + describe "group preferences for specific member role" $ do + it "direct messages" testGroupPrefsDirectForRole + it "files & media" testGroupPrefsFilesForRole + it "SimpleX links" testGroupPrefsSimplexLinksForRole testUpdateProfile :: HasCallStack => FilePath -> IO () testUpdateProfile = @@ -179,10 +184,12 @@ testMultiWordProfileNames = alice <# "#'Our Team' 'Bob James'> hi" cath <# "#'Our Team' 'Bob James'> hi" alice `send` "@'Cath Johnson' hello" - alice <## "member #'Our Team' 'Cath Johnson' does not have direct connection, creating" - alice <## "contact for member #'Our Team' 'Cath Johnson' is created" - alice <## "sent invitation to connect directly to member #'Our Team' 'Cath Johnson'" - alice <# "@'Cath Johnson' hello" + alice + <### [ "member #'Our Team' 'Cath Johnson' does not have direct connection, creating", + "contact for member #'Our Team' 'Cath Johnson' is created", + "sent invitation to connect directly to member #'Our Team' 'Cath Johnson'", + WithTime "@'Cath Johnson' hello" + ] cath <## "#'Our Team' 'Alice Jones' is creating direct contact 'Alice Jones' with you" cath <# "'Alice Jones'> hello" cath <## "'Alice Jones': contact is connected" @@ -1901,3 +1908,122 @@ testUpdateMultipleUserPrefs = testChat3 aliceProfile bobProfile cathProfile $ alice #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(1, "hi bob"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "hi cath"), (1, "Full deletion: enabled for contact"), (1, "Message reactions: off")]) + +testGroupPrefsDirectForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsDirectForRole = testChat4 aliceProfile bobProfile cathProfile danProfile $ + \alice bob cath dan -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice ##> "/set direct #team on owner" + alice <## "updated group preferences:" + alice <## "Direct messages: on for owners" + directForOwners bob + directForOwners cath + threadDelay 1000000 + bob ##> "@cath hello again" + bob <## "bad chat command: direct messages not allowed" + (cath "/j #team" + concurrentlyN_ + [ cath <## "#team: dan joined the group", + do + dan <## "#team: you joined the group" + dan + <### [ "#team: member alice (Alice) is connected", + "#team: member bob (Bob) is connected" + ], + do + alice <## "#team: cath added dan (Daniel) to the group (connecting...)" + alice <## "#team: new member dan is connected", + do + bob <## "#team: cath added dan (Daniel) to the group (connecting...)" + bob <## "#team: new member dan is connected" + ] + -- dan cannot send direct messages to alice (owner) + dan ##> "@alice hello alice" + dan <## "bad chat command: direct messages not allowed" + (alice hello dan" + dan <## "alice (Alice): contact is connected" + -- and now dan can too + dan #> "@alice hi alice" + alice <# "dan> hi alice" + where + directForOwners :: HasCallStack => TestCC -> IO () + directForOwners cc = do + cc <## "alice updated group #team:" + cc <## "updated group preferences:" + cc <## "Direct messages: on for owners" + +testGroupPrefsFilesForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsFilesForRole = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + alice #$> ("/_files_folder ./tests/tmp/alice", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/bob", id, "ok") + createDirectoryIfMissing True "./tests/tmp/alice" + createDirectoryIfMissing True "./tests/tmp/bob" + copyFile "./tests/fixtures/test.txt" "./tests/tmp/alice/test1.txt" + copyFile "./tests/fixtures/test.txt" "./tests/tmp/bob/test2.txt" + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice ##> "/set files #team on owner" + alice <## "updated group preferences:" + alice <## "Files and media: on for owners" + filesForOwners bob + filesForOwners cath + threadDelay 1000000 + bob ##> "/f #team test2.txt" + bob <## "bad chat command: feature not allowed Files and media" + (alice "/f #team test1.txt" + alice <## "use /fc 1 to cancel sending" + alice <## "completed uploading file 1 (test1.txt) for #team" + bob <# "#team alice> sends file test1.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + cath <# "#team alice> sends file test1.txt (11 bytes / 11 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + where + filesForOwners :: HasCallStack => TestCC -> IO () + filesForOwners cc = do + cc <## "alice updated group #team:" + cc <## "updated group preferences:" + cc <## "Files and media: on for owners" + +testGroupPrefsSimplexLinksForRole :: HasCallStack => FilePath -> IO () +testGroupPrefsSimplexLinksForRole = testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> withXFTPServer $ do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice ##> "/set links #team on owner" + alice <## "updated group preferences:" + alice <## "SimpleX links: on for owners" + linksForOwners bob + linksForOwners cath + threadDelay 1000000 + bob ##> "/c" + inv <- getInvitation bob + bob ##> ("#team " <> inv) + bob <## "bad chat command: feature not allowed SimpleX links" + (alice ("#team " <> inv) + bob <# ("#team alice> " <> inv) + cath <# ("#team alice> " <> inv) + where + linksForOwners :: HasCallStack => TestCC -> IO () + linksForOwners cc = do + cc <## "alice updated group #team:" + cc <## "updated group preferences:" + cc <## "SimpleX links: on for owners" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 3b0748e7d0..aa6579efee 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -30,6 +30,7 @@ import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.FileTransfer.Client.Main (xftpClientCLI) import Simplex.Messaging.Agent.Store.SQLite (maybeFirstRow, withTransaction) import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB @@ -315,6 +316,7 @@ groupFeatures'' = ((0, "Message reactions: on"), Nothing, Nothing), ((0, "Voice messages: on"), Nothing, Nothing), ((0, "Files and media: on"), Nothing, Nothing), + ((0, "SimpleX links: on"), Nothing, Nothing), ((0, "Recent history: on"), Nothing, Nothing) ] @@ -721,3 +723,12 @@ linkAnotherSchema link xftpCLI :: [String] -> IO [String] xftpCLI params = lines <$> capture_ (withArgs params xftpClientCLI) + +setRelativePaths :: HasCallStack => TestCC -> String -> String -> IO () +setRelativePaths cc filesFolder tempFolder = do + cc ##> "/_stop" + cc <## "chat stopped" + cc #$> ("/_files_folder " <> filesFolder, id, "ok") + cc #$> ("/_temp_folder " <> tempFolder, id, "ok") + cc ##> "/_start" + cc <## "chat started" diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 082af825e5..18fb677be2 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -12,6 +12,7 @@ import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences +import Simplex.Chat.Types.Shared import Simplex.Messaging.Agent.Protocol import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.Ratchet @@ -99,7 +100,7 @@ testChatPreferences :: Maybe Preferences testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn}, files = Nothing, fullDelete = Nothing, history = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences} diff --git a/website/langs/en.json b/website/langs/en.json index 10db2dd4ed..32db4f3c47 100644 --- a/website/langs/en.json +++ b/website/langs/en.json @@ -235,6 +235,7 @@ "docs-dropdown-7": "Translate SimpleX Chat", "docs-dropdown-8": "SimpleX Directory Service", "docs-dropdown-9": "Downloads", + "docs-dropdown-10": "Transparency", "newer-version-of-eng-msg": "There is a newer version of this page in English.", "click-to-see": "Click to see", "menu": "Menu", diff --git a/website/src/_data/docs_dropdown.json b/website/src/_data/docs_dropdown.json index a8c6e634b2..610be49221 100644 --- a/website/src/_data/docs_dropdown.json +++ b/website/src/_data/docs_dropdown.json @@ -35,6 +35,10 @@ { "title": "docs-dropdown-9", "url": "/downloads/" + }, + { + "title": "docs-dropdown-10", + "url": "/transparency/" } ] } \ No newline at end of file diff --git a/website/src/_data/docs_sidebar.json b/website/src/_data/docs_sidebar.json index 857684661b..a2b15de7e4 100644 --- a/website/src/_data/docs_sidebar.json +++ b/website/src/_data/docs_sidebar.json @@ -26,7 +26,8 @@ "TRANSLATIONS.md", "WEBRTC.md", "XFTP-SERVER.md", - "DOWNLOADS.md" + "DOWNLOADS.md", + "TRANSPARENCY.md" ] }, { diff --git a/website/src/_includes/blog_previews/20240404.html b/website/src/_includes/blog_previews/20240404.html new file mode 100644 index 0000000000..b2407b3ab2 --- /dev/null +++ b/website/src/_includes/blog_previews/20240404.html @@ -0,0 +1,8 @@ +By Esra'a al Shafei + +

Transitioning from a lifelong career dedicated to nonprofits, +including Board roles at organizations like the Wikimedia Foundation, Access Now and Tor, +my decision to join SimpleX Chat may come as a surprise to some. +But, as I step into this new chapter, I want to share the insights and convictions +that have guided me here, shedding light on what I think sets SimpleX Chat apart +and why this move feels like an essential learning opportunity.

diff --git a/website/src/_includes/blog_previews/20240416.html b/website/src/_includes/blog_previews/20240416.html new file mode 100644 index 0000000000..6c6edfb6c1 --- /dev/null +++ b/website/src/_includes/blog_previews/20240416.html @@ -0,0 +1,5 @@ +By Esra'a al Shafei + +

It's important not to be complacent with the current standards of messaging, + where metadata aggregation is still normalized in apps falsely and dangerously marketed as "private". + This is a post exploring the fundamental differences between privacy and security.

\ No newline at end of file diff --git a/website/src/call/call.js b/website/src/call/call.js index b247431f4b..8104470686 100644 --- a/website/src/call/call.js +++ b/website/src/call/call.js @@ -24,8 +24,9 @@ var TransformOperation; let activeCall; const processCommand = (function () { const defaultIceServers = [ + { urls: ["stuns:stun.simplex.im:443"] }, { urls: ["stun:stun.simplex.im:443"] }, - { urls: ["turn:turn.simplex.im:443"], username: "private", credential: "yleob6AVkiNI87hpR94Z" }, + { urls: ["turns:turn.simplex.im:443"], username: "private2", credential: "Hxuq2QxUjnhj96Zq2r4HjqHRj" }, ]; function getCallConfig(encodedInsertableStreams, iceServers, relay) { return {