diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index aef8711f3..85e66e893 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async { m.removeChat(connection.id) } } + case let .contactDeletedByContact(user, contact): + if active(user) && contact.directOrUsed { + await MainActor.run { + m.updateContact(contact) + } + } case let .contactConnected(user, contact, _): if active(user) && contact.directOrUsed { await MainActor.run { diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec4cb9009..81412bf31 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -164,7 +164,7 @@ struct ChatInfoView: View { // synchronizeConnectionButtonForce() // } } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) if let contactLink = contact.contactLink { Section { @@ -181,7 +181,7 @@ struct ChatInfoView: View { } } - if contact.ready { + if contact.ready && contact.active { Section("Servers") { networkStatusRow() .onTapGesture { @@ -192,8 +192,7 @@ struct ChatInfoView: View { alert = .switchAddressAlert } .disabled( - !contact.ready - || connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } + connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil } || connStats.ratchetSyncSendProhibited ) if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index a79047ebc..31fe19c39 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -79,6 +79,7 @@ struct ChatItemContentView: View { case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case .rcvDirectEvent: eventItemView() case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent: eventItemView() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 81a063dcf..389080efc 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -150,7 +150,7 @@ struct ChatView: View { HStack { if contact.allowsFeature(.calls) { callButton(contact, .audio, imageName: "phone") - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } Menu { if contact.allowsFeature(.calls) { @@ -159,11 +159,11 @@ struct ChatView: View { } label: { Label("Video call", systemImage: "video") } - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } searchButton() toggleNtfsButton(chat) - .disabled(!contact.ready) + .disabled(!contact.ready || !contact.active) } label: { Image(systemName: "ellipsis") } @@ -321,6 +321,7 @@ struct ChatView: View { @ViewBuilder private func connectingText() -> some View { if case let .direct(contact) = chat.chatInfo, !contact.ready, + contact.active, !contact.nextSendGrpInv { Text("connecting…") .font(.caption) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index e7580530b..f445ae4b5 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -65,7 +65,7 @@ struct ChatListNavLink: View { } Button { AlertManager.shared.showAlert( - contact.ready + contact.ready || !contact.active ? deleteContactAlert(chat.chatInfo) : deletePendingContactAlert(chat, contact) ) diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 3ac8fada7..2eb6d9f6b 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -57,19 +57,26 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { - if case let .group(groupInfo) = chat.chatInfo { + switch chat.chatInfo { + case let .direct(contact): + if !contact.active { + inactiveIcon() + } else { + EmptyView() + } + case let .group(groupInfo): switch (groupInfo.membership.memberStatus) { - case .memLeft: groupInactiveIcon() - case .memRemoved: groupInactiveIcon() - case .memGroupDeleted: groupInactiveIcon() + case .memLeft: inactiveIcon() + case .memRemoved: inactiveIcon() + case .memGroupDeleted: inactiveIcon() default: EmptyView() } - } else { + default: EmptyView() } } - @ViewBuilder private func groupInactiveIcon() -> some View { + @ViewBuilder private func inactiveIcon() -> some View { Image(systemName: "multiply.circle.fill") .foregroundColor(.secondary.opacity(0.65)) .background(Circle().foregroundColor(Color(uiColor: .systemBackground))) @@ -80,7 +87,6 @@ struct ChatPreviewView: View { switch chat.chatInfo { case let .direct(contact): previewTitle(contact.verified == true ? verifiedIcon + t : t) - .foregroundColor(chat.chatInfo.ready ? .primary : .secondary) case let .group(groupInfo): let v = previewTitle(t) switch (groupInfo.membership.memberStatus) { @@ -183,7 +189,7 @@ struct ChatPreviewView: View { if !contact.ready { if contact.nextSendGrpInv { chatPreviewInfoText("send direct message") - } else { + } else if contact.active { chatPreviewInfoText("connecting…") } } @@ -228,16 +234,20 @@ struct ChatPreviewView: View { @ViewBuilder private func chatStatusImage() -> some View { switch chat.chatInfo { case let .direct(contact): - switch (chatModel.contactNetworkStatus(contact)) { - case .connected: incognitoIcon(chat.chatInfo.incognito) - case .error: - Image(systemName: "exclamationmark.circle") - .resizable() - .scaledToFit() - .frame(width: 17, height: 17) - .foregroundColor(.secondary) - default: - ProgressView() + if contact.active { + switch (chatModel.contactNetworkStatus(contact)) { + case .connected: incognitoIcon(chat.chatInfo.incognito) + case .error: + Image(systemName: "exclamationmark.circle") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + .foregroundColor(.secondary) + default: + ProgressView() + } + } else { + incognitoIcon(chat.chatInfo.incognito) } default: incognitoIcon(chat.chatInfo.incognito) diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index b0834f571..951f726be 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error { case contactAlreadyExists(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact) + case contactDeletedByContact(user: UserRef, contact: Contact) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error { case .contactAlreadyExists: return "contactAlreadyExists" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactDeleted: return "contactDeleted" + case .contactDeletedByContact: return "contactDeletedByContact" case .chatCleared: return "chatCleared" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" @@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error { case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) + case let .contactDeletedByContact(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(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) @@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable { case invalidConnReq case invalidChatMessage(connection: Connection, message: String) case contactNotReady(contact: Contact) + case contactNotActive(contact: Contact) case contactDisabled(contact: Contact) case connectionDisabled(connection: Connection) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index c0ec04857..f9996d840 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var activeConn: Connection public var viaGroup: Int64? public var contactUsed: Bool + public var contactStatus: ContactStatus public var chatSettings: ChatSettings public var userPreferences: Preferences public var mergedPreferences: ContactUserPreferences @@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat { public var id: ChatId { get { "@\(contactId)" } } public var apiId: Int64 { get { contactId } } public var ready: Bool { get { activeConn.connStatus == .ready } } + public var active: Bool { get { contactStatus == .active } } public var sendMsgEnabled: Bool { get { - (ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) + (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) || nextSendGrpInv } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } @@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat { profile: LocalProfile.sampleData, activeConn: Connection.sampleData, contactUsed: true, + contactStatus: .active, chatSettings: ChatSettings.defaults, userPreferences: Preferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData, @@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat { ) } +public enum ContactStatus: String, Decodable { + case active = "active" + case deleted = "deleted" +} + public struct ContactRef: Decodable, Equatable { var contactId: Int64 public var agentConnId: String @@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable { case .rcvDecryptionError: return showNtfDir case .rcvGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir + case .rcvDirectEvent: return false case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): switch rcvGroupEvent { case .groupUpdated: return false @@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent { case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent) @@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent { case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text @@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable { case expired } +public enum RcvDirectEvent: Decodable { + case contactDeleted + + var text: String { + switch self { + case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item") + } + } +} + public enum RcvGroupEvent: Decodable { case memberAdded(groupMemberId: Int64, profile: Profile) case memberConnected