diff --git a/apps/ios/Shared/AppDelegate.swift b/apps/ios/Shared/AppDelegate.swift index 268ebb1a75..6b70f0f054 100644 --- a/apps/ios/Shared/AppDelegate.swift +++ b/apps/ios/Shared/AppDelegate.swift @@ -50,7 +50,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { try await apiVerifyToken(token: token, nonce: nonce, code: verification) m.tokenStatus = .active } catch { - if let cr = error as? ChatResponse, case .chatCmdError(.errorAgent(.NTF(.AUTH))) = cr { + if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { m.tokenStatus = .expired } logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index d8a1efddf6..32c3ad19ad 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -16,7 +16,7 @@ final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var v3DBMigration: V3DBMigrationState = v3DBMigrationDefault.get() @Published var currentUser: User? - @Published var users: [UserInfo] = [] + @Published private(set) var users: [UserInfo] = [] @Published var chatInitialized = false @Published var chatRunning: Bool? @Published var chatDbChanged = false @@ -177,6 +177,7 @@ final class ChatModel: ObservableObject { chats[i].chatItems = [cItem] if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 + increaseUnreadCounter(user: currentUser!) NtfManager.shared.incNtfBadgeCount() } if i > 0 { @@ -344,6 +345,7 @@ final class ChatModel: ObservableObject { if markedCount > 0 { NtfManager.shared.decNtfBadgeCount(by: markedCount) chat.chatStats.unreadCount -= markedCount + self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) } } } @@ -395,6 +397,21 @@ final class ChatModel: ObservableObject { func decreaseUnreadCounter(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount - 1 + decreaseUnreadCounter(user: currentUser!) + } + } + + func increaseUnreadCounter(user: User) { + changeUnreadCounter(user: user, by: 1) + } + + func decreaseUnreadCounter(user: User, by: Int = 1) { + changeUnreadCounter(user: user, by: -by) + } + + private func changeUnreadCounter(user: User, by: Int) { + if let i = users.firstIndex(where: { $0.user.id == user.id }) { + users[i].unreadCount += Int64(by) } } @@ -480,6 +497,31 @@ final class ChatModel: ObservableObject { while i < maxIx && inView(i) { i += 1 } return reversedChatItems[min(i - 1, maxIx)] } + + func updateUsers(_ new: [UserInfo]) { + users = new + .sorted { $0.user.chatViewName.compare($1.user.chatViewName) == .orderedAscending } + .sorted { first, _ in first.user.activeUser } + } + + func changeActiveUser(_ toUserId: Int64) { + do { + let activeUser = try apiSetActiveUser(toUserId) + var users = users + let oldActiveIndex = users.firstIndex(where: { $0.user.userId == currentUser?.userId })! + var oldActive = users[oldActiveIndex] + oldActive.user.activeUser = false + users[oldActiveIndex] = oldActive + + currentUser = activeUser + let currentActiveIndex = users.firstIndex(where: { $0.user.userId == activeUser.userId })! + users[currentActiveIndex] = UserInfo(user: activeUser, unreadCount: users[currentActiveIndex].unreadCount) + updateUsers(users) + try getUserChatData(self) + } catch { + logger.error("Unable to set active user: \(error.localizedDescription)") + } + } } struct UnreadChatItemCounts { diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 398e9eb378..b817b44857 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -28,7 +28,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { private var granted = false private var prevNtfTime: Dictionary = [:] - // Handle notification when app is in background func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -38,6 +37,10 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { let chatModel = ChatModel.shared let action = response.actionIdentifier logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)") + if let userId = content.userInfo["userId"] as? Int64, + userId != chatModel.currentUser?.userId { + chatModel.changeActiveUser(userId) + } if content.categoryIdentifier == ntfCategoryContactRequest && action == ntfActionAcceptContact, let chatId = content.userInfo["chatId"] as? String { if case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo { @@ -189,20 +192,20 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { center.delegate = self } - func notifyContactRequest(_ contactRequest: UserContactRequest) { + func notifyContactRequest(_ user: User, _ contactRequest: UserContactRequest) { logger.debug("NtfManager.notifyContactRequest") - addNotification(createContactRequestNtf(contactRequest)) + addNotification(createContactRequestNtf(user, contactRequest)) } - func notifyContactConnected(_ contact: Contact) { + func notifyContactConnected(_ user: User, _ contact: Contact) { logger.debug("NtfManager.notifyContactConnected") - addNotification(createContactConnectedNtf(contact)) + addNotification(createContactConnectedNtf(user, contact)) } - func notifyMessageReceived(_ cInfo: ChatInfo, _ cItem: ChatItem) { + func notifyMessageReceived(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) { logger.debug("NtfManager.notifyMessageReceived") if cInfo.ntfsEnabled { - addNotification(createMessageReceivedNtf(cInfo, cItem)) + addNotification(createMessageReceivedNtf(user, cInfo, cItem)) } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index f80605239a..51c46520aa 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -120,7 +120,7 @@ func apiGetActiveUser() throws -> User? { let r = chatSendCmdSync(.showActiveUser) switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: throw r } } @@ -209,19 +209,19 @@ func apiStorageEncryption(currentKey: String = "", newKey: String = "") async th func apiGetChats() throws -> [ChatData] { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetChats: no current user") } let r = chatSendCmdSync(.apiGetChats(userId: userId)) - if case let .apiChats(chats) = r { return chats } + if case let .apiChats(_, chats) = r { return chats } throw r } func apiGetChat(type: ChatType, id: Int64, search: String = "") throws -> Chat { let r = chatSendCmdSync(.apiGetChat(type: type, id: id, pagination: .last(count: 50), search: search)) - if case let .apiChat(chat) = r { return Chat.init(chat) } + if case let .apiChat(_, chat) = r { return Chat.init(chat) } throw r } func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, search: String = "") async throws -> [ChatItem] { let r = await chatSendCmd(.apiGetChat(type: type, id: id, pagination: pagination, search: search)) - if case let .apiChat(chat) = r { return chat.chatItems } + if case let .apiChat(_, chat) = r { return chat.chatItems } throw r } @@ -246,7 +246,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 var cItem: ChatItem! let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } }) r = await chatSendCmd(cmd, bgTask: false) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { cItem = aChatItem.chatItem chatModel.messageDelivery[cItem.id] = endTask return cItem @@ -258,7 +258,7 @@ func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int6 return nil } else { r = await chatSendCmd(cmd, bgDelay: msgDelay) - if case let .newChatItem(aChatItem) = r { + if case let .newChatItem(_, aChatItem) = r { return aChatItem.chatItem } sendMessageErrorAlert(r) @@ -276,13 +276,13 @@ private func sendMessageErrorAlert(_ r: ChatResponse) { func apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool = false) async throws -> ChatItem { let r = await chatSendCmd(.apiUpdateChatItem(type: type, id: id, itemId: itemId, msg: msg, live: live), bgDelay: msgDelay) - if case let .chatItemUpdated(aChatItem) = r { return aChatItem.chatItem } + if case let .chatItemUpdated(_, aChatItem) = r { return aChatItem.chatItem } throw r } func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) { let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay) - if case let .chatItemDeleted(deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } + if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) } throw r } @@ -290,7 +290,7 @@ func apiGetNtfToken() -> (DeviceToken?, NtfTknStatus?, NotificationsMode) { let r = chatSendCmdSync(.apiGetNtfToken) switch r { case let .ntfToken(token, status, ntfMode): return (token, status, ntfMode) - case .chatCmdError(.errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) + case .chatCmdError(_, .errorAgent(.CMD(.PROHIBITED))): return (nil, nil, .off) default: logger.debug("apiGetNtfToken response: \(String(describing: r), privacy: .public)") return (nil, nil, .off) @@ -331,7 +331,7 @@ func apiDeleteToken(token: DeviceToken) async throws { func getUserSMPServers() throws -> ([ServerCfg], [String]) { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getUserSMPServers: no current user") } let r = chatSendCmdSync(.apiGetUserSMPServers(userId: userId)) - if case let .userSMPServers(smpServers, presetServers) = r { return (smpServers, presetServers) } + if case let .userSMPServers(_, smpServers, presetServers) = r { return (smpServers, presetServers) } throw r } @@ -343,7 +343,7 @@ func setUserSMPServers(smpServers: [ServerCfg]) async throws { func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("testSMPServer: no current user") } let r = await chatSendCmd(.testSMPServer(userId: userId, smpServer: smpServer)) - if case let .smpTestResult(testFailure) = r { + if case let .smpTestResult(_, testFailure) = r { if let t = testFailure { return .failure(t) } @@ -355,7 +355,7 @@ func testSMPServer(smpServer: String) async throws -> Result<(), SMPTestFailure> func getChatItemTTL() throws -> ChatItemTTL { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("getChatItemTTL: no current user") } let r = chatSendCmdSync(.apiGetChatItemTTL(userId: userId)) - if case let .chatItemTTL(chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } + if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) } throw r } @@ -382,13 +382,13 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) - if case let .contactInfo(_, connStats, customUserProfile) = r { return (connStats, customUserProfile) } + if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } throw r } func apiGroupMemberInfo(_ groupId: Int64, _ groupMemberId: Int64) throws -> (ConnectionStats?) { let r = chatSendCmdSync(.apiGroupMemberInfo(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberInfo(_, _, connStats_) = r { return (connStats_) } + if case let .groupMemberInfo(_, _, _, connStats_) = r { return (connStats_) } throw r } @@ -402,26 +402,26 @@ func apiSwitchGroupMember(_ groupId: Int64, _ groupMemberId: Int64) async throws func apiGetContactCode(_ contactId: Int64) async throws -> (Contact, String) { let r = await chatSendCmd(.apiGetContactCode(contactId: contactId)) - if case let .contactCode(contact, connectionCode) = r { return (contact, connectionCode) } + if case let .contactCode(_, contact, connectionCode) = r { return (contact, connectionCode) } throw r } func apiGetGroupMemberCode(_ groupId: Int64, _ groupMemberId: Int64) throws -> (GroupMember, String) { let r = chatSendCmdSync(.apiGetGroupMemberCode(groupId: groupId, groupMemberId: groupMemberId)) - if case let .groupMemberCode(_, member, connectionCode) = r { return (member, connectionCode) } + if case let .groupMemberCode(_, _, member, connectionCode) = r { return (member, connectionCode) } throw r } func apiVerifyContact(_ contactId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyContact(contactId: contactId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyContact error: \(String(describing: r))") return nil } func apiVerifyGroupMember(_ groupId: Int64, _ groupMemberId: Int64, connectionCode: String?) -> (Bool, String)? { let r = chatSendCmdSync(.apiVerifyGroupMember(groupId: groupId, groupMemberId: groupMemberId, connectionCode: connectionCode)) - if case let .connectionVerified(verified, expectedCode) = r { return (verified, expectedCode) } + if case let .connectionVerified(_, verified, expectedCode) = r { return (verified, expectedCode) } logger.error("apiVerifyGroupMember error: \(String(describing: r))") return nil } @@ -432,7 +432,7 @@ func apiAddContact() async -> String? { return nil } let r = await chatSendCmd(.apiAddContact(userId: userId), bgTask: false) - if case let .invitation(connReqInvitation) = r { return connReqInvitation } + if case let .invitation(_, connReqInvitation) = r { return connReqInvitation } connectionErrorAlert(r) return nil } @@ -447,7 +447,7 @@ func apiConnect(connReq: String) async -> ConnReqType? { switch r { case .sentConfirmation: return .invitation case .sentInvitation: return .contact - case let .contactAlreadyExists(contact): + case let .contactAlreadyExists(_, contact): let m = ChatModel.shared if let c = m.getContactChat(contact.contactId) { await MainActor.run { m.chatId = c.id } @@ -457,19 +457,19 @@ func apiConnect(connReq: String) async -> ConnReqType? { message: "You are already connected to \(contact.displayName)." ) return nil - case .chatCmdError(.error(.invalidConnReq)): + case .chatCmdError(_, .error(.invalidConnReq)): am.showAlertMsg( title: "Invalid connection link", message: "Please check that you used the correct link or ask your contact to send you another one." ) return nil - case .chatCmdError(.errorAgent(.SMP(.AUTH))): + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): am.showAlertMsg( title: "Connection error (AUTH)", message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection." ) return nil - case let .chatCmdError(.errorAgent(.INTERNAL(internalErr))): + case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))): if internalErr == "SEUniqueID" { am.showAlertMsg( title: "Already connected?", @@ -516,7 +516,7 @@ func deleteChat(_ chat: Chat) async { func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo { let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false) - if case let .chatCleared(updatedChatInfo) = r { return updatedChatInfo } + if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo } throw r } @@ -533,7 +533,7 @@ func clearChat(_ chat: Chat) async { func apiListContacts() throws -> [Contact] { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiListContacts: no current user") } let r = chatSendCmdSync(.apiListContacts(userId: userId)) - if case let .contactsList(contacts) = r { return contacts } + if case let .contactsList(_, contacts) = r { return contacts } throw r } @@ -542,33 +542,33 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) switch r { case .userProfileNoChange: return nil - case let .userProfileUpdated(_, toProfile): return toProfile + case let .userProfileUpdated(_, _, toProfile): return toProfile default: throw r } } func apiSetContactPrefs(contactId: Int64, preferences: Preferences) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactPrefs(contactId: contactId, preferences: preferences)) - if case let .contactPrefsUpdated(_, toContact) = r { return toContact } + if case let .contactPrefsUpdated(_, _, toContact) = r { return toContact } throw r } func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Contact? { let r = await chatSendCmd(.apiSetContactAlias(contactId: contactId, localAlias: localAlias)) - if case let .contactAliasUpdated(toContact) = r { return toContact } + if case let .contactAliasUpdated(_, toContact) = r { return toContact } throw r } func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? { let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias)) - if case let .connectionAliasUpdated(toConnection) = r { return toConnection } + if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection } throw r } func apiCreateUserAddress() async throws -> String { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiCreateUserAddress: no current user") } let r = await chatSendCmd(.apiCreateMyAddress(userId: userId)) - if case let .userContactLinkCreated(connReq) = r { return connReq } + if case let .userContactLinkCreated(_, connReq) = r { return connReq } throw r } @@ -583,8 +583,8 @@ func apiGetUserAddress() throws -> UserContactLink? { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiGetUserAddress: no current user") } let r = chatSendCmdSync(.apiShowMyAddress(userId: userId)) switch r { - case let .userContactLink(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLink(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } @@ -593,8 +593,8 @@ func userAddressAutoAccept(_ autoAccept: AutoAccept?) async throws -> UserContac guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("userAddressAutoAccept: no current user") } let r = await chatSendCmd(.apiAddressAutoAccept(userId: userId, autoAccept: autoAccept)) switch r { - case let .userContactLinkUpdated(contactLink): return contactLink - case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil + case let .userContactLinkUpdated(_, contactLink): return contactLink + case .chatCmdError(_, chatError: .errorStore(storeError: .userContactLinkNotFound)): return nil default: throw r } } @@ -603,8 +603,8 @@ func apiAcceptContactRequest(contactReqId: Int64) async -> Contact? { let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId)) let am = AlertManager.shared - if case let .acceptingContactRequest(contact) = r { return contact } - if case .chatCmdError(.errorAgent(.SMP(.AUTH))) = r { + if case let .acceptingContactRequest(_, contact) = r { return contact } + if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r { am.showAlertMsg( title: "Connection error (AUTH)", message: "Sender may have deleted the connection request." @@ -643,7 +643,7 @@ func receiveFile(fileId: Int64) async { func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { let r = await chatSendCmd(.receiveFile(fileId: fileId, inline: inline)) let am = AlertManager.shared - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } if case .rcvFileAcceptedSndCancelled = r { am.showAlertMsg( title: "Cannot receive file", @@ -652,7 +652,7 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { } else if !networkErrorAlert(r) { logger.error("apiReceiveFile error: \(String(describing: r))") switch r { - case .chatCmdError(.error(.fileAlreadyReceiving)): + case .chatCmdError(_, .error(.fileAlreadyReceiving)): logger.debug("apiReceiveFile ignoring fileAlreadyReceiving error") default: am.showAlertMsg( @@ -667,13 +667,13 @@ func apiReceiveFile(fileId: Int64, inline: Bool) async -> AChatItem? { func networkErrorAlert(_ r: ChatResponse) -> Bool { let am = AlertManager.shared switch r { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))): am.showAlertMsg( title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again." ) return true - case let .chatCmdError(.errorAgent(.BROKER(addr, .NETWORK))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): am.showAlertMsg( title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again." @@ -788,13 +788,13 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws { func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { guard let userId = ChatModel.shared.currentUser?.userId else { throw RuntimeError("apiNewGroup: no current user") } let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) - if case let .groupCreated(groupInfo) = r { return groupInfo } + if case let .groupCreated(_, groupInfo) = r { return groupInfo } throw r } func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole)) - if case let .sentGroupInvitation(_, _, member) = r { return member } + if case let .sentGroupInvitation(_, _, _, member) = r { return member } throw r } @@ -807,22 +807,22 @@ enum JoinGroupResult { func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { let r = await chatSendCmd(.apiJoinGroup(groupId: groupId)) switch r { - case let .userAcceptedGroupSent(groupInfo, _): return .joined(groupInfo: groupInfo) - case .chatCmdError(.errorAgent(.SMP(.AUTH))): return .invitationRemoved - case .chatCmdError(.errorStore(.groupNotFound)): return .groupNotFound + case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo) + case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved + case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound default: throw r } } func apiRemoveMember(_ groupId: Int64, _ memberId: Int64) async throws -> GroupMember { let r = await chatSendCmd(.apiRemoveMember(groupId: groupId, memberId: memberId), bgTask: false) - if case let .userDeletedMember(_, member) = r { return member } + if case let .userDeletedMember(_, _, member) = r { return member } throw r } func apiMemberRole(_ groupId: Int64, _ memberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { let r = await chatSendCmd(.apiMemberRole(groupId: groupId, memberId: memberId, memberRole: memberRole), bgTask: false) - if case let .memberRoleUser(_, member, _, _) = r { return member } + if case let .memberRoleUser(_, _, member, _, _) = r { return member } throw r } @@ -837,19 +837,19 @@ func leaveGroup(_ groupId: Int64) async { func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo { let r = await chatSendCmd(.apiLeaveGroup(groupId: groupId), bgTask: false) - if case let .leftMemberUser(groupInfo) = r { return groupInfo } + if case let .leftMemberUser(_, groupInfo) = r { return groupInfo } throw r } func apiListMembers(_ groupId: Int64) async -> [GroupMember] { let r = await chatSendCmd(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } func apiListMembersSync(_ groupId: Int64) -> [GroupMember] { let r = chatSendCmdSync(.apiListMembers(groupId: groupId)) - if case let .groupMembers(group) = r { return group.members } + if case let .groupMembers(_, group) = r { return group.members } return [] } @@ -863,13 +863,13 @@ func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { func apiUpdateGroup(_ groupId: Int64, _ groupProfile: GroupProfile) async throws -> GroupInfo { let r = await chatSendCmd(.apiUpdateGroupProfile(groupId: groupId, groupProfile: groupProfile)) - if case let .groupUpdated(toGroup) = r { return toGroup } + if case let .groupUpdated(_, toGroup) = r { return toGroup } throw r } func apiCreateGroupLink(_ groupId: Int64) async throws -> String { let r = await chatSendCmd(.apiCreateGroupLink(groupId: groupId)) - if case let .groupLinkCreated(_, connReq) = r { return connReq } + if case let .groupLinkCreated(_, _, connReq) = r { return connReq } throw r } @@ -882,9 +882,9 @@ func apiDeleteGroupLink(_ groupId: Int64) async throws { func apiGetGroupLink(_ groupId: Int64) throws -> String? { let r = chatSendCmdSync(.apiGetGroupLink(groupId: groupId)) switch r { - case let .groupLink(_, connReq): + case let .groupLink(_, _, connReq): return connReq - case .chatCmdError(chatError: .errorStore(storeError: .groupLinkNotFound)): + case .chatCmdError(_, chatError: .errorStore(storeError: .groupLinkNotFound)): return nil default: throw r } @@ -917,9 +917,9 @@ func startChat() throws { let m = ChatModel.shared try setNetworkConfig(getNetCfg()) let justStarted = try apiStartChat() + m.updateUsers(listUsers()) if justStarted { try getUserChatData(m) - m.users = listUsers() NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() @@ -927,7 +927,7 @@ func startChat() throws { registerToken(token: token) } withAnimation { - m.onboardingStage = m.onboardingStage == .step2_CreateProfile + m.onboardingStage = m.onboardingStage == .step2_CreateProfile && m.users.count == 1 ? .step3_SetNotificationsMode : .onboardingComplete } @@ -988,25 +988,31 @@ func processReceivedMsg(_ res: ChatResponse) async { m.terminalItems.append(.resp(.now, res)) logger.debug("processReceivedMsg: \(res.responseType)") switch res { - case let .newContactConnection(connection): - m.updateContactConnection(connection) - case let .contactConnectionDeleted(connection): - m.removeChat(connection.id) - case let .contactConnected(contact, _): - if contact.directOrUsed { + case let .newContactConnection(user, connection): + if active(user) { + m.updateContactConnection(connection) + } + case let .contactConnectionDeleted(user, connection): + if active(user) { + m.removeChat(connection.id) + } + case let .contactConnected(user, contact, _): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) m.updateNetworkStatus(contact.id, .connected) - NtfManager.shared.notifyContactConnected(contact) + NtfManager.shared.notifyContactConnected(user, contact) } - case let .contactConnecting(contact): - if contact.directOrUsed { + case let .contactConnecting(user, contact): + if active(user) && contact.directOrUsed { m.updateContact(contact) m.dismissConnReqView(contact.activeConn.id) m.removeChat(contact.activeConn.id) } - case let .receivedContactRequest(contactRequest): + case let .receivedContactRequest(user, contactRequest): + if !active(user) { return } + let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest) if m.hasChat(contactRequest.id) { m.updateChatInfo(cInfo) @@ -1015,27 +1021,34 @@ func processReceivedMsg(_ res: ChatResponse) async { chatInfo: cInfo, chatItems: [] )) - NtfManager.shared.notifyContactRequest(contactRequest) + NtfManager.shared.notifyContactRequest(user, contactRequest) } - case let .contactUpdated(toContact): - let cInfo = ChatInfo.direct(contact: toContact) - if m.hasChat(toContact.id) { + case let .contactUpdated(user, toContact): + if active(user) && m.hasChat(toContact.id) { + let cInfo = ChatInfo.direct(contact: toContact) m.updateChatInfo(cInfo) } - case let .contactsMerged(intoContact, mergedContact): - if m.hasChat(mergedContact.id) { + case let .contactsMerged(user, intoContact, mergedContact): + if active(user) && m.hasChat(mergedContact.id) { if m.chatId == mergedContact.id { m.chatId = intoContact.id } m.removeChat(mergedContact.id) } - case let .contactsSubscribed(_, contactRefs): - updateContactsStatus(contactRefs, status: .connected) - case let .contactsDisconnected(_, contactRefs): - updateContactsStatus(contactRefs, status: .disconnected) - case let .contactSubError(contact, chatError): - processContactSubError(contact, chatError) - case let .contactSubSummary(contactSubscriptions): + case let .contactsSubscribed(user, _, contactRefs): + if active(user) { + updateContactsStatus(contactRefs, status: .connected) + } + case let .contactsDisconnected(user, _, contactRefs): + if active(user) { + updateContactsStatus(contactRefs, status: .disconnected) + } + case let .contactSubError(user, contact, chatError): + if active(user) { + processContactSubError(contact, chatError) + } + case let .contactSubSummary(user, contactSubscriptions): + if !active(user) { return } for sub in contactSubscriptions { if let err = sub.contactError { processContactSubError(sub.contact, err) @@ -1044,7 +1057,14 @@ func processReceivedMsg(_ res: ChatResponse) async { m.updateNetworkStatus(sub.contact.id, .connected) } } - case let .newChatItem(aChatItem): + case let .newChatItem(user, aChatItem): + if !active(user) { + if case .rcvNew = aChatItem.chatItem.meta.itemStatus { + m.increaseUnreadCounter(user: user) + } + return + } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem m.addChatItem(cInfo, cItem) @@ -1060,9 +1080,11 @@ func processReceivedMsg(_ res: ChatResponse) async { } } if cItem.showNotification { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } - case let .chatItemStatusUpdated(aChatItem): + case let .chatItemStatusUpdated(user, aChatItem): + if !active(user) { return } + let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem var res = false @@ -1070,7 +1092,7 @@ func processReceivedMsg(_ res: ChatResponse) async { res = m.upsertChatItem(cInfo, cItem) } if res { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(user, cInfo, cItem) } else if let endTask = m.messageDelivery[cItem.id] { switch cItem.meta.itemStatus { case .sndSent: endTask() @@ -1079,48 +1101,87 @@ func processReceivedMsg(_ res: ChatResponse) async { default: break } } - case let .chatItemUpdated(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, _): + case let .chatItemUpdated(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .chatItemDeleted(user, deletedChatItem, toChatItem, _): + if !active(user) { + if toChatItem == nil && deletedChatItem.chatItem.isRcvNew { + m.decreaseUnreadCounter(user: user) + } + return + } + if let toChatItem = toChatItem { _ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem) } else { m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem) } - case let .receivedGroupInvitation(groupInfo, _, _): - m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated - // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? - case let .userAcceptedGroupSent(groupInfo, hostContact): + case let .receivedGroupInvitation(user, groupInfo, _, _): + if active(user) { + m.updateGroup(groupInfo) // update so that repeat group invitations are not duplicated + // NtfManager.shared.notifyContactRequest(contactRequest) // TODO notifyGroupInvitation? + } + case let .userAcceptedGroupSent(user, groupInfo, hostContact): + if !active(user) { return } + m.updateGroup(groupInfo) if let hostContact = hostContact { m.dismissConnReqView(hostContact.activeConn.id) m.removeChat(hostContact.activeConn.id) } - case let .joinedGroupMemberConnecting(groupInfo, _, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .deletedMemberUser(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .deletedMember(groupInfo, _, deletedMember): - _ = m.upsertGroupMember(groupInfo, deletedMember) - case let .leftMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupDeleted(groupInfo, _): // TODO update user member - m.updateGroup(groupInfo) - case let .userJoinedGroup(groupInfo): - m.updateGroup(groupInfo) - case let .joinedGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .connectedToGroupMember(groupInfo, member): - _ = m.upsertGroupMember(groupInfo, member) - case let .groupUpdated(toGroup): - m.updateGroup(toGroup) - case let .rcvFileStart(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .rcvFileComplete(aChatItem): - chatItemSimpleUpdate(aChatItem) - case let .sndFileStart(aChatItem, _): - chatItemSimpleUpdate(aChatItem) - case let .sndFileComplete(aChatItem, _): + case let .joinedGroupMemberConnecting(user, groupInfo, _, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .deletedMemberUser(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .deletedMember(user, groupInfo, _, deletedMember): + if active(user) { + _ = m.upsertGroupMember(groupInfo, deletedMember) + } + case let .leftMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupDeleted(user, groupInfo, _): // TODO update user member + if active(user) { + m.updateGroup(groupInfo) + } + case let .userJoinedGroup(user, groupInfo): + if active(user) { + m.updateGroup(groupInfo) + } + case let .joinedGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .connectedToGroupMember(user, groupInfo, member): + if active(user) { + _ = m.upsertGroupMember(groupInfo, member) + } + case let .groupUpdated(user, toGroup): + if active(user) { + m.updateGroup(toGroup) + } + case let .rcvFileStart(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .rcvFileComplete(user, aChatItem): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileStart(user, aChatItem, _): + if active(user) { + chatItemSimpleUpdate(aChatItem) + } + case let .sndFileComplete(user, aChatItem, _): + if !active(user) { return } + chatItemSimpleUpdate(aChatItem) let cItem = aChatItem.chatItem let mc = cItem.content.msgContent @@ -1145,7 +1206,7 @@ func processReceivedMsg(_ res: ChatResponse) async { // logger.debug("reportNewIncomingVoIPPushPayload success for \(contact.id)") // } // } - case let .callOffer(contact, callType, offer, sharedKey, _): + case let .callOffer(_, contact, callType, offer, sharedKey, _): withCall(contact) { call in call.callState = .offerReceived call.peerMedia = callType.media @@ -1163,16 +1224,16 @@ func processReceivedMsg(_ res: ChatResponse) async { relay: useRelay ) } - case let .callAnswer(contact, answer): + case let .callAnswer(_, contact, answer): withCall(contact) { call in call.callState = .answerReceived m.callCommand = .answer(answer: answer.rtcSession, iceCandidates: answer.rtcIceCandidates) } - case let .callExtraInfo(contact, extraInfo): + case let .callExtraInfo(_, contact, extraInfo): withCall(contact) { _ in m.callCommand = .ice(iceCandidates: extraInfo.rtcIceCandidates) } - case let .callEnded(contact): + case let .callEnded(_, contact): if let invitation = m.callInvitations.removeValue(forKey: contact.id) { CallController.shared.reportCallRemoteEnded(invitation: invitation) } @@ -1186,6 +1247,10 @@ func processReceivedMsg(_ res: ChatResponse) async { logger.debug("unsupported event: \(res.responseType)") } + func active(_ user: User) -> Bool { + user.id == m.currentUser?.id + } + func withCall(_ contact: Contact, _ perform: (Call) -> Void) { if let call = m.activeCall, call.contact.apiId == contact.apiId { perform(call) @@ -1201,7 +1266,7 @@ func chatItemSimpleUpdate(_ aChatItem: AChatItem) { let cInfo = aChatItem.chatInfo let cItem = aChatItem.chatItem if m.upsertChatItem(cInfo, cItem) { - NtfManager.shared.notifyMessageReceived(cInfo, cItem) + NtfManager.shared.notifyMessageReceived(m.currentUser!, cInfo, cItem) } } diff --git a/apps/ios/Shared/Views/Call/CallManager.swift b/apps/ios/Shared/Views/Call/CallManager.swift index 962d783bcb..7da22919e9 100644 --- a/apps/ios/Shared/Views/Call/CallManager.swift +++ b/apps/ios/Shared/Views/Call/CallManager.swift @@ -36,6 +36,7 @@ class CallManager { func answerIncomingCall(invitation: RcvCallInvitation) { let m = ChatModel.shared + // TODO: change active user m.callInvitations.removeValue(forKey: invitation.contact.id) m.activeCall = Call( direction: .incoming, diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index 81c642625b..59ecad36e7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -416,9 +416,9 @@ struct ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { switch error as? ChatResponse { - case let .chatCmdError(.errorAgent(.BROKER(addr, .TIMEOUT))): + 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))): + case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))): return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.") default: return ErrorAlert(title: title, message: "Error: \(responseError(error))") diff --git a/apps/ios/Shared/Views/ChatList/UserPicker.swift b/apps/ios/Shared/Views/ChatList/UserPicker.swift index 75d96e2fc6..4f817c8753 100644 --- a/apps/ios/Shared/Views/ChatList/UserPicker.swift +++ b/apps/ios/Shared/Views/ChatList/UserPicker.swift @@ -29,45 +29,56 @@ struct UserPicker: View { Spacer().frame(height: 1) VStack(spacing: 0) { ScrollView { - VStack(spacing: 0) { - ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in - Button(action: { - if !userInfo.user.activeUser { - changeActiveUser(toUser: userInfo) - } - }, label: { - HStack(spacing: 0) { - ProfileImage(imageStr: userInfo.user.image) + ScrollViewReader { sp in + VStack(spacing: 0) { + ForEach(Array(chatModel.users.enumerated()), id: \.0) { i, userInfo in + Button(action: { + if !userInfo.user.activeUser { + chatModel.changeActiveUser(userInfo.user.userId) + userPickerVisible = false + } + }, label: { + HStack(spacing: 0) { + ProfileImage(imageStr: userInfo.user.image) .frame(width: 44, height: 44) .padding(.trailing, 12) - Text(userInfo.user.chatViewName) + Text(userInfo.user.chatViewName) .fontWeight(i == 0 ? .medium : .regular) .foregroundColor(.primary) .overlay(DetermineWidth()) - Spacer() - if i == 0 { - Image(systemName: "checkmark") - } else if userInfo.unreadCount > 0 { - unreadCounter(userInfo.unreadCount) + Spacer() + if i == 0 { + Image(systemName: "checkmark") + } else if userInfo.unreadCount > 0 { + unreadCounter(userInfo.unreadCount) + } } + .padding(.trailing) + .padding([.leading, .vertical], 12) + }) + .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) + if i < chatModel.users.count - 1 { + Divider() } - .padding(.trailing) - .padding([.leading, .vertical], 12) - }) - .buttonStyle(PressedButtonStyle(defaultColor: fillColor, pressedColor: Color(uiColor: .secondarySystemFill))) - if i < chatModel.users.count - 1 { - Divider() } } - } - .overlay { - GeometryReader { geo -> Color in - DispatchQueue.main.async { - scrollViewContentSize = geo.size - let layoutFrame = UIApplication.shared.windows[0].safeAreaLayoutGuide.layoutFrame - disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + .overlay { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + scrollViewContentSize = geo.size + let scenes = UIApplication.shared.connectedScenes + if let windowScene = scenes.first as? UIWindowScene { + let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame + disableScrolling = scrollViewContentSize.height + menuButtonHeight * 2 + 10 < layoutFrame.height + } + } + return Color.clear + } + } + .onChange(of: userPickerVisible) { visible in + if visible { + sp.scrollTo(0) } - return Color.clear } } } @@ -108,36 +119,15 @@ struct UserPicker: View { private func reloadCurrentUser() { if let updatedUser = chatModel.currentUser, let index = chatModel.users.firstIndex(where: { $0.user.userId == updatedUser.userId }) { - let removed = chatModel.users.remove(at: index) - chatModel.users.insert(UserInfo(user: updatedUser, unreadCount: removed.unreadCount), at: 0) - } - } - - private func changeActiveUser(toUser: UserInfo) { - Task { - do { - let activeUser = try apiSetActiveUser(toUser.user.userId) - let oldActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == chatModel.currentUser?.userId })! - var oldActive = chatModel.users[oldActiveIndex] - oldActive.user.activeUser = false - chatModel.users[oldActiveIndex] = oldActive - - chatModel.currentUser = activeUser - let currentActiveIndex = chatModel.users.firstIndex(where: { $0.user.userId == activeUser.userId })! - let removed = chatModel.users.remove(at: currentActiveIndex) - chatModel.users.insert(UserInfo(user: activeUser, unreadCount: removed.unreadCount), at: 0) - chatModel.users = chatModel.users.map { $0 } - try getUserChatData(chatModel) - userPickerVisible = false - } catch { - logger.error("Unable to set active user: \(error.localizedDescription)") - } + var users = chatModel.users + users[index] = UserInfo(user: updatedUser, unreadCount: users[index].unreadCount) + chatModel.updateUsers(users) } } private func reloadUsers() { Task { - chatModel.users = listUsers().sorted { one, two -> Bool in one.user.activeUser } + chatModel.updateUsers(listUsers()) } } @@ -171,7 +161,7 @@ func unreadCounter(_ unread: Int64) -> some View { struct UserPicker_Previews: PreviewProvider { static var previews: some View { let m = ChatModel() - m.users = [UserInfo.sampleData, UserInfo.sampleData] + m.updateUsers([UserInfo.sampleData, UserInfo.sampleData]) return UserPicker( showSettings: Binding.constant(false), userPickerVisible: Binding.constant(true) diff --git a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift index 52cf600767..22ab2a4edf 100644 --- a/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseEncryptionView.swift @@ -152,7 +152,7 @@ struct DatabaseEncryptionView: View { await operationEnded(.databaseEncrypted) } } catch let error { - if case .chatCmdError(.errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { + if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { await operationEnded(.currentPassphraseError) } else { await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) diff --git a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift index c6ecb21eb9..512cbeda35 100644 --- a/apps/ios/Shared/Views/Onboarding/CreateProfile.swift +++ b/apps/ios/Shared/Views/Onboarding/CreateProfile.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CreateProfile: View { @EnvironmentObject var m: ChatModel + @Environment(\.presentationMode) var presentationMode: Binding @State private var displayName: String = "" @State private var fullName: String = "" @FocusState private var focusDisplayName @@ -97,8 +98,12 @@ struct CreateProfile: View { do { m.currentUser = try apiCreateActiveUser(profile) try startChat() - withAnimation { m.onboardingStage = .step3_SetNotificationsMode } - + if m.users.count == 1 { + withAnimation { m.onboardingStage = .step3_SetNotificationsMode } + } else { + presentationMode.wrappedValue.dismiss() + try getUserChatData(m) + } } catch { fatalError("Failed to create user or start chat: \(responseError(error))") } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index f50b87a695..0274e6fca8 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -108,7 +108,7 @@ struct TerminalView: View { func sendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { - let resp = ChatResponse.chatCmdError(chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) + let resp = ChatResponse.chatCmdError(user: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) DispatchQueue.main.async { ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) ChatModel.shared.terminalItems.append(.resp(.now, resp)) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift index a6ee3302c2..fd1f920002 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfilesView.swift @@ -11,23 +11,24 @@ struct UserProfilesView: View { @Environment(\.editMode) private var editMode @State private var selectedUser: Int? = nil @State private var showAddUser: Bool? = false - @State private var showScanSMPServer = false - @State private var testing = false var body: some View { List { Section("Your profiles") { - ForEach(Array($m.users.enumerated()), id: \.0) { i, userInfo in + ForEach(Array(m.users.enumerated()), id: \.0) { i, userInfo in userProfileView(userInfo, index: i) - .deleteDisabled(userInfo.wrappedValue.user.activeUser) + .deleteDisabled(userInfo.user.activeUser) } .onDelete { indexSet in - do { - try apiDeleteUser(m.users[indexSet.first!].user.userId) - m.users.remove(atOffsets: indexSet) - } catch { - fatalError("Failed to delete user: \(responseError(error))") - } + AlertManager.shared.showAlert( + Alert( + title: Text("Delete profile?"), + message: Text("All chats and messages will be deleted - this cannot be undone!"), + primaryButton: .destructive(Text("Delete")) { + removeUser(index: indexSet.first!) + }, + secondaryButton: .cancel() + )) } NavigationLink(destination: CreateProfile(), tag: true, selection: $showAddUser) { Text("Add profile…") @@ -37,8 +38,21 @@ struct UserProfilesView: View { .toolbar { EditButton() } } - private func userProfileView(_ userBinding: Binding, index: Int) -> some View { - let user = userBinding.wrappedValue.user + private func removeUser(index: Int) { + do { + try apiDeleteUser(m.users[index].user.userId) + var users = m.users + users.remove(at: index) + m.updateUsers(users) + } catch { + AlertManager.shared.showAlertMsg( + title: "Failed to delete the user", + message: "Error: \(responseError(error))" + ) + } + } + private func userProfileView(_ userBinding: UserInfo, index: Int) -> some View { + let user = userBinding.user return NavigationLink(tag: index, selection: $selectedUser) { // UserPrefs(user: userBinding, index: index) // .navigationBarTitle(user.chatViewName) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index bebc06c17e..6971c3f447 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -107,20 +107,22 @@ class NotificationService: UNNotificationServiceExtension { let encNtfInfo = ntfData["message"] as? String, let dbStatus = startChat() { if case .ok = dbStatus, - let ntfMsgInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { - logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") - if let connEntity = ntfMsgInfo.connEntity { - setBestAttemptNtf(createConnectionEventNtf(connEntity)) - if let id = connEntity.id { - Task { - logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") - await PendingNtfs.shared.createStream(id) - await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) - deliverBestAttemptNtf() + let ntfMsgInfos = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) { + for ntfMsgInfo in ntfMsgInfos { + logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") + if let connEntity = ntfMsgInfo.connEntity { + setBestAttemptNtf(createConnectionEventNtf(ntfMsgInfo.user, connEntity)) + if let id = connEntity.id { + Task { + logger.debug("NotificationService: receiveNtfMessages: in Task, connEntity id \(id, privacy: .public)") + await PendingNtfs.shared.createStream(id) + await PendingNtfs.shared.readStream(id, for: self, msgCount: ntfMsgInfo.ntfMessages.count) + deliverBestAttemptNtf() + } } - return } } + return } else { setBestAttemptNtf(createErrorNtf(dbStatus)) } @@ -209,13 +211,13 @@ func chatRecvMsg() async -> ChatResponse? { func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotificationContent)? { logger.debug("NotificationService processReceivedMsg: \(res.responseType)") switch res { - case let .contactConnected(contact, _): - return (contact.id, createContactConnectedNtf(contact)) + case let .contactConnected(user, contact, _): + return (contact.id, createContactConnectedNtf(user, contact)) // case let .contactConnecting(contact): // TODO profile update - case let .receivedContactRequest(contactRequest): - return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(contactRequest)) - case let .newChatItem(aChatItem): + case let .receivedContactRequest(user, contactRequest): + return (UserContact(contactRequest: contactRequest).id, createContactRequestNtf(user, contactRequest)) + case let .newChatItem(user, aChatItem): let cInfo = aChatItem.chatInfo var cItem = aChatItem.chatItem if case .image = cItem.content.msgContent { @@ -234,7 +236,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, UNMutableNotification cItem = apiReceiveFile(fileId: file.fileId, inline: inline)?.chatItem ?? cItem } } - return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(cInfo, cItem)) : nil + return cItem.showMutableNotification ? (aChatItem.chatId, createMessageReceivedNtf(user, cInfo, cItem)) : nil case let .callInvitation(invitation): return (invitation.contact.id, createCallInvitationNtf(invitation)) default: @@ -256,12 +258,21 @@ func updateNetCfg() { } } +func listUsers() -> [UserInfo] { + let r = sendSimpleXCmd(.listUsers) + logger.debug("listUsers sendSimpleXCmd response: \(String(describing: r))") + switch r { + case let .usersList(users): return users + default: return [] + } +} + func apiGetActiveUser() -> User? { let r = sendSimpleXCmd(.showActiveUser) - logger.debug("apiGetActiveUser sendSimpleXCmd responce: \(String(describing: r))") + logger.debug("apiGetActiveUser sendSimpleXCmd response: \(String(describing: r))") switch r { case let .activeUser(user): return user - case .chatCmdError(.error(.noActiveUser)): return nil + case .chatCmdError(_, .error(.noActiveUser)): return nil default: logger.error("NotificationService apiGetActiveUser unexpected response: \(String(describing: r))") return nil @@ -289,22 +300,26 @@ func apiSetIncognito(incognito: Bool) throws { throw r } -func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { - guard let user = apiGetActiveUser() else { - logger.debug("no active user") - return nil +func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> [NtfMessages]? { + let users = listUsers() + if users.isEmpty { + logger.debug("no users") + return [] } - let r = sendSimpleXCmd(.apiGetNtfMessage(userId: user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) - if case let .ntfMessages(connEntity, msgTs, ntfMessages) = r { - return NtfMessages(connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages) + var result: [NtfMessages] = [] + users.forEach { + let r = sendSimpleXCmd(.apiGetNtfMessage(userId: $0.user.userId, nonce: nonce, encNtfInfo: encNtfInfo)) + if case let .ntfMessages(user, connEntity, msgTs, ntfMessages) = r { + result.append(NtfMessages(user: user, connEntity: connEntity, msgTs: msgTs, ntfMessages: ntfMessages)) + } + logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") } - logger.debug("apiGetNtfMessage ignored response: \(String.init(describing: r), privacy: .public)") - return nil + return result } func apiReceiveFile(fileId: Int64, inline: Bool) -> AChatItem? { let r = sendSimpleXCmd(.receiveFile(fileId: fileId, inline: inline)) - if case let .rcvFileAccepted(chatItem) = r { return chatItem } + if case let .rcvFileAccepted(_, chatItem) = r { return chatItem } logger.error("receiveFile error: \(responseError(r))") return nil } @@ -316,6 +331,7 @@ func setNetworkConfig(_ cfg: NetCfg) throws { } struct NtfMessages { + var user: User var connEntity: ConnectionEntity? var msgTs: Date? var ntfMessages: [NtfMsgInfo] diff --git a/apps/ios/SimpleXChat/API.swift b/apps/ios/SimpleXChat/API.swift index cbb296e829..b6b7d55f3a 100644 --- a/apps/ios/SimpleXChat/API.swift +++ b/apps/ios/SimpleXChat/API.swift @@ -134,6 +134,7 @@ public func chatResponse(_ s: String) -> ChatResponse { type = jResp.allKeys[0] as? String if type == "apiChats" { if let jApiChats = jResp["apiChats"] as? NSDictionary, + let user: User = try? decodeObject(jApiChats["user"] as Any), let jChats = jApiChats["chats"] as? NSArray { let chats = jChats.map { jChat in if let chatData = try? parseChatData(jChat) { @@ -141,13 +142,14 @@ public func chatResponse(_ s: String) -> ChatResponse { } return ChatData.invalidJSON(prettyJSON(jChat) ?? "") } - return .apiChats(chats: chats) + return .apiChats(user: user, chats: chats) } } else if type == "apiChat" { if let jApiChat = jResp["apiChat"] as? NSDictionary, + let user: User = try? decodeObject(jApiChat["user"] as Any), let jChat = jApiChat["chat"] as? NSDictionary, let chat = try? parseChatData(jChat) { - return .apiChat(chat: chat) + return .apiChat(user: user, chat: chat) } } } diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index 8725deede2..737b51e607 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -316,102 +316,102 @@ public enum ChatResponse: Decodable, Error { case chatRunning case chatStopped case chatSuspended - case apiChats(chats: [ChatData]) - case apiChat(chat: ChatData) - case userSMPServers(smpServers: [ServerCfg], presetSMPServers: [String]) - case smpTestResult(smpTestFailure: SMPTestFailure?) - case chatItemTTL(chatItemTTL: Int64?) + case apiChats(user: User, chats: [ChatData]) + case apiChat(user: User, chat: ChatData) + case userSMPServers(user: User, smpServers: [ServerCfg], presetSMPServers: [String]) + case smpTestResult(user: User, smpTestFailure: SMPTestFailure?) + case chatItemTTL(user: User, chatItemTTL: Int64?) case networkConfig(networkConfig: NetCfg) - case contactInfo(contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) - case groupMemberInfo(groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) - case contactCode(contact: Contact, connectionCode: String) - case groupMemberCode(groupInfo: GroupInfo, member: GroupMember, connectionCode: String) - case connectionVerified(verified: Bool, expectedCode: String) - case invitation(connReqInvitation: String) - case sentConfirmation - case sentInvitation - case contactAlreadyExists(contact: Contact) - case contactDeleted(contact: Contact) - case chatCleared(chatInfo: ChatInfo) - case userProfileNoChange - case userProfileUpdated(fromProfile: Profile, toProfile: Profile) - case contactAliasUpdated(toContact: Contact) - case connectionAliasUpdated(toConnection: PendingContactConnection) - case contactPrefsUpdated(fromContact: Contact, toContact: Contact) - case userContactLink(contactLink: UserContactLink) - case userContactLinkUpdated(contactLink: UserContactLink) - case userContactLinkCreated(connReqContact: String) - case userContactLinkDeleted - case contactConnected(contact: Contact, userCustomProfile: Profile?) - case contactConnecting(contact: Contact) - case receivedContactRequest(contactRequest: UserContactRequest) - case acceptingContactRequest(contact: Contact) - case contactRequestRejected - case contactUpdated(toContact: Contact) - case contactsSubscribed(server: String, contactRefs: [ContactRef]) - case contactsDisconnected(server: String, contactRefs: [ContactRef]) - case contactSubError(contact: Contact, chatError: ChatError) - case contactSubSummary(contactSubscriptions: [ContactSubStatus]) - case groupSubscribed(groupInfo: GroupInfo) - case memberSubErrors(memberSubErrors: [MemberSubError]) - case groupEmpty(groupInfo: GroupInfo) + case contactInfo(user: User, contact: Contact, connectionStats: ConnectionStats, customUserProfile: Profile?) + case groupMemberInfo(user: User, groupInfo: GroupInfo, member: GroupMember, connectionStats_: ConnectionStats?) + case contactCode(user: User, contact: Contact, connectionCode: String) + case groupMemberCode(user: User, groupInfo: GroupInfo, member: GroupMember, connectionCode: String) + case connectionVerified(user: User, verified: Bool, expectedCode: String) + case invitation(user: User, connReqInvitation: String) + case sentConfirmation(user: User) + case sentInvitation(user: User) + case contactAlreadyExists(user: User, contact: Contact) + case contactDeleted(user: User, contact: Contact) + case chatCleared(user: User, chatInfo: ChatInfo) + case userProfileNoChange(user: User) + case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile) + case contactAliasUpdated(user: User, toContact: Contact) + case connectionAliasUpdated(user: User, toConnection: PendingContactConnection) + case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact) + case userContactLink(user: User, contactLink: UserContactLink) + case userContactLinkUpdated(user: User, contactLink: UserContactLink) + case userContactLinkCreated(user: User, connReqContact: String) + case userContactLinkDeleted(user: User) + case contactConnected(user: User, contact: Contact, userCustomProfile: Profile?) + case contactConnecting(user: User, contact: Contact) + case receivedContactRequest(user: User, contactRequest: UserContactRequest) + case acceptingContactRequest(user: User, contact: Contact) + case contactRequestRejected(user: User) + case contactUpdated(user: User, toContact: Contact) + case contactsSubscribed(user: User, server: String, contactRefs: [ContactRef]) + case contactsDisconnected(user: User, server: String, contactRefs: [ContactRef]) + case contactSubError(user: User, contact: Contact, chatError: ChatError) + case contactSubSummary(user: User, contactSubscriptions: [ContactSubStatus]) + case groupSubscribed(user: User, groupInfo: GroupInfo) + case memberSubErrors(user: User, memberSubErrors: [MemberSubError]) + case groupEmpty(user: User, groupInfo: GroupInfo) case userContactLinkSubscribed - case newChatItem(chatItem: AChatItem) - case chatItemStatusUpdated(chatItem: AChatItem) - case chatItemUpdated(chatItem: AChatItem) - case chatItemDeleted(deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) - case contactsList(contacts: [Contact]) + case newChatItem(user: User, chatItem: AChatItem) + case chatItemStatusUpdated(user: User, chatItem: AChatItem) + case chatItemUpdated(user: User, chatItem: AChatItem) + case chatItemDeleted(user: User, deletedChatItem: AChatItem, toChatItem: AChatItem?, byUser: Bool) + case contactsList(user: User, contacts: [Contact]) // group events - case groupCreated(groupInfo: GroupInfo) - case sentGroupInvitation(groupInfo: GroupInfo, contact: Contact, member: GroupMember) - case userAcceptedGroupSent(groupInfo: GroupInfo, hostContact: Contact?) - case userDeletedMember(groupInfo: GroupInfo, member: GroupMember) - case leftMemberUser(groupInfo: GroupInfo) - case groupMembers(group: Group) - case receivedGroupInvitation(groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) - case groupDeletedUser(groupInfo: GroupInfo) - case joinedGroupMemberConnecting(groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) - case memberRole(groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case memberRoleUser(groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) - case deletedMemberUser(groupInfo: GroupInfo, member: GroupMember) - case deletedMember(groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) - case leftMember(groupInfo: GroupInfo, member: GroupMember) - case groupDeleted(groupInfo: GroupInfo, member: GroupMember) - case contactsMerged(intoContact: Contact, mergedContact: Contact) - case groupInvitation(groupInfo: GroupInfo) // unused - case userJoinedGroup(groupInfo: GroupInfo) - case joinedGroupMember(groupInfo: GroupInfo, member: GroupMember) - case connectedToGroupMember(groupInfo: GroupInfo, member: GroupMember) - case groupRemoved(groupInfo: GroupInfo) // unused - case groupUpdated(toGroup: GroupInfo) - case groupLinkCreated(groupInfo: GroupInfo, connReqContact: String) - case groupLink(groupInfo: GroupInfo, connReqContact: String) - case groupLinkDeleted(groupInfo: GroupInfo) + case groupCreated(user: User, groupInfo: GroupInfo) + case sentGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, member: GroupMember) + case userAcceptedGroupSent(user: User, groupInfo: GroupInfo, hostContact: Contact?) + case userDeletedMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case leftMemberUser(user: User, groupInfo: GroupInfo) + case groupMembers(user: User, group: Group) + case receivedGroupInvitation(user: User, groupInfo: GroupInfo, contact: Contact, memberRole: GroupMemberRole) + case groupDeletedUser(user: User, groupInfo: GroupInfo) + case joinedGroupMemberConnecting(user: User, groupInfo: GroupInfo, hostMember: GroupMember, member: GroupMember) + case memberRole(user: User, groupInfo: GroupInfo, byMember: GroupMember, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case memberRoleUser(user: User, groupInfo: GroupInfo, member: GroupMember, fromRole: GroupMemberRole, toRole: GroupMemberRole) + case deletedMemberUser(user: User, groupInfo: GroupInfo, member: GroupMember) + case deletedMember(user: User, groupInfo: GroupInfo, byMember: GroupMember, deletedMember: GroupMember) + case leftMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupDeleted(user: User, groupInfo: GroupInfo, member: GroupMember) + case contactsMerged(user: User, intoContact: Contact, mergedContact: Contact) + case groupInvitation(user: User, groupInfo: GroupInfo) // unused + case userJoinedGroup(user: User, groupInfo: GroupInfo) + case joinedGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case connectedToGroupMember(user: User, groupInfo: GroupInfo, member: GroupMember) + case groupRemoved(user: User, groupInfo: GroupInfo) // unused + case groupUpdated(user: User, toGroup: GroupInfo) + case groupLinkCreated(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLink(user: User, groupInfo: GroupInfo, connReqContact: String) + case groupLinkDeleted(user: User, groupInfo: GroupInfo) // receiving file events - case rcvFileAccepted(chatItem: AChatItem) - case rcvFileAcceptedSndCancelled(rcvFileTransfer: RcvFileTransfer) - case rcvFileStart(chatItem: AChatItem) - case rcvFileComplete(chatItem: AChatItem) + case rcvFileAccepted(user: User, chatItem: AChatItem) + case rcvFileAcceptedSndCancelled(user: User, rcvFileTransfer: RcvFileTransfer) + case rcvFileStart(user: User, chatItem: AChatItem) + case rcvFileComplete(user: User, chatItem: AChatItem) // sending file events - case sndFileStart(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileComplete(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileStart(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndFileComplete(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndFileRcvCancelled(chatItem: AChatItem, sndFileTransfer: SndFileTransfer) - case sndGroupFileCancelled(chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) + case sndFileRcvCancelled(user: User, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) + case sndGroupFileCancelled(user: User, chatItem: AChatItem, fileTransferMeta: FileTransferMeta, sndFileTransfers: [SndFileTransfer]) case callInvitation(callInvitation: RcvCallInvitation) - case callOffer(contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) - case callAnswer(contact: Contact, answer: WebRTCSession) - case callExtraInfo(contact: Contact, extraInfo: WebRTCExtraInfo) - case callEnded(contact: Contact) + case callOffer(user: User, contact: Contact, callType: CallType, offer: WebRTCSession, sharedKey: String?, askConfirmation: Bool) + case callAnswer(user: User, contact: Contact, answer: WebRTCSession) + case callExtraInfo(user: User, contact: Contact, extraInfo: WebRTCExtraInfo) + case callEnded(user: User, contact: Contact) case callInvitations(callInvitations: [RcvCallInvitation]) case ntfTokenStatus(status: NtfTknStatus) case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode) - case ntfMessages(connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) - case newContactConnection(connection: PendingContactConnection) - case contactConnectionDeleted(connection: PendingContactConnection) - case cmdOk - case chatCmdError(chatError: ChatError) - case chatError(chatError: ChatError) + case ntfMessages(user: User, connEntity: ConnectionEntity?, msgTs: Date?, ntfMessages: [NtfMsgInfo]) + case newContactConnection(user: User, connection: PendingContactConnection) + case contactConnectionDeleted(user: User, connection: PendingContactConnection) + case cmdOk(user: User?) + case chatCmdError(user: User?, chatError: ChatError) + case chatError(user: User?, chatError: ChatError) public var responseType: String { get { @@ -530,104 +530,111 @@ public enum ChatResponse: Decodable, Error { case .chatRunning: return noDetails case .chatStopped: return noDetails case .chatSuspended: return noDetails - case let .apiChats(chats): return String(describing: chats) - case let .apiChat(chat): return String(describing: chat) - case let .userSMPServers(smpServers, presetServers): return "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))" - case let .smpTestResult(smpTestFailure): return String(describing: smpTestFailure) - case let .chatItemTTL(chatItemTTL): return String(describing: chatItemTTL) + case let .apiChats(u, chats): return withUser(u, String(describing: chats)) + case let .apiChat(u, chat): return withUser(u, String(describing: chat)) + case let .userSMPServers(u, smpServers, presetServers): return withUser(u, "smpServers: \(String(describing: smpServers))\npresetServers: \(String(describing: presetServers))") + case let .smpTestResult(u, smpTestFailure): return withUser(u, String(describing: smpTestFailure)) + case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL)) case let .networkConfig(networkConfig): return String(describing: networkConfig) - case let .contactInfo(contact, connectionStats, customUserProfile): return "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))" - case let .groupMemberInfo(groupInfo, member, connectionStats_): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))" - case let .contactCode(contact, connectionCode): return "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)" - case let .groupMemberCode(groupInfo, member, connectionCode): return "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)" - case let .connectionVerified(verified, expectedCode): return "verified: \(verified)\nconnectionCode: \(expectedCode)" - case let .invitation(connReqInvitation): return connReqInvitation + case let .contactInfo(u, contact, connectionStats, customUserProfile): return withUser(u, "contact: \(String(describing: contact))\nconnectionStats: \(String(describing: connectionStats))\ncustomUserProfile: \(String(describing: customUserProfile))") + case let .groupMemberInfo(u, groupInfo, member, connectionStats_): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionStats_: \(String(describing: connectionStats_)))") + case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)") + case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)") + case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") + case let .invitation(u, connReqInvitation): return withUser(u, connReqInvitation) case .sentConfirmation: return noDetails case .sentInvitation: return noDetails - case let .contactAlreadyExists(contact): return String(describing: contact) - case let .contactDeleted(contact): return String(describing: contact) - case let .chatCleared(chatInfo): return String(describing: chatInfo) + case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails - case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) - case let .contactAliasUpdated(toContact): return String(describing: toContact) - case let .connectionAliasUpdated(toConnection): return String(describing: toConnection) - case let .contactPrefsUpdated(fromContact, toContact): return "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))" - case let .userContactLink(contactLink): return contactLink.responseDetails - case let .userContactLinkUpdated(contactLink): return contactLink.responseDetails - case let .userContactLinkCreated(connReq): return connReq + case let .userProfileUpdated(u, _, toProfile): return withUser(u, String(describing: toProfile)) + case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) + case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))") + case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkUpdated(u, contactLink): return withUser(u, contactLink.responseDetails) + case let .userContactLinkCreated(u, connReq): return withUser(u, connReq) case .userContactLinkDeleted: return noDetails - case let .contactConnected(contact, _): return String(describing: contact) - case let .contactConnecting(contact): return String(describing: contact) - case let .receivedContactRequest(contactRequest): return String(describing: contactRequest) - case let .acceptingContactRequest(contact): return String(describing: contact) + case let .contactConnected(u, contact, _): return withUser(u, String(describing: contact)) + case let .contactConnecting(u, contact): return withUser(u, String(describing: contact)) + case let .receivedContactRequest(u, contactRequest): return withUser(u, String(describing: contactRequest)) + case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case .contactRequestRejected: return noDetails - case let .contactUpdated(toContact): return 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(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))" - case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions) - case let .groupSubscribed(groupInfo): return String(describing: groupInfo) - case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors) - case let .groupEmpty(groupInfo): return String(describing: groupInfo) + case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) + case let .contactsSubscribed(u, server, contactRefs): return withUser(u, "server: \(server)\ncontacts:\n\(String(describing: contactRefs))") + case let .contactsDisconnected(u, server, contactRefs): return withUser(u, "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 .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)) case .userContactLinkSubscribed: return noDetails - case let .newChatItem(chatItem): return String(describing: chatItem) - case let .chatItemStatusUpdated(chatItem): return String(describing: chatItem) - case let .chatItemUpdated(chatItem): return String(describing: chatItem) - case let .chatItemDeleted(deletedChatItem, toChatItem, byUser): return "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)" - case let .contactsList(contacts): return String(describing: contacts) - case let .groupCreated(groupInfo): return String(describing: groupInfo) - case let .sentGroupInvitation(groupInfo, contact, member): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)" - case let .userAcceptedGroupSent(groupInfo, hostContact): return "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))" - case let .userDeletedMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .leftMemberUser(groupInfo): return String(describing: groupInfo) - case let .groupMembers(group): return String(describing: group) - case let .receivedGroupInvitation(groupInfo, contact, memberRole): return "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)" - case let .groupDeletedUser(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMemberConnecting(groupInfo, hostMember, member): return "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)" - case let .memberRole(groupInfo, byMember, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .memberRoleUser(groupInfo, member, fromRole, toRole): return "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)" - case let .deletedMemberUser(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .deletedMember(groupInfo, byMember, deletedMember): return "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)" - case let .leftMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupDeleted(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .contactsMerged(intoContact, mergedContact): return "intoContact: \(intoContact)\nmergedContact: \(mergedContact)" - case let .groupInvitation(groupInfo): return String(describing: groupInfo) - case let .userJoinedGroup(groupInfo): return String(describing: groupInfo) - case let .joinedGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .connectedToGroupMember(groupInfo, member): return "groupInfo: \(groupInfo)\nmember: \(member)" - case let .groupRemoved(groupInfo): return String(describing: groupInfo) - case let .groupUpdated(toGroup): return String(describing: toGroup) - case let .groupLinkCreated(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLink(groupInfo, connReqContact): return "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)" - case let .groupLinkDeleted(groupInfo): return String(describing: groupInfo) - case let .rcvFileAccepted(chatItem): return String(describing: chatItem) + case let .newChatItem(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemStatusUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .chatItemDeleted(u, deletedChatItem, toChatItem, byUser): return withUser(u, "deletedChatItem:\n\(String(describing: deletedChatItem))\ntoChatItem:\n\(String(describing: toChatItem))\nbyUser: \(byUser)") + case let .contactsList(u, contacts): return withUser(u, String(describing: contacts)) + case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") + case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") + case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupMembers(u, group): return withUser(u, String(describing: group)) + case let .receivedGroupInvitation(u, groupInfo, contact, memberRole): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmemberRole: \(memberRole)") + case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMemberConnecting(u, groupInfo, hostMember, member): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(hostMember)\nmember: \(member)") + case let .memberRole(u, groupInfo, byMember, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .memberRoleUser(u, groupInfo, member, fromRole, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)\nfromRole: \(fromRole)\ntoRole: \(toRole)") + case let .deletedMemberUser(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .deletedMember(u, groupInfo, byMember, deletedMember): return withUser(u, "groupInfo: \(groupInfo)\nbyMember: \(byMember)\ndeletedMember: \(deletedMember)") + case let .leftMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .contactsMerged(u, intoContact, mergedContact): return withUser(u, "intoContact: \(intoContact)\nmergedContact: \(mergedContact)") + case let .groupInvitation(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .userJoinedGroup(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .joinedGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .connectedToGroupMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .groupRemoved(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .groupUpdated(u, toGroup): return withUser(u, String(describing: toGroup)) + case let .groupLinkCreated(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLink(u, groupInfo, connReqContact): return withUser(u, "groupInfo: \(groupInfo)\nconnReqContact: \(connReqContact)") + case let .groupLinkDeleted(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .rcvFileAccepted(u, chatItem): return withUser(u, String(describing: chatItem)) case .rcvFileAcceptedSndCancelled: return noDetails - case let .rcvFileStart(chatItem): return String(describing: chatItem) - case let .rcvFileComplete(chatItem): return String(describing: chatItem) - case let .sndFileStart(chatItem, _): return String(describing: chatItem) - case let .sndFileComplete(chatItem, _): return String(describing: chatItem) + case let .rcvFileStart(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .rcvFileComplete(u, chatItem): return withUser(u, String(describing: chatItem)) + case let .sndFileStart(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileCancelled(chatItem, _): return String(describing: chatItem) - case let .sndFileRcvCancelled(chatItem, _): return String(describing: chatItem) - case let .sndGroupFileCancelled(chatItem, _, _): return String(describing: chatItem) + case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) + case let .sndGroupFileCancelled(u, chatItem, _, _): return withUser(u, String(describing: chatItem)) case let .callInvitation(inv): return String(describing: inv) - case let .callOffer(contact, callType, offer, sharedKey, askConfirmation): return "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))" - case let .callAnswer(contact, answer): return "contact: \(contact.id)\nanswer: \(String(describing: answer))" - case let .callExtraInfo(contact, extraInfo): return "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))" - case let .callEnded(contact): return "contact: \(contact.id)" + case let .callOffer(u, contact, callType, offer, sharedKey, askConfirmation): return withUser(u, "contact: \(contact.id)\ncallType: \(String(describing: callType))\nsharedKey: \(sharedKey ?? "")\naskConfirmation: \(askConfirmation)\noffer: \(String(describing: offer))") + case let .callAnswer(u, contact, answer): return withUser(u, "contact: \(contact.id)\nanswer: \(String(describing: answer))") + case let .callExtraInfo(u, contact, extraInfo): return withUser(u, "contact: \(contact.id)\nextraInfo: \(String(describing: extraInfo))") + case let .callEnded(u, contact): return withUser(u, "contact: \(contact.id)") case let .callInvitations(invs): return String(describing: invs) case let .ntfTokenStatus(status): return String(describing: status) case let .ntfToken(token, status, ntfMode): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)" - case let .ntfMessages(connEntity, msgTs, ntfMessages): return "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))" - case let .newContactConnection(connection): return String(describing: connection) - case let .contactConnectionDeleted(connection): return String(describing: connection) + case let .ntfMessages(u, connEntity, msgTs, ntfMessages): return withUser(u, "connEntity: \(String(describing: connEntity))\nmsgTs: \(String(describing: msgTs))\nntfMessages: \(String(describing: ntfMessages))") + case let .newContactConnection(u, connection): return withUser(u, String(describing: connection)) + case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case .cmdOk: return noDetails - case let .chatCmdError(chatError): return String(describing: chatError) - case let .chatError(chatError): return String(describing: chatError) + case let .chatCmdError(u, chatError): return withUser(u, String(describing: chatError)) + case let .chatError(u, chatError): return withUser(u, String(describing: chatError)) } } } private var noDetails: String { get { "\(responseType): no details" } } + + private func withUser(_ u: User?, _ s: String) -> String { + if let id = u?.userId { + return "userId: \(id)\n\(s)" + } + return s + } } public enum ChatPagination { diff --git a/apps/ios/SimpleXChat/CallTypes.swift b/apps/ios/SimpleXChat/CallTypes.swift index 1d5fa360ca..4a041f784e 100644 --- a/apps/ios/SimpleXChat/CallTypes.swift +++ b/apps/ios/SimpleXChat/CallTypes.swift @@ -38,6 +38,7 @@ public struct WebRTCExtraInfo: Codable { } public struct RcvCallInvitation: Decodable { + public var user: User public var contact: Contact public var callkitUUID: UUID? = UUID() public var callType: CallType @@ -53,6 +54,7 @@ public struct RcvCallInvitation: Decodable { } public static let sampleData = RcvCallInvitation( + user: User.sampleData, contact: Contact.sampleData, callType: CallType(media: .audio, capabilities: CallCapabilities(encryption: false)), callTs: .now diff --git a/apps/ios/SimpleXChat/Notifications.swift b/apps/ios/SimpleXChat/Notifications.swift index e859d880a8..035e849ff5 100644 --- a/apps/ios/SimpleXChat/Notifications.swift +++ b/apps/ios/SimpleXChat/Notifications.swift @@ -21,7 +21,7 @@ public let appNotificationId = "chat.simplex.app.notification" let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification") -public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNMutableNotificationContent { +public func createContactRequestNtf(_ user: User, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactRequest, @@ -34,11 +34,11 @@ public func createContactRequestNtf(_ contactRequest: UserContactRequest) -> UNM hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName ), targetContentIdentifier: nil, - userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] + userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId] ) } -public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificationContent { +public func createContactConnectedNtf(_ user: User, _ contact: Contact) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden return createNotification( categoryIdentifier: ntfCategoryContactConnected, @@ -50,12 +50,13 @@ public func createContactConnectedNtf(_ contact: Contact) -> UNMutableNotificati NSLocalizedString("You can now send messages to %@", comment: "notification body"), hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName ), - targetContentIdentifier: contact.id + targetContentIdentifier: contact.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": contact.id, "contactId": contact.apiId] ) } -public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { +public func createMessageReceivedNtf(_ user: User, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent { let previewMode = ntfPreviewModeGroupDefault.get() var title: String if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir { @@ -67,7 +68,8 @@ public func createMessageReceivedNtf(_ cInfo: ChatInfo, _ cItem: ChatItem) -> UN categoryIdentifier: ntfCategoryMessageReceived, title: title, body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"), - targetContentIdentifier: cInfo.id + targetContentIdentifier: cInfo.id, + userInfo: ["userId": user.userId] // userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id] ) } @@ -82,11 +84,11 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutabl title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):", body: text, targetContentIdentifier: nil, - userInfo: ["chatId": invitation.contact.id] + userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId] ) } -public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutableNotificationContent { +public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent { let hideContent = ntfPreviewModeGroupDefault.get() == .hidden var title: String var body: String? = nil @@ -115,7 +117,8 @@ public func createConnectionEventNtf(_ connEntity: ConnectionEntity) -> UNMutabl categoryIdentifier: ntfCategoryConnectionEvent, title: title, body: body, - targetContentIdentifier: targetContentIdentifier + targetContentIdentifier: targetContentIdentifier, + userInfo: ["userId": user.userId] ) }