diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 8cd8b0eece..2784551361 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -105,27 +105,87 @@ class ChatTagsModel: ObservableObject { @Published var userTags: [ChatTag] = [] @Published var activeFilter: ActiveFilter? = nil - @Published var presetTags: [PresetTag] = [] -} - -func updatePresetTags(_ chats: [Chat]) { - var matches: Set = [] - for chat in chats { - for tag in PresetTag.allCases { - if presetTagMatchesChat(tag, chat) { - matches.insert(tag) + @Published var presetTags: [PresetTag:Int] = [:] + @Published var unreadTags: [Int64:Int] = [:] + + func updateChatTags(_ chats: [Chat]) { + let tm = ChatTagsModel.shared + var newPresetTags: [PresetTag:Int] = [:] + var newUnreadTags: [Int64:Int] = [:] + for chat in chats { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chat.chatInfo) { + newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1 + } + } + if chat.isUnread, let tags = chat.chatInfo.chatTags { + for tag in tags { + newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1 + } } } - if matches.count == PresetTag.allCases.count { - break + if case let .presetTag(tag) = tm.activeFilter, (newPresetTags[tag] ?? 0) == 0 { + activeFilter = nil + } + presetTags = newPresetTags + unreadTags = newUnreadTags + } + + func updateChatFavorite(favorite: Bool, wasFavorite: Bool) { + let count = presetTags[.favorites] + if favorite && !wasFavorite { + presetTags[.favorites] = (count ?? 0) + 1 + } else if !favorite && wasFavorite, let count { + presetTags[.favorites] = max(0, count - 1) + if case .presetTag(.favorites) = activeFilter, (presetTags[.favorites] ?? 0) == 0 { + activeFilter = nil + } + } + } + + func addPresetChatTags(_ chatInfo: ChatInfo) { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chatInfo) { + presetTags[tag] = (presetTags[tag] ?? 0) + 1 + } + } + } + + func removePresetChatTags(_ chatInfo: ChatInfo) { + for tag in PresetTag.allCases { + if presetTagMatchesChat(tag, chatInfo) { + if let count = presetTags[tag] { + presetTags[tag] = max(0, count - 1) + } + } } } - let tm = ChatTagsModel.shared - if case let .presetTag(tag) = tm.activeFilter, !matches.contains(tag) { - tm.activeFilter = nil + func markChatTagRead(_ chat: Chat) -> Void { + if chat.isUnread, let tags = chat.chatInfo.chatTags { + markChatTagRead_(chat, tags) + } + } + + func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void { + guard let tags = chat.chatInfo.chatTags else { return } + let nowUnread = chat.isUnread + if nowUnread && !wasUnread { + for tag in tags { + unreadTags[tag] = (unreadTags[tag] ?? 0) + 1 + } + } else if !nowUnread && wasUnread { + markChatTagRead_(chat, tags) + } + } + + private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void { + for tag in tags { + if let count = unreadTags[tag] { + unreadTags[tag] = max(0, count - 1) + } + } } - tm.presetTags = Array(matches).sorted(by: { $0.rawValue < $1.rawValue }) } class NetworkModel: ObservableObject { @@ -370,10 +430,9 @@ final class ChatModel: ObservableObject { private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) { if hasChat(cInfo.id) { updateChatInfo(cInfo) - updatePresetTags(self.chats) } else if addMissing { addChat(Chat(chatInfo: cInfo, chatItems: [])) - updatePresetTags(self.chats) + ChatTagsModel.shared.addPresetChatTags(cInfo) } } @@ -596,6 +655,7 @@ final class ChatModel: ObservableObject { _updateChat(cInfo.id) { chat in self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) self.updateFloatingButtons(unreadCount: 0) + ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() } // update current chat @@ -634,7 +694,9 @@ final class ChatModel: ObservableObject { // update preview let markedCount = chat.chatStats.unreadCount - unreadBelow if markedCount > 0 { + let wasUnread = chat.isUnread chat.chatStats.unreadCount -= markedCount + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount) self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount) } @@ -647,7 +709,9 @@ final class ChatModel: ObservableObject { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in + let wasUnread = chat.isUnread chat.chatStats.unreadChat = unreadChat + ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) } } @@ -656,6 +720,7 @@ final class ChatModel: ObservableObject { if let chat = getChat(cInfo.id) { self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount) chat.chatItems = [] + ChatTagsModel.shared.markChatTagRead(chat) chat.chatStats = ChatStats() chat.chatInfo = cInfo } @@ -782,7 +847,9 @@ final class ChatModel: ObservableObject { } func changeUnreadCounter(_ chatIndex: Int, by count: Int) { + let wasUnread = chats[chatIndex].isUnread chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count + ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) changeUnreadCounter(user: currentUser!, by: count) } @@ -887,8 +954,10 @@ final class ChatModel: ObservableObject { func removeChat(_ id: String) { withAnimation { - chats.removeAll(where: { $0.id == id }) - updatePresetTags(chats) + if let i = getChatIndex(id) { + let removed = chats.remove(at: i) + ChatTagsModel.shared.removePresetChatTags(removed.chatInfo) + } } } @@ -986,6 +1055,10 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } } + var isUnread: Bool { + chatStats.unreadCount > 0 || chatStats.unreadChat + } + var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 177f3e884f..7eb78edf74 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1793,7 +1793,7 @@ func getUserChatData() throws { let tm = ChatTagsModel.shared tm.activeFilter = nil tm.userTags = tags - updatePresetTags(m.chats) + tm.updateChatTags(m.chats) } private func getUserChatDataAsync() async throws { @@ -1810,7 +1810,7 @@ private func getUserChatDataAsync() async throws { m.updateChats(chats) tm.activeFilter = nil tm.userTags = tags - updatePresetTags(m.chats) + tm.updateChatTags(m.chats) } } else { await MainActor.run { @@ -1818,7 +1818,7 @@ private func getUserChatDataAsync() async throws { m.updateChats([]) tm.activeFilter = nil tm.userTags = [] - tm.presetTags = [] + tm.presetTags = [:] } } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 90c277ce76..ac4066d23e 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2007,6 +2007,8 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) { do { try await apiSetChatSettings(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, chatSettings: chatSettings) await MainActor.run { + let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false + ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite) switch chat.chatInfo { case var .direct(contact): contact.chatSettings = chatSettings diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index d3ed26161d..9cb87a4b22 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -472,7 +472,7 @@ struct ChatListView: View { func filtered(_ chat: Chat) -> Bool { switch chatTagsModel.activeFilter { - case let .presetTag(tag): presetTagMatchesChat(tag, chat) + case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo) case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0 case .none: true @@ -665,6 +665,7 @@ struct ChatListSearchBar: View { struct ChatTagsView: View { @EnvironmentObject var chatTagsModel: ChatTagsModel @EnvironmentObject var chatModel: ChatModel + @EnvironmentObject var theme: AppTheme @Binding var parentSheet: SomeSheet? var body: some View { @@ -681,13 +682,13 @@ struct ChatTagsView: View { collapsedTagsFilterView() } } + let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter { + tag + } else { + nil + } ForEach(chatTagsModel.userTags, id: \.id) { tag in - let current = if case let .userTag(t) = chatTagsModel.activeFilter { - t == tag - } else { - false - } - + let current = tag == selectedTag let color: Color = current ? .accentColor : .secondary ZStack { HStack(spacing: 4) { @@ -698,8 +699,9 @@ struct ChatTagsView: View { .foregroundColor(color) } ZStack { - Text(tag.chatTagText).fontWeight(.semibold).foregroundColor(.clear) - Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + let badge = Text(verbatim: (chatTagsModel.unreadTags[tag.chatTagId] ?? 0) > 0 ? " ●" : "").font(.footnote) + (Text(tag.chatTagText).fontWeight(.semibold) + badge).foregroundColor(.clear) + Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color) + badge.foregroundColor(theme.colors.primary) } } .onTapGesture { @@ -757,21 +759,23 @@ struct ChatTagsView: View { } else { nil } - ForEach(chatTagsModel.presetTags, id: \.id) { tag in - let active = tag == selectedPresetTag - let (icon, text) = presetTagLabel(tag: tag, active: active) - let color: Color = active ? .accentColor : .secondary - - HStack(spacing: 4) { - Image(systemName: icon) - .foregroundColor(color) - ZStack { - Text(text).fontWeight(.semibold).foregroundColor(.clear) - Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) + ForEach(PresetTag.allCases, id: \.id) { tag in + if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + let active = tag == selectedPresetTag + let (icon, text) = presetTagLabel(tag: tag, active: active) + let color: Color = active ? .accentColor : .secondary + + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundColor(color) + ZStack { + Text(text).fontWeight(.semibold).foregroundColor(.clear) + Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color) + } + } + .onTapGesture { + setActiveFilter(filter: .presetTag(tag)) } - } - .onTapGesture { - setActiveFilter(filter: .presetTag(tag)) } } } @@ -793,14 +797,16 @@ struct ChatTagsView: View { } } } - ForEach(chatTagsModel.presetTags, id: \.id) { tag in - Button { - setActiveFilter(filter: .presetTag(tag)) - } label: { - let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag) - HStack { - Image(systemName: systemName) - Text(text) + ForEach(PresetTag.allCases, id: \.id) { tag in + if (chatTagsModel.presetTags[tag] ?? 0) > 0 { + Button { + setActiveFilter(filter: .presetTag(tag)) + } label: { + let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag) + HStack { + Image(systemName: systemName) + Text(text) + } } } } @@ -846,12 +852,12 @@ func chatStoppedIcon() -> some View { } } -func presetTagMatchesChat(_ tag: PresetTag, _ chat: Chat) -> Bool { +func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool { switch tag { case .favorites: - chat.chatInfo.chatSettings?.favorite == true + chatInfo.chatSettings?.favorite == true case .contacts: - switch chat.chatInfo { + switch chatInfo { case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted case .contactRequest: true case .contactConnection: true @@ -859,12 +865,12 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chat: Chat) -> Bool { default: false } case .groups: - switch chat.chatInfo { + switch chatInfo { case let .group(groupInfo): groupInfo.businessChat == nil default: false } case .business: - chat.chatInfo.groupInfo?.businessChat?.chatType == .business + chatInfo.groupInfo?.businessChat?.chatType == .business } }