mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-10 10:01:50 +00:00
Merge branch 'master' into group-knocking
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import UIKit
|
||||
import Dispatch
|
||||
import BackgroundTasks
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
@preconcurrency import SimpleXChat
|
||||
|
||||
private var chatController: chat_ctrl?
|
||||
|
||||
@@ -91,7 +91,7 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? =
|
||||
logger.debug("chatSendCmd \(cmd.cmdType)")
|
||||
}
|
||||
let start = Date.now
|
||||
let resp = bgTask
|
||||
let resp: ChatResponse = bgTask
|
||||
? withBGTask(bgDelay: bgDelay) { sendSimpleXCmd(cmd, ctrl) }
|
||||
: sendSimpleXCmd(cmd, ctrl)
|
||||
if log {
|
||||
@@ -115,7 +115,7 @@ func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil
|
||||
func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? {
|
||||
await withCheckedContinuation { cont in
|
||||
_ = withBGTask(bgDelay: msgDelay) { () -> ChatResponse? in
|
||||
let resp = recvSimpleXMsg(ctrl)
|
||||
let resp: ChatResponse? = recvSimpleXMsg(ctrl)
|
||||
cont.resume(returning: resp)
|
||||
return resp
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func chatRecvMsg(_ ctrl: chat_ctrl? = nil) async -> ChatResponse? {
|
||||
}
|
||||
|
||||
func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
|
||||
let r = chatSendCmdSync(.showActiveUser, ctrl)
|
||||
let r: ChatResponse = chatSendCmdSync(.showActiveUser, ctrl)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
case .chatCmdError(_, .error(.noActiveUser)): return nil
|
||||
@@ -132,7 +132,7 @@ func apiGetActiveUser(ctrl: chat_ctrl? = nil) throws -> User? {
|
||||
}
|
||||
|
||||
func apiCreateActiveUser(_ p: Profile?, pastTimestamp: Bool = false, ctrl: chat_ctrl? = nil) throws -> User {
|
||||
let r = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
|
||||
let r: ChatResponse = chatSendCmdSync(.createActiveUser(profile: p, pastTimestamp: pastTimestamp), ctrl)
|
||||
if case let .activeUser(user) = r { return user }
|
||||
throw r
|
||||
}
|
||||
@@ -199,19 +199,19 @@ func apiUnmuteUser(_ userId: Int64) async throws -> User {
|
||||
}
|
||||
|
||||
func setUserPrivacy_(_ cmd: ChatCommand) async throws -> User {
|
||||
let r = await chatSendCmd(cmd)
|
||||
let r: ChatResponse = await chatSendCmd(cmd)
|
||||
if case let .userPrivacy(_, updatedUser) = r { return updatedUser }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) async throws {
|
||||
let r = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
|
||||
let r: ChatResponse = await chatSendCmd(.apiDeleteUser(userId: userId, delSMPQueues: delSMPQueues, viewPwd: viewPwd))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
|
||||
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||
let r: ChatResponse = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -890,7 +890,7 @@ func apiConnect_(incognito: Bool, connLink: CreatedConnLink) async -> ((ConnReqT
|
||||
logger.error("apiConnect: no current user")
|
||||
return (nil, nil)
|
||||
}
|
||||
let r = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
|
||||
let r: ChatResponse = await chatSendCmd(.apiConnect(userId: userId, incognito: incognito, connLink: connLink))
|
||||
let m = ChatModel.shared
|
||||
switch r {
|
||||
case let .sentConfirmation(_, connection):
|
||||
@@ -1281,7 +1281,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
|
||||
case let .rcvFileAccepted(_, chatItem):
|
||||
await chatItemSimpleUpdate(user, chatItem)
|
||||
default:
|
||||
if let chatError = chatError(r) {
|
||||
if let chatError = r.chatErrorType {
|
||||
switch chatError {
|
||||
case let .fileNotApproved(fileId, unknownServers):
|
||||
fileIdsToApprove.append(fileId)
|
||||
@@ -1348,7 +1348,7 @@ func receiveFiles(user: any UserLike, fileIds: [Int64], userApprovedRelays: Bool
|
||||
)
|
||||
}
|
||||
default:
|
||||
if let chatError = chatError(errorResponse) {
|
||||
if let chatError = errorResponse.chatErrorType {
|
||||
switch chatError {
|
||||
case .fileCancelled, .fileAlreadyReceiving:
|
||||
logger.debug("receiveFiles ignoring FileCancelled or FileAlreadyReceiving error")
|
||||
@@ -1635,7 +1635,7 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
|
||||
|
||||
// use ChatModel's loadGroupMembers from views
|
||||
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
|
||||
let r = await chatSendCmd(.apiListMembers(groupId: groupId))
|
||||
let r: ChatResponse = await chatSendCmd(.apiListMembers(groupId: groupId))
|
||||
if case let .groupMembers(_, group) = r { return group.members }
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -752,7 +752,7 @@ private class MigrationChatReceiver {
|
||||
|
||||
func receiveMsgLoop() async {
|
||||
// TODO use function that has timeout
|
||||
if let msg = await chatRecvMsg(ctrl) {
|
||||
if let msg: ChatResponse = await chatRecvMsg(ctrl) {
|
||||
Task {
|
||||
await TerminalItems.shared.add(.resp(.now, msg))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// APITypes.swift
|
||||
// SimpleX
|
||||
//
|
||||
// Created by EP on 01/05/2025.
|
||||
// Copyright © 2025 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SimpleXChat
|
||||
|
||||
enum NSEChatCommand: ChatCmdProtocol {
|
||||
case showActiveUser
|
||||
case startChat(mainApp: Bool, enableSndFiles: Bool)
|
||||
case apiActivateChat(restoreChat: Bool)
|
||||
case apiSuspendChat(timeoutMicroseconds: Int)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String)
|
||||
case apiSetEncryptLocalFiles(enable: Bool)
|
||||
case apiGetNtfConns(nonce: String, encNtfInfo: String)
|
||||
case apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq])
|
||||
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
|
||||
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case .showActiveUser: return "/u"
|
||||
case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))"
|
||||
case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))"
|
||||
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder):
|
||||
return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))"
|
||||
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
|
||||
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
|
||||
case let .apiGetConnNtfMessages(connMsgReqs): return "/_ntf conn messages \(connMsgReqs.map { $0.cmdString }.joined(separator: ","))"
|
||||
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
|
||||
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
|
||||
}
|
||||
}
|
||||
|
||||
private func onOffParam(_ param: String, _ b: Bool?) -> String {
|
||||
if let b = b {
|
||||
" \(param)=\(onOff(b))"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum NSEChatResponse: Decodable, Error, ChatRespProtocol {
|
||||
case response(type: String, json: String)
|
||||
case activeUser(user: User)
|
||||
case chatStarted
|
||||
case chatRunning
|
||||
case chatSuspended
|
||||
case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?)
|
||||
case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest)
|
||||
case newChatItems(user: UserRef, chatItems: [AChatItem])
|
||||
case rcvFileAccepted(user: UserRef, chatItem: AChatItem)
|
||||
case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer)
|
||||
case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer)
|
||||
case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer)
|
||||
case callInvitation(callInvitation: RcvCallInvitation)
|
||||
case ntfConns(ntfConns: [NtfConn])
|
||||
case connNtfMessages(receivedMsgs: [NtfMsgInfo?])
|
||||
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo)
|
||||
case cmdOk(user_: UserRef?)
|
||||
case chatCmdError(user_: UserRef?, chatError: ChatError)
|
||||
case chatError(user_: UserRef?, chatError: ChatError)
|
||||
|
||||
var responseType: String {
|
||||
switch self {
|
||||
case let .response(type, _): "* \(type)"
|
||||
case .activeUser: "activeUser"
|
||||
case .chatStarted: "chatStarted"
|
||||
case .chatRunning: "chatRunning"
|
||||
case .chatSuspended: "chatSuspended"
|
||||
case .contactConnected: "contactConnected"
|
||||
case .receivedContactRequest: "receivedContactRequest"
|
||||
case .newChatItems: "newChatItems"
|
||||
case .rcvFileAccepted: "rcvFileAccepted"
|
||||
case .rcvFileSndCancelled: "rcvFileSndCancelled"
|
||||
case .sndFileComplete: "sndFileComplete"
|
||||
case .sndFileRcvCancelled: "sndFileRcvCancelled"
|
||||
case .callInvitation: "callInvitation"
|
||||
case .ntfConns: "ntfConns"
|
||||
case .connNtfMessages: "connNtfMessages"
|
||||
case .ntfMessage: "ntfMessage"
|
||||
case .cmdOk: "cmdOk"
|
||||
case .chatCmdError: "chatCmdError"
|
||||
case .chatError: "chatError"
|
||||
}
|
||||
}
|
||||
|
||||
var details: String {
|
||||
switch self {
|
||||
case let .response(_, json): return json
|
||||
case let .activeUser(user): return String(describing: user)
|
||||
case .chatStarted: return noDetails
|
||||
case .chatRunning: return noDetails
|
||||
case .chatSuspended: return noDetails
|
||||
case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact))
|
||||
case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest))
|
||||
case let .newChatItems(u, chatItems):
|
||||
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
|
||||
return withUser(u, itemsString)
|
||||
case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
|
||||
case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem))
|
||||
case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem))
|
||||
case let .callInvitation(inv): return String(describing: inv)
|
||||
case let .ntfConns(ntfConns): return String(describing: ntfConns)
|
||||
case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))"
|
||||
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
|
||||
case .cmdOk: return noDetails
|
||||
case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .chatError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
}
|
||||
}
|
||||
|
||||
var noDetails: String { "\(responseType): no details" }
|
||||
|
||||
static func chatResponse(_ s: String) -> NSEChatResponse {
|
||||
let d = s.data(using: .utf8)!
|
||||
// TODO is there a way to do it without copying the data? e.g:
|
||||
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
|
||||
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
|
||||
do {
|
||||
let r = try jsonDecoder.decode(APIResponse<NSEChatResponse>.self, from: d)
|
||||
return r.resp
|
||||
} catch {
|
||||
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
var type: String?
|
||||
var json: String?
|
||||
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
|
||||
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 {
|
||||
type = jResp.allKeys[0] as? String
|
||||
if jResp.count == 2 && type == "_owsf" {
|
||||
type = jResp.allKeys[1] as? String
|
||||
}
|
||||
if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
} else if type == "chatError" {
|
||||
if let jError = jResp["chatError"] as? NSDictionary {
|
||||
return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
json = serializeJSON(j, options: .prettyPrinted)
|
||||
}
|
||||
return NSEChatResponse.response(type: type ?? "invalid", json: json ?? s)
|
||||
}
|
||||
|
||||
var chatError: ChatError? {
|
||||
switch self {
|
||||
case let .chatCmdError(_, error): error
|
||||
case let .chatError(_, error): error
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
var chatErrorType: ChatErrorType? {
|
||||
switch self {
|
||||
case let .chatCmdError(_, .error(error)): error
|
||||
case let .chatError(_, .error(error)): error
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,8 +143,7 @@ class NSEThreads {
|
||||
}
|
||||
|
||||
struct ExpectedMessage {
|
||||
var ntfConn: UserNtfConn
|
||||
var receiveConnId: String?
|
||||
var ntfConn: NtfConn
|
||||
var expectedMsgId: String?
|
||||
var allowedGetNextAttempts: Int
|
||||
var msgBestAttemptNtf: NSENotificationData?
|
||||
@@ -152,6 +151,14 @@ struct ExpectedMessage {
|
||||
var shouldProcessNtf: Bool
|
||||
var startedProcessingNewMsgs: Bool
|
||||
var semaphore: DispatchSemaphore
|
||||
|
||||
var connMsgReq: ConnMsgReq? {
|
||||
if let expectedMsg_ = ntfConn.expectedMsg_ {
|
||||
ConnMsgReq(msgConnId: ntfConn.agentConnId, msgDbQueueId: ntfConn.agentDbQueueId, msgTs: expectedMsg_.msgTs)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification service extension creates a new instance of the class and calls didReceive for each notification.
|
||||
@@ -172,20 +179,27 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
|
||||
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
logger.debug("DEBUGGING: NotificationService.didReceive")
|
||||
let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
|
||||
setServiceBestAttemptNtf(ntf)
|
||||
let receivedNtf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
|
||||
setServiceBestAttemptNtf(receivedNtf)
|
||||
self.contentHandler = contentHandler
|
||||
registerGroupDefaults()
|
||||
let appState = appStateGroupDefault.get()
|
||||
logger.debug("NotificationService: app is \(appState.rawValue)")
|
||||
switch appState {
|
||||
case .stopped:
|
||||
// Use this block to debug notificaitons delivery in CLI, with "ejected" database and stopped chat
|
||||
// if let nrData = ntfRequestData(request) {
|
||||
// logger.debug("NotificationService get notification connections: /_ntf conns \(nrData.nonce) \(nrData.encNtfInfo)")
|
||||
// contentHandler(receivedNtf)
|
||||
// return;
|
||||
// }
|
||||
setBadgeCount()
|
||||
setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount))
|
||||
deliverBestAttemptNtf()
|
||||
contentHandler(createAppStoppedNtf(badgeCount))
|
||||
case .suspended:
|
||||
receiveNtfMessages(request, contentHandler)
|
||||
setExpirationTimer()
|
||||
receiveNtfMessages(request)
|
||||
case .suspending:
|
||||
setExpirationTimer()
|
||||
Task {
|
||||
let state: AppState = await withCheckedContinuation { cont in
|
||||
appSubscriber = appStateSubscriber { s in
|
||||
@@ -206,42 +220,55 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
logger.debug("NotificationService: app state is now \(state.rawValue)")
|
||||
if state.inactive {
|
||||
receiveNtfMessages(request, contentHandler)
|
||||
if state.inactive && self.contentHandler != nil {
|
||||
receiveNtfMessages(request)
|
||||
} else {
|
||||
deliverBestAttemptNtf()
|
||||
contentHandler(receivedNtf)
|
||||
}
|
||||
}
|
||||
case .active: contentHandler(UNMutableNotificationContent())
|
||||
case .activating: contentHandler(UNMutableNotificationContent())
|
||||
case .bgRefresh: contentHandler(UNMutableNotificationContent())
|
||||
case .active: contentHandler(receivedNtf)
|
||||
case .activating: contentHandler(receivedNtf)
|
||||
case .bgRefresh: contentHandler(receivedNtf)
|
||||
}
|
||||
}
|
||||
|
||||
func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) {
|
||||
private func setExpirationTimer() -> Void {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 30) {
|
||||
self.deliverBestAttemptNtf(urgent: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func ntfRequestData(_ request: UNNotificationRequest) -> (nonce: String, encNtfInfo: String)? {
|
||||
if let ntfData = request.content.userInfo["notificationData"] as? [AnyHashable : Any],
|
||||
let nonce = ntfData["nonce"] as? String,
|
||||
let encNtfInfo = ntfData["message"] as? String {
|
||||
(nonce, encNtfInfo)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
func receiveNtfMessages(_ request: UNNotificationRequest) {
|
||||
logger.debug("NotificationService: receiveNtfMessages")
|
||||
if case .documents = dbContainerGroupDefault.get() {
|
||||
deliverBestAttemptNtf()
|
||||
return
|
||||
}
|
||||
let userInfo = request.content.userInfo
|
||||
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
|
||||
let nonce = ntfData["nonce"] as? String,
|
||||
let encNtfInfo = ntfData["message"] as? String,
|
||||
if let nrData = ntfRequestData(request),
|
||||
// check it here again
|
||||
appStateGroupDefault.get().inactive {
|
||||
// thread is added to activeThreads tracking set here - if thread started chat it needs to be suspended
|
||||
if let t = threadId { NSEThreads.shared.startThread(t, self) }
|
||||
let dbStatus = startChat()
|
||||
if case .ok = dbStatus,
|
||||
let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) {
|
||||
let ntfConns = apiGetNtfConns(nonce: nrData.nonce, encNtfInfo: nrData.encNtfInfo) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)")
|
||||
|
||||
// logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns \(String(describing: ntfConns.map { $0.connEntity.id }))")
|
||||
for ntfConn in ntfConns {
|
||||
addExpectedMessage(ntfConn: ntfConn)
|
||||
}
|
||||
|
||||
let connIdsToGet = expectedMessages.compactMap { (id, _) in
|
||||
let connMsgReqs = expectedMessages.compactMap { (id, _) in
|
||||
let started = NSEThreads.queue.sync {
|
||||
let canStart = checkCanStart(id)
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") }
|
||||
@@ -253,7 +280,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
return canStart
|
||||
}
|
||||
if started {
|
||||
return expectedMessages[id]?.receiveConnId
|
||||
return expectedMessages[id]?.connMsgReq
|
||||
} else {
|
||||
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") }
|
||||
expectedMessages[id]?.semaphore.wait()
|
||||
@@ -264,17 +291,17 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
expectedMessages[id]?.startedProcessingNewMsgs = true
|
||||
expectedMessages[id]?.shouldProcessNtf = true
|
||||
}
|
||||
if let connId = expectedMessages[id]?.receiveConnId {
|
||||
let _ = getConnNtfMessage(connId: connId)
|
||||
if let connMsgReq = expectedMessages[id]?.connMsgReq {
|
||||
let _ = getConnNtfMessage(connMsgReq: connMsgReq)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if !connIdsToGet.isEmpty {
|
||||
if let r = apiGetConnNtfMessages(connIds: connIdsToGet) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)")
|
||||
if !connMsgReqs.isEmpty {
|
||||
if let r = apiGetConnNtfMessages(connMsgReqs: connMsgReqs) {
|
||||
logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count), expecting messages \(r.count { $0 != nil })")
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -285,18 +312,16 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
deliverBestAttemptNtf()
|
||||
}
|
||||
|
||||
func addExpectedMessage(ntfConn: UserNtfConn) {
|
||||
if let connEntity = ntfConn.connEntity_,
|
||||
let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil {
|
||||
let expectedMsgId = ntfConn.expectedMsg_?.msgId
|
||||
func addExpectedMessage(ntfConn: NtfConn) {
|
||||
let expectedMsgId = ntfConn.expectedMsg_?.msgId
|
||||
if let receiveEntityId = ntfConn.connEntity.id {
|
||||
logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)")
|
||||
expectedMessages[receiveEntityId] = ExpectedMessage(
|
||||
ntfConn: ntfConn,
|
||||
receiveConnId: connEntity.conn.agentConnId,
|
||||
expectedMsgId: expectedMsgId,
|
||||
allowedGetNextAttempts: 3,
|
||||
msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf,
|
||||
ready: false,
|
||||
msgBestAttemptNtf: defaultBestAttemptNtf(ntfConn),
|
||||
ready: ntfConn.expectedMsg_ == nil, // show defaultBestAttemptNtf(ntfConn) if there is no expected message
|
||||
shouldProcessNtf: false,
|
||||
startedProcessingNewMsgs: false,
|
||||
semaphore: DispatchSemaphore(value: 0)
|
||||
@@ -350,10 +375,10 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
self.deliverBestAttemptNtf()
|
||||
} else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId {
|
||||
} else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let connMsgReq = expectedMessages[id]?.connMsgReq {
|
||||
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message")
|
||||
expectedMessages[id]?.allowedGetNextAttempts -= 1
|
||||
if let receivedMsg = getConnNtfMessage(connId: receiveConnId) {
|
||||
if let receivedMsg = getConnNtfMessage(connMsgReq: connMsgReq) {
|
||||
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
|
||||
} else {
|
||||
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
|
||||
@@ -373,13 +398,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
setBadgeCount()
|
||||
}
|
||||
let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf
|
||||
if prevBestAttempt?.callInvitation != nil {
|
||||
if ntf.callInvitation != nil { // replace with newer call
|
||||
expectedMessages[id]?.msgBestAttemptNtf = ntf
|
||||
} // otherwise keep call as best attempt
|
||||
} else {
|
||||
if prevBestAttempt?.callInvitation == nil || ntf.callInvitation != nil {
|
||||
expectedMessages[id]?.msgBestAttemptNtf = ntf
|
||||
}
|
||||
} // otherwise keep call as best attempt
|
||||
} else {
|
||||
NSEThreads.shared.droppedNotifications.append((id, ntf))
|
||||
if signalReady { entityReady(id) }
|
||||
@@ -406,7 +427,11 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
|
||||
private func deliverBestAttemptNtf(urgent: Bool = false) {
|
||||
if (urgent || !expectingMoreMessages) {
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf urgent: \(urgent) expectingMoreMessages: \(self.expectingMoreMessages)")
|
||||
if let handler = contentHandler, urgent || !expectingMoreMessages {
|
||||
if urgent {
|
||||
contentHandler = nil
|
||||
}
|
||||
logger.debug("NotificationService.deliverBestAttemptNtf")
|
||||
// stop processing other messages
|
||||
for (key, _) in expectedMessages {
|
||||
@@ -420,18 +445,18 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
} else {
|
||||
suspend = false
|
||||
}
|
||||
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
|
||||
deliverCallkitOrNotification(urgent: urgent, suspend: suspend, handler: handler)
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) {
|
||||
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false, handler: @escaping (UNNotificationContent) -> Void) {
|
||||
if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) {
|
||||
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit")
|
||||
if urgent {
|
||||
// suspending NSE even though there may be other notifications
|
||||
// to allow the app to process callkit call
|
||||
suspendChat(0)
|
||||
deliverNotification()
|
||||
deliverNotification(handler: handler)
|
||||
} else {
|
||||
// suspending NSE with delay and delivering after the suspension
|
||||
// because pushkit notification must be processed without delay
|
||||
@@ -439,7 +464,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + fastNSESuspendSchedule.delay) {
|
||||
suspendChat(fastNSESuspendSchedule.timeout)
|
||||
DispatchQueue.global().asyncAfter(deadline: .now() + Double(fastNSESuspendSchedule.timeout)) {
|
||||
self.deliverNotification()
|
||||
self.deliverNotification(handler: handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,12 +483,12 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
}
|
||||
}
|
||||
}
|
||||
deliverNotification()
|
||||
deliverNotification(handler: handler)
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverNotification() {
|
||||
if let handler = contentHandler, let ntf = prepareNotification() {
|
||||
private func deliverNotification(handler: @escaping (UNNotificationContent) -> Void) {
|
||||
if serviceBestAttemptNtf != nil, let ntf = prepareNotification() {
|
||||
contentHandler = nil
|
||||
serviceBestAttemptNtf = nil
|
||||
switch ntf {
|
||||
@@ -496,7 +521,9 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
let callNtf = callNtfKV.value.msgBestAttemptNtf {
|
||||
return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount))
|
||||
} else {
|
||||
logger.debug("NotificationService prepareNotification \(String(describing: self.expectedMessages.map { $0.key }))")
|
||||
let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent }
|
||||
logger.debug("NotificationService prepareNotification \(ntfEvents.count)")
|
||||
if ntfEvents.isEmpty {
|
||||
return .empty
|
||||
} else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil {
|
||||
@@ -654,7 +681,7 @@ func doStartChat() -> DBMigrationResult? {
|
||||
let state = NSEChatState.shared.value
|
||||
NSEChatState.shared.set(.starting)
|
||||
if let user = apiGetActiveUser() {
|
||||
logger.debug("NotificationService active user \(String(describing: user))")
|
||||
logger.debug("NotificationService active user \(user.displayName)")
|
||||
do {
|
||||
try setNetworkConfig(networkConfig)
|
||||
try apiSetAppFilePaths(filesFolder: getAppFilesDirectory().path, tempFolder: getTempFilesDirectory().path, assetsFolder: getWallpaperDirectory().deletingLastPathComponent().path)
|
||||
@@ -762,9 +789,9 @@ func receiveMessages() async {
|
||||
}
|
||||
}
|
||||
|
||||
func chatRecvMsg() async -> ChatResponse? {
|
||||
func chatRecvMsg() async -> NSEChatResponse? {
|
||||
await withCheckedContinuation { cont in
|
||||
let resp = recvSimpleXMsg()
|
||||
let resp: NSEChatResponse? = recvSimpleXMsg()
|
||||
cont.resume(returning: resp)
|
||||
}
|
||||
}
|
||||
@@ -772,7 +799,7 @@ func chatRecvMsg() async -> ChatResponse? {
|
||||
private let isInChina = SKStorefront().countryCode == "CHN"
|
||||
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
|
||||
|
||||
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? {
|
||||
func receivedMsgNtf(_ res: NSEChatResponse) async -> (String, NSENotificationData)? {
|
||||
logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
|
||||
switch res {
|
||||
case let .contactConnected(user, contact, _):
|
||||
@@ -841,7 +868,7 @@ func updateNetCfg() {
|
||||
}
|
||||
|
||||
func apiGetActiveUser() -> User? {
|
||||
let r = sendSimpleXCmd(.showActiveUser)
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.showActiveUser)
|
||||
logger.debug("apiGetActiveUser sendSimpleXCmd response: \(r.responseType)")
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
@@ -858,7 +885,7 @@ func apiGetActiveUser() -> User? {
|
||||
}
|
||||
|
||||
func apiStartChat() throws -> Bool {
|
||||
let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: false))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.startChat(mainApp: false, enableSndFiles: false))
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -868,40 +895,40 @@ func apiStartChat() throws -> Bool {
|
||||
|
||||
func apiActivateChat() -> Bool {
|
||||
chatReopenStore()
|
||||
let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiActivateChat(restoreChat: false))
|
||||
if case .cmdOk = r { return true }
|
||||
logger.error("NotificationService apiActivateChat error: \(String(describing: r))")
|
||||
return false
|
||||
}
|
||||
|
||||
func apiSuspendChat(timeoutMicroseconds: Int) -> Bool {
|
||||
let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSuspendChat(timeoutMicroseconds: timeoutMicroseconds))
|
||||
if case .cmdOk = r { return true }
|
||||
logger.error("NotificationService apiSuspendChat error: \(String(describing: r))")
|
||||
return false
|
||||
}
|
||||
|
||||
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws {
|
||||
let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
|
||||
func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [NtfConn]? {
|
||||
guard apiGetActiveUser() != nil else {
|
||||
logger.debug("no active user")
|
||||
return nil
|
||||
}
|
||||
let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo))
|
||||
if case let .ntfConns(ntfConns) = r {
|
||||
logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)")
|
||||
return ntfConns.compactMap { toUserNtfConn($0) }
|
||||
return ntfConns
|
||||
} else if case let .chatCmdError(_, error) = r {
|
||||
logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))")
|
||||
} else {
|
||||
@@ -910,30 +937,23 @@ func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? {
|
||||
if let user = ntfConn.user_ {
|
||||
return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? {
|
||||
func apiGetConnNtfMessages(connMsgReqs: [ConnMsgReq]) -> [NtfMsgInfo?]? {
|
||||
guard apiGetActiveUser() != nil else {
|
||||
logger.debug("no active user")
|
||||
return nil
|
||||
}
|
||||
let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds))
|
||||
logger.debug("apiGetConnNtfMessages command: \(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs).cmdString)")
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiGetConnNtfMessages(connMsgReqs: connMsgReqs))
|
||||
if case let .connNtfMessages(receivedMsgs) = r {
|
||||
logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)")
|
||||
logger.debug("apiGetConnNtfMessages response receivedMsgs: total \(receivedMsgs.count), expecting messages \(receivedMsgs.count { $0 != nil })")
|
||||
return receivedMsgs
|
||||
}
|
||||
logger.debug("apiGetConnNtfMessages error: \(responseError(r))")
|
||||
return nil
|
||||
}
|
||||
|
||||
func getConnNtfMessage(connId: String) -> NtfMsgInfo? {
|
||||
let r_ = apiGetConnNtfMessages(connIds: [connId])
|
||||
func getConnNtfMessage(connMsgReq: ConnMsgReq) -> NtfMsgInfo? {
|
||||
let r_ = apiGetConnNtfMessages(connMsgReqs: [connMsgReq])
|
||||
if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil {
|
||||
return receivedMsg
|
||||
}
|
||||
@@ -942,7 +962,7 @@ func getConnNtfMessage(connId: String) -> NtfMsgInfo? {
|
||||
|
||||
func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> AChatItem? {
|
||||
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
|
||||
let r = sendSimpleXCmd(.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.receiveFile(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted, inline: inline))
|
||||
if case let .rcvFileAccepted(_, chatItem) = r { return chatItem }
|
||||
logger.error("receiveFile error: \(responseError(r))")
|
||||
return nil
|
||||
@@ -950,7 +970,7 @@ func apiReceiveFile(fileId: Int64, encrypted: Bool, inline: Bool? = nil) -> ACha
|
||||
|
||||
func apiSetFileToReceive(fileId: Int64, encrypted: Bool) {
|
||||
let userApprovedRelays = !privacyAskToApproveRelaysGroupDefault.get()
|
||||
let r = sendSimpleXCmd(.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.setFileToReceive(fileId: fileId, userApprovedRelays: userApprovedRelays, encrypted: encrypted))
|
||||
if case .cmdOk = r { return }
|
||||
logger.error("setFileToReceive error: \(responseError(r))")
|
||||
}
|
||||
@@ -969,38 +989,33 @@ func autoReceiveFile(_ file: CIFile) -> ChatItem? {
|
||||
}
|
||||
|
||||
func setNetworkConfig(_ cfg: NetCfg) throws {
|
||||
let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg))
|
||||
let r: NSEChatResponse = sendSimpleXCmd(NSEChatCommand.apiSetNetworkConfig(networkConfig: cfg))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
struct UserNtfConn {
|
||||
var user: User
|
||||
var connEntity_: ConnectionEntity?
|
||||
var expectedMsg_: NtfMsgInfo?
|
||||
|
||||
var defaultBestAttemptNtf: NSENotificationData {
|
||||
return if !user.showNotifications {
|
||||
.noNtf
|
||||
} else if let connEntity = connEntity_ {
|
||||
switch connEntity {
|
||||
case let .rcvDirectMsgConnection(_, contact):
|
||||
contact?.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case let .rcvGroupMsgConnection(_, groupInfo, _):
|
||||
groupInfo.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case .sndFileConnection: .noNtf
|
||||
case .rcvFileConnection: .noNtf
|
||||
case let .userContactConnection(_, userContact):
|
||||
userContact.groupId == nil
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
}
|
||||
} else {
|
||||
.noNtf
|
||||
func defaultBestAttemptNtf(_ ntfConn: NtfConn) -> NSENotificationData {
|
||||
let user = ntfConn.user
|
||||
let connEntity = ntfConn.connEntity
|
||||
return if !user.showNotifications {
|
||||
.noNtf
|
||||
} else {
|
||||
switch ntfConn.connEntity {
|
||||
case let .rcvDirectMsgConnection(_, contact):
|
||||
contact?.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case let .rcvGroupMsgConnection(_, groupInfo, _):
|
||||
groupInfo.chatSettings.enableNtfs == .all
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
case .sndFileConnection: .noNtf
|
||||
case .rcvFileConnection: .noNtf
|
||||
case let .userContactConnection(_, userContact):
|
||||
userContact.groupId == nil
|
||||
? .connectionEvent(user, connEntity)
|
||||
: .noNtf
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import SimpleXChat
|
||||
let logger = Logger()
|
||||
|
||||
func apiGetActiveUser() throws -> User? {
|
||||
let r = sendSimpleXCmd(.showActiveUser)
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.showActiveUser)
|
||||
switch r {
|
||||
case let .activeUser(user): return user
|
||||
case .chatCmdError(_, .error(.noActiveUser)): return nil
|
||||
@@ -22,7 +22,7 @@ func apiGetActiveUser() throws -> User? {
|
||||
}
|
||||
|
||||
func apiStartChat() throws -> Bool {
|
||||
let r = sendSimpleXCmd(.startChat(mainApp: false, enableSndFiles: true))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.startChat(mainApp: false, enableSndFiles: true))
|
||||
switch r {
|
||||
case .chatStarted: return true
|
||||
case .chatRunning: return false
|
||||
@@ -31,25 +31,25 @@ func apiStartChat() throws -> Bool {
|
||||
}
|
||||
|
||||
func apiSetNetworkConfig(_ cfg: NetCfg) throws {
|
||||
let r = sendSimpleXCmd(.apiSetNetworkConfig(networkConfig: cfg))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetNetworkConfig(networkConfig: cfg))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String) throws {
|
||||
let r = sendSimpleXCmd(.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetAppFilePaths(filesFolder: filesFolder, tempFolder: tempFolder, assetsFolder: assetsFolder))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
|
||||
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSetEncryptLocalFiles(enable: enable))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetChats(userId: User.ID) throws -> Array<ChatData> {
|
||||
let r = sendSimpleXCmd(.apiGetChats(userId: userId))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiGetChats(userId: userId))
|
||||
if case let .apiChats(user: _, chats: chats) = r { return chats }
|
||||
throw r
|
||||
}
|
||||
@@ -58,13 +58,13 @@ func apiSendMessages(
|
||||
chatInfo: ChatInfo,
|
||||
composedMessages: [ComposedMessage]
|
||||
) throws -> [AChatItem] {
|
||||
let r = sendSimpleXCmd(
|
||||
let r: SEChatResponse = sendSimpleXCmd(
|
||||
chatInfo.chatType == .local
|
||||
? .apiCreateChatItems(
|
||||
? SEChatCommand.apiCreateChatItems(
|
||||
noteFolderId: chatInfo.apiId,
|
||||
composedMessages: composedMessages
|
||||
)
|
||||
: .apiSendMessages(
|
||||
: SEChatCommand.apiSendMessages(
|
||||
type: chatInfo.chatType,
|
||||
id: chatInfo.apiId,
|
||||
live: false,
|
||||
@@ -84,19 +84,20 @@ func apiSendMessages(
|
||||
|
||||
func apiActivateChat() throws {
|
||||
chatReopenStore()
|
||||
let r = sendSimpleXCmd(.apiActivateChat(restoreChat: false))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiActivateChat(restoreChat: false))
|
||||
if case .cmdOk = r { return }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSuspendChat(expired: Bool) {
|
||||
let r = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000))
|
||||
let r: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: expired ? 0 : 3_000000))
|
||||
// Block until `chatSuspended` received or 3 seconds has passed
|
||||
var suspended = false
|
||||
if case .cmdOk = r, !expired {
|
||||
let startTime = CFAbsoluteTimeGetCurrent()
|
||||
while CFAbsoluteTimeGetCurrent() - startTime < 3 {
|
||||
switch recvSimpleXMsg(messageTimeout: 3_500000) {
|
||||
let msg: SEChatResponse? = recvSimpleXMsg(messageTimeout: 3_500000)
|
||||
switch msg {
|
||||
case .chatSuspended:
|
||||
suspended = false
|
||||
break
|
||||
@@ -105,9 +106,166 @@ func apiSuspendChat(expired: Bool) {
|
||||
}
|
||||
}
|
||||
if !suspended {
|
||||
_ = sendSimpleXCmd(.apiSuspendChat(timeoutMicroseconds: 0))
|
||||
let _r1: SEChatResponse = sendSimpleXCmd(SEChatCommand.apiSuspendChat(timeoutMicroseconds: 0))
|
||||
}
|
||||
logger.debug("close store")
|
||||
chatCloseStore()
|
||||
SEChatState.shared.set(.inactive)
|
||||
}
|
||||
|
||||
enum SEChatCommand: ChatCmdProtocol {
|
||||
case showActiveUser
|
||||
case startChat(mainApp: Bool, enableSndFiles: Bool)
|
||||
case apiActivateChat(restoreChat: Bool)
|
||||
case apiSuspendChat(timeoutMicroseconds: Int)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
case apiSetAppFilePaths(filesFolder: String, tempFolder: String, assetsFolder: String)
|
||||
case apiSetEncryptLocalFiles(enable: Bool)
|
||||
case apiGetChats(userId: Int64)
|
||||
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
|
||||
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case .showActiveUser: return "/u"
|
||||
case let .startChat(mainApp, enableSndFiles): return "/_start main=\(onOff(mainApp)) snd_files=\(onOff(enableSndFiles))"
|
||||
case let .apiActivateChat(restore): return "/_app activate restore=\(onOff(restore))"
|
||||
case let .apiSuspendChat(timeoutMicroseconds): return "/_app suspend \(timeoutMicroseconds)"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
case let .apiSetAppFilePaths(filesFolder, tempFolder, assetsFolder):
|
||||
return "/set file paths \(encodeJSON(AppFilePaths(appFilesFolder: filesFolder, appTempFolder: tempFolder, appAssetsFolder: assetsFolder)))"
|
||||
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
|
||||
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
|
||||
case let .apiCreateChatItems(noteFolderId, composedMessages):
|
||||
let msgs = encodeJSON(composedMessages)
|
||||
return "/_create *\(noteFolderId) json \(msgs)"
|
||||
case let .apiSendMessages(type, id, live, ttl, composedMessages):
|
||||
let msgs = encodeJSON(composedMessages)
|
||||
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
|
||||
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
|
||||
}
|
||||
}
|
||||
|
||||
func ref(_ type: ChatType, _ id: Int64) -> String {
|
||||
"\(type.rawValue)\(id)"
|
||||
}
|
||||
}
|
||||
|
||||
enum SEChatResponse: Decodable, Error, ChatRespProtocol {
|
||||
case response(type: String, json: String)
|
||||
case activeUser(user: User)
|
||||
case chatStarted
|
||||
case chatRunning
|
||||
case chatSuspended
|
||||
case apiChats(user: UserRef, chats: [ChatData])
|
||||
case newChatItems(user: UserRef, chatItems: [AChatItem])
|
||||
case sndFileProgressXFTP(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, sentSize: Int64, totalSize: Int64)
|
||||
case sndFileCompleteXFTP(user: UserRef, chatItem: AChatItem, fileTransferMeta: FileTransferMeta)
|
||||
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
|
||||
case sndFileError(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String)
|
||||
case sndFileWarning(user: UserRef, chatItem_: AChatItem?, fileTransferMeta: FileTransferMeta, errorMessage: String)
|
||||
case cmdOk(user_: UserRef?)
|
||||
case chatCmdError(user_: UserRef?, chatError: ChatError)
|
||||
case chatError(user_: UserRef?, chatError: ChatError)
|
||||
|
||||
var responseType: String {
|
||||
switch self {
|
||||
case let .response(type, _): "* \(type)"
|
||||
case .activeUser: "activeUser"
|
||||
case .chatStarted: "chatStarted"
|
||||
case .chatRunning: "chatRunning"
|
||||
case .chatSuspended: "chatSuspended"
|
||||
case .apiChats: "apiChats"
|
||||
case .newChatItems: "newChatItems"
|
||||
case .sndFileProgressXFTP: "sndFileProgressXFTP"
|
||||
case .sndFileCompleteXFTP: "sndFileCompleteXFTP"
|
||||
case .chatItemsStatusesUpdated: "chatItemsStatusesUpdated"
|
||||
case .sndFileError: "sndFileError"
|
||||
case .sndFileWarning: "sndFileWarning"
|
||||
case .cmdOk: "cmdOk"
|
||||
case .chatCmdError: "chatCmdError"
|
||||
case .chatError: "chatError"
|
||||
}
|
||||
}
|
||||
|
||||
var details: String {
|
||||
switch self {
|
||||
case let .response(_, json): return json
|
||||
case let .activeUser(user): return String(describing: user)
|
||||
case .chatStarted: return noDetails
|
||||
case .chatRunning: return noDetails
|
||||
case .chatSuspended: return noDetails
|
||||
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
|
||||
case let .newChatItems(u, chatItems):
|
||||
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
|
||||
return withUser(u, itemsString)
|
||||
case let .sndFileProgressXFTP(u, chatItem, _, sentSize, totalSize): return withUser(u, "chatItem: \(String(describing: chatItem))\nsentSize: \(sentSize)\ntotalSize: \(totalSize)")
|
||||
case let .sndFileCompleteXFTP(u, chatItem, _): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemsStatusesUpdated(u, chatItems):
|
||||
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
|
||||
return withUser(u, itemsString)
|
||||
case let .sndFileError(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))")
|
||||
case let .sndFileWarning(u, chatItem, _, err): return withUser(u, "error: \(String(describing: err))\nchatItem: \(String(describing: chatItem))")
|
||||
case .cmdOk: return noDetails
|
||||
case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
case let .chatError(u, chatError): return withUser(u, String(describing: chatError))
|
||||
}
|
||||
}
|
||||
|
||||
var noDetails: String { "\(responseType): no details" }
|
||||
|
||||
static func chatResponse(_ s: String) -> SEChatResponse {
|
||||
let d = s.data(using: .utf8)!
|
||||
// TODO is there a way to do it without copying the data? e.g:
|
||||
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
|
||||
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
|
||||
do {
|
||||
let r = try jsonDecoder.decode(APIResponse<SEChatResponse>.self, from: d)
|
||||
return r.resp
|
||||
} catch {
|
||||
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
var type: String?
|
||||
var json: String?
|
||||
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
|
||||
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 {
|
||||
type = jResp.allKeys[0] as? String
|
||||
if jResp.count == 2 && type == "_owsf" {
|
||||
type = jResp.allKeys[1] as? String
|
||||
}
|
||||
if type == "apiChats" {
|
||||
if let r = parseApiChats(jResp) {
|
||||
return .apiChats(user: r.user, chats: r.chats)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
} else if type == "chatError" {
|
||||
if let jError = jResp["chatError"] as? NSDictionary {
|
||||
return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
json = serializeJSON(j, options: .prettyPrinted)
|
||||
}
|
||||
return SEChatResponse.response(type: type ?? "invalid", json: json ?? s)
|
||||
}
|
||||
|
||||
var chatError: ChatError? {
|
||||
switch self {
|
||||
case let .chatCmdError(_, error): error
|
||||
case let .chatError(_, error): error
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
var chatErrorType: ChatErrorType? {
|
||||
switch self {
|
||||
case let .chatCmdError(_, .error(error)): error
|
||||
case let .chatError(_, .error(error)): error
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ class ShareModel: ObservableObject {
|
||||
resetChatCtrl() // Clears retained migration result
|
||||
registerGroupDefaults()
|
||||
haskell_init_se()
|
||||
let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation())
|
||||
let (_, result) = chatMigrateInit(dbKey, confirmMigrations: defaultMigrationConfirmation(), backgroundMode: false)
|
||||
if let e = migrationError(result) { return e }
|
||||
try apiSetAppFilePaths(
|
||||
filesFolder: getAppFilesDirectory().path,
|
||||
@@ -303,7 +303,8 @@ class ShareModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
}
|
||||
switch recvSimpleXMsg(messageTimeout: 1_000_000) {
|
||||
let r: SEChatResponse? = recvSimpleXMsg(messageTimeout: 1_000_000)
|
||||
switch r {
|
||||
case let .sndFileProgressXFTP(_, ci, _, sentSize, totalSize):
|
||||
guard isMessage(for: ci) else { continue }
|
||||
networkTimeout = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
@@ -242,6 +242,8 @@
|
||||
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
|
||||
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
|
||||
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; };
|
||||
E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */; };
|
||||
E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -643,6 +645,8 @@
|
||||
E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
|
||||
E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppAPITypes.swift; sourceTree = "<group>"; };
|
||||
E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSEAPITypes.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -795,6 +799,7 @@
|
||||
5C764E87279CBC8E000C6508 /* Model */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E5DDBE6D2DC4106200A0EFF0 /* AppAPITypes.swift */,
|
||||
5C764E88279CBCB3000C6508 /* ChatModel.swift */,
|
||||
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */,
|
||||
5C35CFC727B2782E00FB6C6D /* BGManager.swift */,
|
||||
@@ -990,6 +995,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5CDCAD5128186DE400503DA2 /* SimpleX NSE.entitlements */,
|
||||
E5DDBE6F2DC4217900A0EFF0 /* NSEAPITypes.swift */,
|
||||
5CDCAD472818589900503DA2 /* NotificationService.swift */,
|
||||
5CDCAD492818589900503DA2 /* Info.plist */,
|
||||
5CB0BA862826CB3A00B3292C /* InfoPlist.strings */,
|
||||
@@ -1006,9 +1012,9 @@
|
||||
5CDCAD7228188CFF00503DA2 /* ChatTypes.swift */,
|
||||
5CDCAD7428188D2900503DA2 /* APITypes.swift */,
|
||||
5C5E5D3C282447AB00B0488A /* CallTypes.swift */,
|
||||
5CDCAD7D2818941F00503DA2 /* API.swift */,
|
||||
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */,
|
||||
5C9FD96A27A56D4D0075386C /* JSON.swift */,
|
||||
5CDCAD7D2818941F00503DA2 /* API.swift */,
|
||||
5CDCAD80281A7E2700503DA2 /* Notifications.swift */,
|
||||
5CBD2859295711D700EC2CF4 /* ImageUtils.swift */,
|
||||
CE2AD9CD2C452A4D00E844E3 /* ChatUtils.swift */,
|
||||
@@ -1534,6 +1540,7 @@
|
||||
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
|
||||
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */,
|
||||
6448BBB628FA9D56000D2AB9 /* GroupLinkView.swift in Sources */,
|
||||
E5DDBE6E2DC4106800A0EFF0 /* AppAPITypes.swift in Sources */,
|
||||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
|
||||
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
|
||||
@@ -1606,6 +1613,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */,
|
||||
E5DDBE702DC4217900A0EFF0 /* NSEAPITypes.swift in Sources */,
|
||||
5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@@ -110,19 +110,19 @@ public func resetChatCtrl() {
|
||||
migrationResult = nil
|
||||
}
|
||||
|
||||
public func sendSimpleXCmd(_ cmd: ChatCommand, _ ctrl: chat_ctrl? = nil) -> ChatResponse {
|
||||
public func sendSimpleXCmd<CR: ChatRespProtocol>(_ cmd: ChatCmdProtocol, _ ctrl: chat_ctrl? = nil) -> CR {
|
||||
var c = cmd.cmdString.cString(using: .utf8)!
|
||||
let cjson = chat_send_cmd(ctrl ?? getChatCtrl(), &c)!
|
||||
return chatResponse(fromCString(cjson))
|
||||
return CR.chatResponse(fromCString(cjson))
|
||||
}
|
||||
|
||||
// in microseconds
|
||||
public let MESSAGE_TIMEOUT: Int32 = 15_000_000
|
||||
|
||||
public func recvSimpleXMsg(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> ChatResponse? {
|
||||
public func recvSimpleXMsg<CR: ChatRespProtocol>(_ ctrl: chat_ctrl? = nil, messageTimeout: Int32 = MESSAGE_TIMEOUT) -> CR? {
|
||||
if let cjson = chat_recv_msg_wait(ctrl ?? getChatCtrl(), messageTimeout) {
|
||||
let s = fromCString(cjson)
|
||||
return s == "" ? nil : chatResponse(s)
|
||||
return s == "" ? nil : CR.chatResponse(s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -177,89 +177,7 @@ public func fromCString(_ c: UnsafeMutablePointer<CChar>) -> String {
|
||||
return s
|
||||
}
|
||||
|
||||
public func chatResponse(_ s: String) -> ChatResponse {
|
||||
let d = s.data(using: .utf8)!
|
||||
// TODO is there a way to do it without copying the data? e.g:
|
||||
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
|
||||
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
|
||||
do {
|
||||
let r = try callWithLargeStack {
|
||||
try jsonDecoder.decode(APIResponse.self, from: d)
|
||||
}
|
||||
return r.resp
|
||||
} catch {
|
||||
logger.error("chatResponse jsonDecoder.decode error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
var type: String?
|
||||
var json: String?
|
||||
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
|
||||
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 {
|
||||
type = jResp.allKeys[0] as? String
|
||||
if jResp.count == 2 && type == "_owsf" {
|
||||
type = jResp.allKeys[1] as? String
|
||||
}
|
||||
if type == "apiChats" {
|
||||
if let jApiChats = jResp["apiChats"] as? NSDictionary,
|
||||
let user: UserRef = try? decodeObject(jApiChats["user"] as Any),
|
||||
let jChats = jApiChats["chats"] as? NSArray {
|
||||
let chats = jChats.map { jChat in
|
||||
if let chatData = try? parseChatData(jChat) {
|
||||
return chatData.0
|
||||
}
|
||||
return ChatData.invalidJSON(serializeJSON(jChat, options: .prettyPrinted) ?? "")
|
||||
}
|
||||
return .apiChats(user: user, chats: chats)
|
||||
}
|
||||
} else if type == "apiChat" {
|
||||
if let jApiChat = jResp["apiChat"] as? NSDictionary,
|
||||
let user: UserRef = try? decodeObject(jApiChat["user"] as Any),
|
||||
let jChat = jApiChat["chat"] as? NSDictionary,
|
||||
let (chat, navInfo) = try? parseChatData(jChat, jApiChat["navInfo"] as? NSDictionary) {
|
||||
return .apiChat(user: user, chat: chat, navInfo: navInfo)
|
||||
}
|
||||
} else if type == "chatCmdError" {
|
||||
if let jError = jResp["chatCmdError"] as? NSDictionary {
|
||||
return .chatCmdError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
} else if type == "chatError" {
|
||||
if let jError = jResp["chatError"] as? NSDictionary {
|
||||
return .chatError(user_: decodeUser_(jError), chatError: .invalidJSON(json: errorJson(jError) ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
json = serializeJSON(j, options: .prettyPrinted)
|
||||
}
|
||||
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
|
||||
}
|
||||
|
||||
private let largeStackSize: Int = 2 * 1024 * 1024
|
||||
|
||||
private func callWithLargeStack<T>(_ f: @escaping () throws -> T) throws -> T {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
var result: Result<T, Error>?
|
||||
let thread = Thread {
|
||||
do {
|
||||
result = .success(try f())
|
||||
} catch {
|
||||
result = .failure(error)
|
||||
}
|
||||
semaphore.signal()
|
||||
}
|
||||
|
||||
thread.stackSize = largeStackSize
|
||||
thread.qualityOfService = Thread.current.qualityOfService
|
||||
thread.start()
|
||||
|
||||
semaphore.wait()
|
||||
|
||||
switch result! {
|
||||
case let .success(r): return r
|
||||
case let .failure(e): throw e
|
||||
}
|
||||
}
|
||||
|
||||
private func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
|
||||
public func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
|
||||
if let user_ = jDict["user_"] {
|
||||
try? decodeObject(user_ as Any)
|
||||
} else {
|
||||
@@ -267,7 +185,7 @@ private func decodeUser_(_ jDict: NSDictionary) -> UserRef? {
|
||||
}
|
||||
}
|
||||
|
||||
private func errorJson(_ jDict: NSDictionary) -> String? {
|
||||
public func errorJson(_ jDict: NSDictionary) -> String? {
|
||||
if let chatError = jDict["chatError"] {
|
||||
serializeJSON(chatError)
|
||||
} else {
|
||||
@@ -275,7 +193,7 @@ private func errorJson(_ jDict: NSDictionary) -> String? {
|
||||
}
|
||||
}
|
||||
|
||||
func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
|
||||
public func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, NavigationInfo) {
|
||||
let jChatDict = jChat as! NSDictionary
|
||||
let chatInfo: ChatInfo = try decodeObject(jChatDict["chatInfo"]!)
|
||||
let chatStats: ChatStats = try decodeObject(jChatDict["chatStats"]!)
|
||||
@@ -294,7 +212,7 @@ func parseChatData(_ jChat: Any, _ jNavInfo: Any? = nil) throws -> (ChatData, Na
|
||||
return (ChatData(chatInfo: chatInfo, chatItems: chatItems, chatStats: chatStats), navInfo)
|
||||
}
|
||||
|
||||
func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
|
||||
public func decodeObject<T: Decodable>(_ obj: Any) throws -> T {
|
||||
try jsonDecoder.decode(T.self, from: JSONSerialization.data(withJSONObject: obj))
|
||||
}
|
||||
|
||||
@@ -305,7 +223,7 @@ func decodeProperty<T: Decodable>(_ obj: Any, _ prop: NSString) -> T? {
|
||||
return nil
|
||||
}
|
||||
|
||||
func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? {
|
||||
public func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -> String? {
|
||||
if let d = try? JSONSerialization.data(withJSONObject: obj, options: options) {
|
||||
return String(decoding: d, as: UTF8.self)
|
||||
}
|
||||
@@ -313,14 +231,14 @@ func serializeJSON(_ obj: Any, options: JSONSerialization.WritingOptions = []) -
|
||||
}
|
||||
|
||||
public func responseError(_ err: Error) -> String {
|
||||
if let r = err as? ChatResponse {
|
||||
switch r {
|
||||
case let .chatCmdError(_, chatError): return chatErrorString(chatError)
|
||||
case let .chatError(_, chatError): return chatErrorString(chatError)
|
||||
default: return "\(String(describing: r.responseType)), details: \(String(describing: r.details))"
|
||||
if let r = err as? ChatRespProtocol {
|
||||
if let e = r.chatError {
|
||||
chatErrorString(e)
|
||||
} else {
|
||||
"\(String(describing: r.responseType)), details: \(String(describing: r.details))"
|
||||
}
|
||||
} else {
|
||||
return String(describing: err)
|
||||
String(describing: err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-2308
File diff suppressed because it is too large
Load Diff
@@ -2399,14 +2399,14 @@ public enum ConnectionEntity: Decodable, Hashable {
|
||||
|
||||
public var id: String? {
|
||||
switch self {
|
||||
case let .rcvDirectMsgConnection(_, contact):
|
||||
return contact?.id
|
||||
case let .rcvDirectMsgConnection(conn, contact):
|
||||
contact?.id ?? conn.id
|
||||
case let .rcvGroupMsgConnection(_, _, groupMember):
|
||||
return groupMember.id
|
||||
groupMember.id
|
||||
case let .userContactConnection(_, userContact):
|
||||
return userContact.id
|
||||
userContact.id
|
||||
default:
|
||||
return nil
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2422,10 +2422,11 @@ public enum ConnectionEntity: Decodable, Hashable {
|
||||
}
|
||||
|
||||
public struct NtfConn: Decodable, Hashable {
|
||||
public var user_: User?
|
||||
public var connEntity_: ConnectionEntity?
|
||||
public var user: User
|
||||
public var agentConnId: String
|
||||
public var agentDbQueueId: Int64
|
||||
public var connEntity: ConnectionEntity
|
||||
public var expectedMsg_: NtfMsgInfo?
|
||||
|
||||
}
|
||||
|
||||
public struct NtfMsgInfo: Decodable, Hashable {
|
||||
@@ -2433,6 +2434,29 @@ public struct NtfMsgInfo: Decodable, Hashable {
|
||||
public var msgTs: Date
|
||||
}
|
||||
|
||||
let iso8601DateFormatter = {
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f
|
||||
}()
|
||||
|
||||
// used in apiGetConnNtfMessages
|
||||
public struct ConnMsgReq {
|
||||
public var msgConnId: String
|
||||
public var msgDbQueueId: Int64
|
||||
public var msgTs: Date // SystemTime encodes as a number, should be taken from NtfMsgInfo
|
||||
|
||||
public init(msgConnId: String, msgDbQueueId: Int64, msgTs: Date) {
|
||||
self.msgConnId = msgConnId
|
||||
self.msgDbQueueId = msgDbQueueId
|
||||
self.msgTs = msgTs
|
||||
}
|
||||
|
||||
public var cmdString: String {
|
||||
"\(msgConnId):\(msgDbQueueId):\(iso8601DateFormatter.string(from: msgTs))"
|
||||
}
|
||||
}
|
||||
|
||||
public struct NtfMsgAckInfo: Decodable, Hashable {
|
||||
public var msgId: String
|
||||
public var msgTs_: Date?
|
||||
@@ -3911,7 +3935,7 @@ public enum MsgContent: Equatable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
var cmdString: String {
|
||||
public var cmdString: String {
|
||||
"json \(encodeJSON(self))"
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ public struct ErrorAlert: Error {
|
||||
}
|
||||
|
||||
public init(_ error: any Error) {
|
||||
self = if let chatResponse = error as? ChatResponse {
|
||||
self = if let chatResponse = error as? ChatRespProtocol {
|
||||
ErrorAlert(chatResponse)
|
||||
} else {
|
||||
ErrorAlert("\(error.localizedDescription)")
|
||||
@@ -48,7 +48,7 @@ public struct ErrorAlert: Error {
|
||||
self = ErrorAlert("\(chatErrorString(chatError))")
|
||||
}
|
||||
|
||||
public init(_ chatResponse: ChatResponse) {
|
||||
public init(_ chatResponse: ChatRespProtocol) {
|
||||
self = if let networkErrorAlert = getNetworkErrorAlert(chatResponse) {
|
||||
networkErrorAlert
|
||||
} else {
|
||||
@@ -94,22 +94,21 @@ extension View {
|
||||
}
|
||||
}
|
||||
|
||||
public func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? {
|
||||
switch r {
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
|
||||
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
|
||||
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))):
|
||||
return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).")
|
||||
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))):
|
||||
return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).")
|
||||
case let .chatCmdError(_, .errorAgent(.SMP(serverAddress, .PROXY(proxyErr)))):
|
||||
return smpProxyErrorAlert(proxyErr, serverAddress)
|
||||
case let .chatCmdError(_, .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr))))):
|
||||
return proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer)
|
||||
default:
|
||||
return nil
|
||||
public func getNetworkErrorAlert(_ r: ChatRespProtocol) -> ErrorAlert? {
|
||||
switch r.chatError {
|
||||
case let .errorAgent(.BROKER(addr, .TIMEOUT)):
|
||||
ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .errorAgent(.BROKER(addr, .NETWORK)):
|
||||
ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
|
||||
case let .errorAgent(.BROKER(addr, .HOST)):
|
||||
ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).")
|
||||
case let .errorAgent(.BROKER(addr, .TRANSPORT(.version))):
|
||||
ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).")
|
||||
case let .errorAgent(.SMP(serverAddress, .PROXY(proxyErr))):
|
||||
smpProxyErrorAlert(proxyErr, serverAddress)
|
||||
case let .errorAgent(.PROXY(proxyServer, relayServer, .protocolError(.PROXY(proxyErr)))):
|
||||
proxyDestinationErrorAlert(proxyErr, proxyServer, relayServer)
|
||||
default: nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 08b84deba458407ae97d55debd98b872cb6c4d79
|
||||
tag: 3d10c9bf9e4d8196d39162ff8712f6b729b8c247
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."08b84deba458407ae97d55debd98b872cb6c4d79" = "0b4n7d81spl1r7zppr0lc40ls9m1i93g4l3hzg2996pi3bxmafrr";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."3d10c9bf9e4d8196d39162ff8712f6b729b8c247" = "1nnr6klv240da97qmrzlh8jywpimcnlrxnxnjrm2rd0w0w7gvra1";
|
||||
"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";
|
||||
|
||||
@@ -357,7 +357,7 @@ data ChatCommand
|
||||
| APICheckToken DeviceToken
|
||||
| APIDeleteToken DeviceToken
|
||||
| APIGetNtfConns {nonce :: C.CbNonce, encNtfInfo :: ByteString}
|
||||
| ApiGetConnNtfMessages {connIds :: NonEmpty AgentConnId}
|
||||
| APIGetConnNtfMessages (NonEmpty ConnMsgReq)
|
||||
| APIAddMember GroupId ContactId GroupMemberRole
|
||||
| APIJoinGroup {groupId :: GroupId, enableNtfs :: MsgFilter}
|
||||
| APIAcceptMember GroupId GroupMemberId GroupMemberRole
|
||||
@@ -1139,12 +1139,18 @@ instance FromJSON ChatTagData where
|
||||
parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid)
|
||||
|
||||
data NtfConn = NtfConn
|
||||
{ user_ :: Maybe User,
|
||||
connEntity_ :: Maybe ConnectionEntity,
|
||||
{ user :: User,
|
||||
agentConnId :: AgentConnId,
|
||||
agentDbQueueId :: Int64,
|
||||
connEntity :: ConnectionEntity,
|
||||
-- Decrypted ntf meta of the expected message (the one notification was sent for).
|
||||
-- Nothing means it failed to decrypt or to decode, we can still show event for entity
|
||||
expectedMsg_ :: Maybe NtfMsgInfo
|
||||
}
|
||||
deriving (Show)
|
||||
|
||||
-- brokerTs is the same msgTs, it is used in ConnMsgReq / APIGetConnNtfMessages
|
||||
-- to set it as last connection message in case queue is empty
|
||||
data NtfMsgInfo = NtfMsgInfo {msgId :: Text, msgTs :: UTCTime}
|
||||
deriving (Show)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import Text.Read (readMaybe)
|
||||
import UnliftIO.Async
|
||||
|
||||
simplexChatCore :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO ()
|
||||
simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}} chat =
|
||||
simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {coreOptions = CoreChatOpts {dbOptions, logAgent, yesToUpMigrations}, maintenance} chat =
|
||||
case logAgent of
|
||||
Just level -> do
|
||||
setLogLevel level
|
||||
@@ -48,7 +48,8 @@ simplexChatCore cfg@ChatConfig {confirmMigrations, testView} opts@ChatOpts {core
|
||||
exitFailure
|
||||
run db@ChatDatabase {chatStore} = do
|
||||
u_ <- getSelectActiveUser chatStore
|
||||
cc <- newChatController db u_ cfg opts False
|
||||
let backgroundMode = not maintenance
|
||||
cc <- newChatController db u_ cfg opts backgroundMode
|
||||
u <- maybe (createActiveUser cc) pure u_
|
||||
unless testView $ putStrLn $ "Current user: " <> userStr u
|
||||
runSimplexChat opts u cc chat
|
||||
|
||||
@@ -1311,26 +1311,17 @@ processChatCommand' vr = \case
|
||||
ntfInfos <- withAgent $ \a -> getNotificationConns a nonce encNtfInfo
|
||||
(errs, ntfMsgs) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (getMsgConn db) (L.toList ntfInfos))
|
||||
unless (null errs) $ toView $ CRChatErrors (Just user) errs
|
||||
pure $ CRNtfConns ntfMsgs
|
||||
pure $ CRNtfConns $ catMaybes ntfMsgs
|
||||
where
|
||||
getMsgConn :: DB.Connection -> NotificationInfo -> IO NtfConn
|
||||
getMsgConn db NotificationInfo {ntfConnId, ntfMsgMeta = nMsgMeta} = do
|
||||
getMsgConn :: DB.Connection -> NotificationInfo -> IO (Maybe NtfConn)
|
||||
getMsgConn db NotificationInfo {ntfConnId, ntfDbQueueId, ntfMsgMeta = nMsgMeta} = do
|
||||
let agentConnId = AgentConnId ntfConnId
|
||||
user_ <- getUserByAConnId db agentConnId
|
||||
connEntity_ <-
|
||||
pure user_ $>>= \user ->
|
||||
eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId)
|
||||
pure $
|
||||
NtfConn
|
||||
{ user_,
|
||||
connEntity_,
|
||||
-- Decrypted ntf meta of the expected message (the one notification was sent for)
|
||||
expectedMsg_ = expectedMsgInfo <$> nMsgMeta
|
||||
}
|
||||
ApiGetConnNtfMessages connIds -> withUser $ \_ -> do
|
||||
let acIds = L.map (\(AgentConnId acId) -> acId) connIds
|
||||
msgs <- lift $ withAgent' $ \a -> getConnectionMessages a acIds
|
||||
let ntfMsgs = L.map (\msg -> receivedMsgInfo <$> msg) msgs
|
||||
mkNtfConn user connEntity = NtfConn {user, agentConnId, agentDbQueueId = ntfDbQueueId, connEntity, expectedMsg_ = expectedMsgInfo <$> nMsgMeta}
|
||||
getUserByAConnId db agentConnId
|
||||
$>>= \user -> fmap (mkNtfConn user) . eitherToMaybe <$> runExceptT (getConnectionEntity db vr user agentConnId)
|
||||
APIGetConnNtfMessages connMsgs -> withUser $ \_ -> do
|
||||
msgs <- lift $ withAgent' (`getConnectionMessages` connMsgs)
|
||||
let ntfMsgs = L.map (receivedMsgInfo <$>) msgs
|
||||
pure $ CRConnNtfMessages ntfMsgs
|
||||
GetUserProtoServers (AProtocolType p) -> withUser $ \user -> withServerProtocol p $ do
|
||||
srvs <- withFastStore (`getUserServers` user)
|
||||
@@ -4080,7 +4071,7 @@ chatCommandP =
|
||||
"/_ntf check " *> (APICheckToken <$> strP),
|
||||
"/_ntf delete " *> (APIDeleteToken <$> strP),
|
||||
"/_ntf conns " *> (APIGetNtfConns <$> strP <* A.space <*> strP),
|
||||
"/_ntf conn messages " *> (ApiGetConnNtfMessages <$> strP),
|
||||
"/_ntf conn messages " *> (APIGetConnNtfMessages <$> connMsgsP),
|
||||
"/_add #" *> (APIAddMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
|
||||
"/_join #" *> (APIJoinGroup <$> A.decimal <*> pure MFAll), -- needs to be changed to support in UI
|
||||
"/_accept member #" *> (APIAcceptMember <$> A.decimal <* A.space <*> A.decimal <*> memberRole),
|
||||
@@ -4400,6 +4391,12 @@ chatCommandP =
|
||||
cfArgs <- optional $ CFArgs <$> (" key=" *> strP <* A.space) <*> (" nonce=" *> strP)
|
||||
path <- filePath
|
||||
pure $ CryptoFile path cfArgs
|
||||
connMsgsP = L.fromList <$> connMsgP `A.sepBy1'` A.char ','
|
||||
connMsgP = do
|
||||
AgentConnId msgConnId <- strP <* A.char ':'
|
||||
msgDbQueueId <- strP <* A.char ':'
|
||||
ts <- strP
|
||||
pure ConnMsgReq {msgConnId, msgDbQueueId, msgTs = Just ts}
|
||||
memberRole =
|
||||
A.choice
|
||||
[ " owner" $> GROwner,
|
||||
|
||||
@@ -1119,9 +1119,9 @@ Query: UPDATE rcv_messages SET user_ack = ? WHERE conn_id = ? AND internal_id =
|
||||
Plan:
|
||||
SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?)
|
||||
|
||||
Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ?
|
||||
Query: UPDATE rcv_queues SET last_broker_ts = ? WHERE conn_id = ? AND rcv_queue_id = ? AND last_broker_ts < ?
|
||||
Plan:
|
||||
SEARCH rcv_queues USING COVERING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?)
|
||||
SEARCH rcv_queues USING INDEX idx_rcv_queue_id (conn_id=? AND rcv_queue_id=?)
|
||||
|
||||
Query: UPDATE rcv_queues SET rcv_primary = ? WHERE conn_id = ?
|
||||
Plan:
|
||||
|
||||
@@ -346,8 +346,8 @@ responseToView hu@(currentRH, user_) cfg@ChatConfig {logLevel, showReactions, sh
|
||||
CRContactConnectionDeleted u PendingContactConnection {pccConnId} -> ttyUser u ["connection :" <> sShow pccConnId <> " deleted"]
|
||||
CRNtfTokenStatus status -> ["device token status: " <> plain (smpEncode status)]
|
||||
CRNtfToken _ status mode srv -> ["device token status: " <> plain (smpEncode status) <> ", notifications mode: " <> plain (strEncode mode) <> ", server: " <> sShow srv]
|
||||
CRNtfConns {} -> []
|
||||
CRConnNtfMessages {} -> []
|
||||
CRNtfConns {ntfConns} -> map (\NtfConn {agentConnId, expectedMsg_} -> plain $ show agentConnId <> " " <> show expectedMsg_) ntfConns
|
||||
CRConnNtfMessages ntfMsgs -> [sShow ntfMsgs]
|
||||
CRNtfMessage {} -> []
|
||||
CRCurrentRemoteHost rhi_ ->
|
||||
[ maybe
|
||||
|
||||
@@ -78,10 +78,11 @@ testBroadcastMessages ps = do
|
||||
bob <## "I broadcast messages to all connected users from @alice."
|
||||
cath `connectVia` botLink
|
||||
alice #> "@broadcast_bot hello all!"
|
||||
alice <# "broadcast_bot> hello all!" -- we broadcast to the sender too, /feed is used by bot
|
||||
bob <# "broadcast_bot> hello all!"
|
||||
cath <# "broadcast_bot> hello all!"
|
||||
alice <# "broadcast_bot> > hello all!"
|
||||
alice <## " Forwarded to 2 contact(s)"
|
||||
alice <## " Forwarded to 3 contact(s), 0 errors"
|
||||
where
|
||||
cc `connectVia` botLink = do
|
||||
cc ##> ("/c " <> botLink)
|
||||
|
||||
Reference in New Issue
Block a user