diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index a1ec2f8f53..ec1a567420 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -688,41 +688,3 @@ final class Chat: ObservableObject, Identifiable { public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } - -enum NetworkStatus: Decodable, Equatable { - case unknown - case connected - case disconnected - case error(String) - - var statusString: LocalizedStringKey { - get { - switch self { - case .connected: return "connected" - case .error: return "error" - default: return "connecting" - } - } - } - - var statusExplanation: LocalizedStringKey { - get { - switch self { - case .connected: return "You are connected to the server used to receive messages from this contact." - case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." - default: return "Trying to connect to the server used to receive messages from this contact." - } - } - } - - var imageName: String { - get { - switch self { - case .unknown: return "circle.dotted" - case .connected: return "circle.fill" - case .disconnected: return "ellipsis.circle.fill" - case .error: return "exclamationmark.circle.fill" - } - } - } -} diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85e66e893d..e4f64ee977 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -944,6 +944,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { } } +func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] { + let r = chatSendCmdSync(.apiGetNetworkStatuses) + if case let .networkStatuses(_, statuses) = r { return statuses } + throw r +} + func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { do { if chat.chatStats.unreadCount > 0 { @@ -1348,13 +1354,6 @@ func processReceivedMsg(_ res: ChatResponse) async { await updateContactsStatus(contactRefs, status: .connected) case let .contactsDisconnected(_, contactRefs): await updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(user, contact, chatError): - await MainActor.run { - if active(user) { - m.updateContact(contact) - } - processContactSubError(contact, chatError) - } case let .contactSubSummary(_, contactSubscriptions): await MainActor.run { for sub in contactSubscriptions { @@ -1369,6 +1368,18 @@ func processReceivedMsg(_ res: ChatResponse) async { } } } + case let .networkStatus(status, connections): + await MainActor.run { + for cId in connections { + m.networkStatuses[cId] = status + } + } + case let .networkStatuses(statuses): () + await MainActor.run { + for s in statuses { + m.networkStatuses[s.agentConnId] = s.networkStatus + } + } case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem @@ -1649,7 +1660,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) { case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" default: err = String(describing: chatError) } - m.setContactNetworkStatus(contact, .error(err)) + m.setContactNetworkStatus(contact, .error(connectionError: err)) } func refreshCallInvitations() throws { diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index d65b9b3283..d29e2481d5 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -110,6 +110,7 @@ public enum ChatCommand { case apiEndCall(contact: Contact) case apiGetCallInvitations case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) + case apiGetNetworkStatuses case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) @@ -241,6 +242,7 @@ public enum ChatCommand { case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case .apiGetCallInvitations: return "/_call get" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" + case .apiGetNetworkStatuses: return "/_network_statuses" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .receiveFile(fileId, encrypted, inline): @@ -356,6 +358,7 @@ public enum ChatCommand { case .apiEndCall: return "apiEndCall" case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiCallStatus: return "apiCallStatus" + case .apiGetNetworkStatuses: return "apiGetNetworkStatuses" case .apiChatRead: return "apiChatRead" case .apiChatUnread: return "apiChatUnread" case .receiveFile: return "receiveFile" @@ -480,11 +483,14 @@ public enum ChatResponse: Decodable, Error { case acceptingContactRequest(user: UserRef, contact: Contact) case contactRequestRejected(user: UserRef) case contactUpdated(user: UserRef, toContact: Contact) + // TODO remove events below case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(user: UserRef, contact: Contact, chatError: ChatError) case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(user: UserRef, groupInfo: GroupInfo) + // TODO remove events above + case networkStatus(networkStatus: NetworkStatus, connections: [String]) + case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus]) + case groupSubscribed(user: UserRef, groupInfo: GroupRef) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case groupEmpty(user: UserRef, groupInfo: GroupInfo) case userContactLinkSubscribed @@ -620,8 +626,9 @@ public enum ChatResponse: Decodable, Error { case .contactUpdated: return "contactUpdated" case .contactsSubscribed: return "contactsSubscribed" case .contactsDisconnected: return "contactsDisconnected" - case .contactSubError: return "contactSubError" case .contactSubSummary: return "contactSubSummary" + case .networkStatus: return "networkStatus" + case .networkStatuses: return "networkStatuses" case .groupSubscribed: return "groupSubscribed" case .memberSubErrors: return "memberSubErrors" case .groupEmpty: return "groupEmpty" @@ -757,8 +764,9 @@ public enum ChatResponse: Decodable, Error { case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" - case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))") case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) + case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))" + case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) @@ -1181,6 +1189,49 @@ public struct KeepAliveOpts: Codable, Equatable { public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) } +public enum NetworkStatus: Decodable, Equatable { + case unknown + case connected + case disconnected + case error(connectionError: String) + + public var statusString: LocalizedStringKey { + get { + switch self { + case .connected: return "connected" + case .error: return "error" + default: return "connecting" + } + } + } + + public var statusExplanation: LocalizedStringKey { + get { + switch self { + case .connected: return "You are connected to the server used to receive messages from this contact." + case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." + default: return "Trying to connect to the server used to receive messages from this contact." + } + } + } + + public var imageName: String { + get { + switch self { + case .unknown: return "circle.dotted" + case .connected: return "circle.fill" + case .disconnected: return "ellipsis.circle.fill" + case .error: return "exclamationmark.circle.fill" + } + } + } +} + +public struct ConnNetworkStatus: Decodable { + public var agentConnId: String + public var networkStatus: NetworkStatus +} + public struct ChatSettings: Codable { public var enableNtfs: MsgFilter public var sendRcpts: Bool? diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 37e4f0316a..caaccb5454 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1729,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat { ) } +public struct GroupRef: Decodable { + public var groupId: Int64 + var localDisplayName: GroupName +} + public struct GroupProfile: Codable, NamedChat { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { self.displayName = displayName @@ -1871,6 +1876,11 @@ public struct GroupMemberRef: Decodable { var profile: Profile } +public struct GroupMemberIds: Decodable { + var groupMemberId: Int64 + var groupId: Int64 +} + public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { case observer = "observer" case member = "member" @@ -1963,7 +1973,7 @@ public enum InvitedBy: Decodable { } public struct MemberSubError: Decodable { - var member: GroupMember + var member: GroupMemberIds var memberError: ChatError } 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 ac5f9fb8a5..a4b76cc79d 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 @@ -789,16 +789,19 @@ sealed class NetworkStatus { val statusExplanation: String get() = when (this) { is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) - is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error) + is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError) else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @Serializable @SerialName("connected") class Connected: NetworkStatus() @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() - @Serializable @SerialName("error") class Error(val error: String): NetworkStatus() + @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus() } +@Serializable +data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus) + @Serializable data class Contact( val contactId: Long, @@ -1051,6 +1054,9 @@ data class GroupInfo ( } } +@Serializable +data class GroupRef(val groupId: Long, val localDisplayName: String) + @Serializable data class GroupProfile ( override val displayName: String, @@ -1159,11 +1165,17 @@ data class GroupMember ( data class GroupMemberSettings(val showMessages: Boolean) {} @Serializable -class GroupMemberRef( +data class GroupMemberRef( val groupMemberId: Long, val profile: Profile ) +@Serializable +data class GroupMemberIds( + val groupMemberId: Long, + val groupId: Long +) + @Serializable enum class GroupMemberRole(val memberRole: String) { @SerialName("observer") Observer("observer"), // order matters in comparisons @@ -1257,7 +1269,7 @@ class LinkPreview ( @Serializable class MemberSubError ( - val member: GroupMember, + val member: GroupMemberIds, val memberError: ChatError ) 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 dc2c3c0f25..e19520093c 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 @@ -1082,6 +1082,13 @@ object ChatController { return r is CR.CmdOk } + suspend fun apiGetNetworkStatuses(): List? { + val r = sendCmd(CC.ApiGetNetworkStatuses()) + if (r is CR.NetworkStatuses) return r.networkStatuses + Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { val r = sendCmd(CC.ApiChatRead(type, id, range)) if (r is CR.CmdOk) return true @@ -1425,12 +1432,6 @@ object ChatController { } is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) - is CR.ContactSubError -> { - if (active(r.user)) { - chatModel.updateContact(r.contact) - } - processContactSubError(r.contact, r.chatError) - } is CR.ContactSubSummary -> { for (sub in r.contactSubscriptions) { if (active(r.user)) { @@ -1444,6 +1445,16 @@ object ChatController { } } } + is CR.NetworkStatusResp -> { + for (cId in r.connections) { + chatModel.networkStatuses[cId] = r.networkStatus + } + } + is CR.NetworkStatuses -> { + for (s in r.networkStatuses) { + chatModel.networkStatuses[s.agentConnId] = s.networkStatus + } + } is CR.NewChatItem -> { val cInfo = r.chatItem.chatInfo val cItem = r.chatItem.chatItem @@ -1915,6 +1926,7 @@ sealed class CC { class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiEndCall(val contact: Contact): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() + class ApiGetNetworkStatuses(): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() @@ -2024,6 +2036,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiEndCall -> "/_call end @${contact.apiId}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" + is ApiGetNetworkStatuses -> "/_network_statuses" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") @@ -2120,6 +2133,7 @@ sealed class CC { is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiEndCall -> "apiEndCall" is ApiCallStatus -> "apiCallStatus" + is ApiGetNetworkStatuses -> "apiGetNetworkStatuses" is ApiChatRead -> "apiChatRead" is ApiChatUnread -> "apiChatUnread" is ReceiveFile -> "receiveFile" @@ -3333,11 +3347,14 @@ sealed class CR { @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() + // TODO remove below @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List): CR() - @Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List): CR() - @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR() + // TODO remove above + @Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List): CR() + @Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List): CR() + @Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @@ -3467,8 +3484,9 @@ sealed class CR { is ContactUpdated -> "contactUpdated" is ContactsSubscribed -> "contactsSubscribed" is ContactsDisconnected -> "contactsDisconnected" - is ContactSubError -> "contactSubError" is ContactSubSummary -> "contactSubSummary" + is NetworkStatusResp -> "networkStatus" + is NetworkStatuses -> "networkStatuses" is GroupSubscribed -> "groupSubscribed" is MemberSubErrors -> "memberSubErrors" is GroupEmpty -> "groupEmpty" @@ -3596,8 +3614,9 @@ sealed class CR { is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" - is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}") is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) + is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections" + is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses)) is GroupSubscribed -> withUser(user, json.encodeToString(group)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is GroupEmpty -> withUser(user, json.encodeToString(group)) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b7421d9366..56077c3bf6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -143,7 +143,8 @@ defaultChatConfig = initialCleanupManagerDelay = 30 * 1000000, -- 30 seconds cleanupManagerInterval = 30 * 60, -- 30 minutes cleanupManagerStepDelay = 3 * 1000000, -- 3 seconds - ciExpirationInterval = 30 * 60 * 1000000 -- 30 minutes + ciExpirationInterval = 30 * 60 * 1000000, -- 30 minutes + coreApi = False } _defaultSMPServers :: NonEmpty SMPServerWithAuth @@ -195,6 +196,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg <- newTVarIO =<< liftIO drgNew inputQ <- newTBQueueIO tbqSize outputQ <- newTBQueueIO tbqSize + connNetworkStatuses <- atomically TM.empty subscriptionMode <- newTVarIO SMSubscribe chatLock <- newEmptyTMVarIO sndFiles <- newTVarIO M.empty @@ -221,6 +223,7 @@ newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agen idsDrg, inputQ, outputQ, + connNetworkStatuses, subscriptionMode, chatLock, sndFiles, @@ -1086,6 +1089,8 @@ processChatCommand = \case user <- getUserByContactId db contactId contact <- getContact db user contactId pure RcvCallInvitation {user, contact, callType = peerCallType, sharedKey, callTs} + APIGetNetworkStatuses -> withUser $ \_ -> + CRNetworkStatuses Nothing . map (uncurry ConnNetworkStatus) . M.toList <$> chatReadVar connNetworkStatuses APICallStatus contactId receivedStatus -> withCurrentCall contactId $ \user ct call -> updateCallItemStatus user ct call receivedStatus Nothing $> Just call @@ -1688,6 +1693,8 @@ processChatCommand = \case (connId, cReq) <- withAgent $ \a -> createConnection a (aUserId user) True SCMInvitation Nothing subMode -- [incognito] reuse membership incognito profile ct <- withStore' $ \db -> createMemberContact db user connId cReq g m mConn subMode + -- TODO not sure it is correct to set connections status here? + setContactNetworkStatus ct NSConnected pure $ CRNewMemberContact user ct g m _ -> throwChatError CEGroupMemberNotActive APISendMemberContactInvitation contactId msgContent_ -> withUser $ \user -> do @@ -2627,6 +2634,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do rs <- withAgent $ \a -> agentBatchSubscribe a conns -- send connection events to view contactSubsToView rs cts ce +-- TODO possibly, we could either disable these events or replace with less noisy for API contactLinkSubsToView rs ucs groupSubsToView rs gs ms ce sndFileSubsToView rs sfts @@ -2687,12 +2695,30 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do let connIds = map aConnId' pcs pure (connIds, M.fromList $ zip connIds pcs) contactSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId Contact -> Bool -> m () - contactSubsToView rs cts ce = do - toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs - when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + contactSubsToView rs cts ce = ifM (asks $ coreApi . config) notifyAPI notifyCLI where - cRs = resultsFor rs cts - cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + notifyCLI = do + let cRs = resultsFor rs cts + cErrors = sortOn (\(Contact {localDisplayName = n}, _) -> n) $ filterErrors cRs + toView . CRContactSubSummary user $ map (uncurry ContactSubStatus) cRs + when ce $ mapM_ (toView . uncurry (CRContactSubError user)) cErrors + notifyAPI = do + let statuses = M.foldrWithKey' addStatus [] cts + chatModifyVar connNetworkStatuses $ M.union (M.fromList statuses) + toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses + where + addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)] + addStatus connId ct = + let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs) + in (ns :) + netStatus :: Maybe ChatError -> NetworkStatus + netStatus = maybe NSConnected $ NSError . errorNetworkStatus + errorNetworkStatus :: ChatError -> String + errorNetworkStatus = \case + ChatErrorAgent (BROKER _ NETWORK) _ -> "network" + ChatErrorAgent (SMP SMP.AUTH) _ -> "contact deleted" + e -> show e +-- TODO possibly below could be replaced with less noisy events for API contactLinkSubsToView :: Map ConnId (Either AgentErrorType ()) -> Map ConnId UserContact -> m () contactLinkSubsToView rs = toView . CRUserContactSubSummary user . map (uncurry UserContactSubStatus) . resultsFor rs groupSubsToView :: Map ConnId (Either AgentErrorType ()) -> [Group] -> Map ConnId GroupMember -> Bool -> m () @@ -2742,12 +2768,12 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do resultsFor rs = M.foldrWithKey' addResult [] where addResult :: ConnId -> a -> [(a, Maybe ChatError)] -> [(a, Maybe ChatError)] - addResult connId = (:) . (,err) - where - err = case M.lookup connId rs of - Just (Left e) -> Just $ ChatErrorAgent e Nothing - Just _ -> Nothing - _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId + addResult connId = (:) . (,resultErr connId rs) + resultErr :: ConnId -> Map ConnId (Either AgentErrorType ()) -> Maybe ChatError + resultErr connId rs = case M.lookup connId rs of + Just (Left e) -> Just $ ChatErrorAgent e Nothing + Just _ -> Nothing + _ -> Just . ChatError . CEAgentNoSubResult $ AgentConnId connId cleanupManager :: forall m. ChatMonad m => m () cleanupManager = do @@ -2892,16 +2918,22 @@ processAgentMessageNoConn :: forall m. ChatMonad m => ACommand 'Agent 'AENone -> processAgentMessageNoConn = \case CONNECT p h -> hostEvent $ CRHostConnected p h DISCONNECT p h -> hostEvent $ CRHostDisconnected p h - DOWN srv conns -> serverEvent srv conns CRContactsDisconnected - UP srv conns -> serverEvent srv conns CRContactsSubscribed + DOWN srv conns -> serverEvent srv conns NSDisconnected CRContactsDisconnected + UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed SUSPENDED -> toView CRChatSuspended DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId where hostEvent :: ChatResponse -> m () hostEvent = whenM (asks $ hostEvents . config) . toView - serverEvent srv conns event = do - cs <- withStore' (`getConnectionsContacts` conns) - toView $ event srv cs + serverEvent srv conns nsStatus event = ifM (asks $ coreApi . config) notifyAPI notifyCLI + where + notifyAPI = do + let connIds = map AgentConnId conns + chatModifyVar connNetworkStatuses $ \m -> foldl' (\m' cId -> M.insert cId nsStatus m') m connIds + toView $ CRNetworkStatus nsStatus connIds + notifyCLI = do + cs <- withStore' (`getConnectionsContacts` conns) + toView $ event srv cs processAgentMsgSndFile :: forall m. ChatMonad m => ACorrId -> SndFileId -> ACommand 'Agent 'AESndFile -> m () processAgentMsgSndFile _corrId aFileId msg = @@ -3188,6 +3220,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do Nothing -> do -- [incognito] print incognito profile used for this contact incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId) + setContactNetworkStatus ct NSConnected toView $ CRContactConnected user ct (fmap fromLocalProfile incognitoProfile) when (directOrUsed ct) $ createFeatureEnabledItems ct when (contactConnInitiated conn) $ do @@ -3762,6 +3795,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do notifyMemberConnected :: GroupInfo -> GroupMember -> Maybe Contact -> m () notifyMemberConnected gInfo m ct_ = do memberConnectedChatItem gInfo m + mapM_ (`setContactNetworkStatus` NSConnected) ct_ toView $ CRConnectedToGroupMember user gInfo m ct_ probeMatchingContactsAndMembers :: Contact -> IncognitoEnabled -> Bool -> m () @@ -5569,6 +5603,7 @@ chatCommandP = "/_call end @" *> (APIEndCall <$> A.decimal), "/_call status @" *> (APICallStatus <$> A.decimal <* A.space <*> strP), "/_call get" $> APIGetCallInvitations, + "/_network_statuses" $> APIGetNetworkStatuses, "/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP), "/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), "/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")), diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index af9f34d2d4..2704c2721f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -32,6 +32,7 @@ import Data.Char (ord) import Data.Int (Int64) import Data.List.NonEmpty (NonEmpty) import Data.Map.Strict (Map) +import qualified Data.Map.Strict as M import Data.String import Data.Text (Text) import Data.Time (NominalDiffTime, UTCTime) @@ -124,7 +125,8 @@ data ChatConfig = ChatConfig initialCleanupManagerDelay :: Int64, cleanupManagerInterval :: NominalDiffTime, cleanupManagerStepDelay :: Int64, - ciExpirationInterval :: Int64 -- microseconds + ciExpirationInterval :: Int64, -- microseconds + coreApi :: Bool } data DefaultAgentServers = DefaultAgentServers @@ -164,6 +166,7 @@ data ChatController = ChatController idsDrg :: TVar ChaChaDRG, inputQ :: TBQueue String, outputQ :: TBQueue (Maybe CorrId, ChatResponse), + connNetworkStatuses :: TMap AgentConnId NetworkStatus, subscriptionMode :: TVar SubscriptionMode, chatLock :: Lock, sndFiles :: TVar (Map Int64 Handle), @@ -251,6 +254,7 @@ data ChatCommand | APIEndCall ContactId | APIGetCallInvitations | APICallStatus ContactId WebRTCCallStatus + | APIGetNetworkStatuses | APIUpdateProfile UserId Profile | APISetContactPrefs ContactId Preferences | APISetContactAlias ContactId LocalAlias @@ -528,6 +532,8 @@ data ChatResponse | CRContactSubError {user :: User, contact :: Contact, chatError :: ChatError} | CRContactSubSummary {user :: User, contactSubscriptions :: [ContactSubStatus]} | CRUserContactSubSummary {user :: User, userContactSubscriptions :: [UserContactSubStatus]} + | CRNetworkStatus {networkStatus :: NetworkStatus, connections :: [AgentConnId]} + | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRHostConnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRHostDisconnected {protocol :: AProtocolType, transportHost :: TransportHost} | CRGroupInvitation {user :: User, groupInfo :: GroupInfo} @@ -1044,6 +1050,13 @@ chatWriteVar :: ChatMonad' m => (ChatController -> TVar a) -> a -> m () chatWriteVar f value = asks f >>= atomically . (`writeTVar` value) {-# INLINE chatWriteVar #-} +chatModifyVar :: ChatMonad' m => (ChatController -> TVar a) -> (a -> a) -> m () +chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue) +{-# INLINE chatModifyVar #-} + +setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m () +setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct) + tryChatError :: ChatMonad m => m a -> m (Either ChatError a) tryChatError = tryAllErrors mkChatError {-# INLINE tryChatError #-} diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 0a970d2c8e..b444888145 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -169,7 +169,8 @@ defaultMobileConfig :: ChatConfig defaultMobileConfig = defaultChatConfig { confirmMigrations = MCYesUp, - logLevel = CLLError + logLevel = CLLError, + coreApi = True } getActiveUser_ :: SQLiteStore -> IO (Maybe User) diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 83d0664a04..9992f96ab0 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -192,6 +192,9 @@ instance ToJSON Contact where contactConn :: Contact -> Connection contactConn Contact {activeConn} = activeConn +contactAgentConnId :: Contact -> AgentConnId +contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId + contactConnId :: Contact -> ConnId contactConnId = aConnId . contactConn @@ -1140,13 +1143,16 @@ liveRcvFileTransferPath ft = fp <$> liveRcvFileTransferInfo ft fp RcvFileInfo {filePath} = filePath newtype AgentConnId = AgentConnId ConnId - deriving (Eq, Show) + deriving (Eq, Ord, Show) instance StrEncoding AgentConnId where strEncode (AgentConnId connId) = strEncode connId strDecode s = AgentConnId <$> strDecode s strP = AgentConnId <$> strP +instance FromJSON AgentConnId where + parseJSON = strParseJSON "AgentConnId" + instance ToJSON AgentConnId where toJSON = strToJSON toEncoding = strToJEncoding @@ -1477,6 +1483,35 @@ serializeIntroStatus = \case textParseJSON :: TextEncoding a => String -> J.Value -> JT.Parser a textParseJSON name = J.withText name $ maybe (fail $ "bad " <> name) pure . textDecode +data NetworkStatus + = NSUnknown + | NSConnected + | NSDisconnected + | NSError {connectionError :: String} + deriving (Eq, Ord, Show, Generic) + +netStatusStr :: NetworkStatus -> String +netStatusStr = \case + NSUnknown -> "unknown" + NSConnected -> "connected" + NSDisconnected -> "disconnected" + NSError e -> "error: " <> e + +instance FromJSON NetworkStatus where + parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "NS" + +instance ToJSON NetworkStatus where + toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "NS" + toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "NS" + +data ConnNetworkStatus = ConnNetworkStatus + { agentConnId :: AgentConnId, + networkStatus :: NetworkStatus + } + deriving (Show, Generic, FromJSON) + +instance ToJSON ConnNetworkStatus where toEncoding = J.genericToEncoding J.defaultOptions + type CommandId = Int64 aCorrId :: CommandId -> ACorrId diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 86a10988d6..bcbc45f4eb 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -19,7 +19,7 @@ import Data.Char (isSpace, toUpper) import Data.Function (on) import Data.Int (Int64) import Data.List (groupBy, intercalate, intersperse, partition, sortOn) -import Data.List.NonEmpty (NonEmpty) +import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) import qualified Data.Map.Strict as M @@ -210,6 +210,8 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView (addresses, groupLinks) = partition (\UserContactSubStatus {userContact} -> isNothing . userContactGroupId $ userContact) summary addressSS UserContactSubStatus {userContactError} = maybe ("Your address is active! To show: " <> highlight' "/sa") (\e -> "User address error: " <> sShow e <> ", to delete your address: " <> highlight' "/da") userContactError (groupLinkErrors, groupLinksSubscribed) = partition (isJust . userContactError) groupLinks + CRNetworkStatus status conns -> if testView then [plain $ show (length conns) <> " connections " <> netStatusStr status] else [] + CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRGroupInvitation u g -> ttyUser u [groupInvitation' g] CRReceivedGroupInvitation {user = u, groupInfo = g, contact = c, memberRole = r} -> ttyUser u $ viewReceivedGroupInvitation g c r CRUserJoinedGroup u g _ -> ttyUser u $ viewUserJoinedGroup g @@ -800,6 +802,12 @@ viewDirectMessagesProhibited :: MsgDirection -> Contact -> [StyledString] viewDirectMessagesProhibited MDSnd c = ["direct messages to indirect contact " <> ttyContact' c <> " are prohibited"] viewDirectMessagesProhibited MDRcv c = ["received prohibited direct message from indirect contact " <> ttyContact' c <> " (discarded)"] +viewNetworkStatuses :: [ConnNetworkStatus] -> [StyledString] +viewNetworkStatuses = map viewStatuses . L.groupBy ((==) `on` netStatus) . sortOn netStatus + where + netStatus ConnNetworkStatus {networkStatus} = networkStatus + viewStatuses ss@(s :| _) = plain $ show (L.length ss) <> " connections " <> netStatusStr (netStatus s) + viewUserJoinedGroup :: GroupInfo -> [StyledString] viewUserJoinedGroup g = case incognitoMembershipProfile g of diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index b4c3c53cd9..677bc4c09d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -119,6 +119,8 @@ chatDirectTests = do testReqVRange vr11 supportedChatVRange testReqVRange vr11 vr11 it "update peer version range on received messages" testUpdatePeerChatVRange + describe "network statuses" $ do + it "should get network statuses" testGetNetworkStatuses where testInvVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnInvChatVRange vr1 vr2 testReqVRange vr1 vr2 = it (vRangeStr vr1 <> " - " <> vRangeStr vr2) $ testConnReqChatVRange vr1 vr2 @@ -2623,6 +2625,20 @@ testUpdatePeerChatVRange tmp = where cfg11 = testCfg {chatVRange = vr11} :: ChatConfig +testGetNetworkStatuses :: HasCallStack => FilePath -> IO () +testGetNetworkStatuses tmp = do + withNewTestChatCfg tmp cfg "alice" aliceProfile $ \alice -> do + withNewTestChatCfg tmp cfg "bob" bobProfile $ \bob -> do + connectUsers alice bob + alice ##> "/_network_statuses" + alice <## "1 connections connected" + withTestChatCfg tmp cfg "alice" $ \alice -> + withTestChatCfg tmp cfg "bob" $ \bob -> do + alice <## "1 connections connected" + bob <## "1 connections connected" + where + cfg = testCfg {coreApi = True} + vr11 :: VersionRange vr11 = mkVersionRange 1 1 diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 692dd5884e..aa36b397a3 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -120,19 +120,19 @@ chatStartedSwift = "{\"resp\":{\"_owsf\":true,\"chatStarted\":{}}}" chatStartedTagged :: LB.ByteString chatStartedTagged = "{\"resp\":{\"type\":\"chatStarted\"}}" -contactSubSummary :: LB.ByteString -contactSubSummary = +networkStatuses :: LB.ByteString +networkStatuses = #if defined(darwin_HOST_OS) && defined(swiftJSON) - contactSubSummarySwift + networkStatusesSwift #else - contactSubSummaryTagged + networkStatusesTagged #endif -contactSubSummarySwift :: LB.ByteString -contactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"contactSubSummary\":{" <> userJSON <> ",\"contactSubscriptions\":[]}}}" +networkStatusesSwift :: LB.ByteString +networkStatusesSwift = "{\"resp\":{\"_owsf\":true,\"networkStatuses\":{\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}}" -contactSubSummaryTagged :: LB.ByteString -contactSubSummaryTagged = "{\"resp\":{\"type\":\"contactSubSummary\"," <> userJSON <> ",\"contactSubscriptions\":[]}}" +networkStatusesTagged :: LB.ByteString +networkStatusesTagged = "{\"resp\":{\"type\":\"networkStatuses\",\"user_\":" <> userJSON <> ",\"networkStatuses\":[]}}" memberSubSummary :: LB.ByteString memberSubSummary = @@ -143,10 +143,10 @@ memberSubSummary = #endif memberSubSummarySwift :: LB.ByteString -memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{" <> userJSON <> ",\"memberSubscriptions\":[]}}}" +memberSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"memberSubSummary\":{\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}}" memberSubSummaryTagged :: LB.ByteString -memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\"," <> userJSON <> ",\"memberSubscriptions\":[]}}" +memberSubSummaryTagged = "{\"resp\":{\"type\":\"memberSubSummary\",\"user\":" <> userJSON <> ",\"memberSubscriptions\":[]}}" userContactSubSummary :: LB.ByteString userContactSubSummary = @@ -157,10 +157,10 @@ userContactSubSummary = #endif userContactSubSummarySwift :: LB.ByteString -userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" +userContactSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"userContactSubSummary\":{\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}}" userContactSubSummaryTagged :: LB.ByteString -userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\"," <> userJSON <> ",\"userContactSubscriptions\":[]}}" +userContactSubSummaryTagged = "{\"resp\":{\"type\":\"userContactSubSummary\",\"user\":" <> userJSON <> ",\"userContactSubscriptions\":[]}}" pendingSubSummary :: LB.ByteString pendingSubSummary = @@ -171,13 +171,13 @@ pendingSubSummary = #endif pendingSubSummarySwift :: LB.ByteString -pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" +pendingSubSummarySwift = "{\"resp\":{\"_owsf\":true,\"pendingSubSummary\":{\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}}" pendingSubSummaryTagged :: LB.ByteString -pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\"," <> userJSON <> ",\"pendingSubscriptions\":[]}}" +pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}" userJSON :: LB.ByteString -userJSON = "\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" +userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}" parsedMarkdown :: LB.ByteString parsedMarkdown = @@ -215,7 +215,7 @@ testChatApi tmp = do chatSendCmd cc "/u" `shouldReturn` activeUser chatSendCmd cc "/create user alice Alice" `shouldReturn` activeUserExists chatSendCmd cc "/_start" `shouldReturn` chatStarted - chatRecvMsg cc `shouldReturn` contactSubSummary + chatRecvMsg cc `shouldReturn` networkStatuses chatRecvMsg cc `shouldReturn` userContactSubSummary chatRecvMsg cc `shouldReturn` memberSubSummary chatRecvMsgWait cc 10000 `shouldReturn` pendingSubSummary