ios: report tags and icon on ChatList (#5503)

* ios: report tags and icon on ChatList

* unfilled flag

* changes

* update lib, simplify

* fix

* simpler

* one loop

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko
2025-01-11 17:39:39 +07:00
committed by GitHub
parent e3ddf04266
commit bbb58c8e09
8 changed files with 158 additions and 45 deletions

View File

@@ -114,7 +114,7 @@ class ChatTagsModel: ObservableObject {
var newUnreadTags: [Int64:Int] = [:]
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat.chatInfo) {
if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) {
newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
}
}
@@ -143,19 +143,23 @@ class ChatTagsModel: ObservableObject {
}
}
func addPresetChatTags(_ chatInfo: ChatInfo) {
func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
presetTags[tag] = (presetTags[tag] ?? 0) + 1
}
}
}
func removePresetChatTags(_ chatInfo: ChatInfo) {
func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
if let count = presetTags[tag] {
presetTags[tag] = max(0, count - 1)
if count > 1 {
presetTags[tag] = count - 1
} else {
presetTags.removeValue(forKey: tag)
}
}
}
}
@@ -186,6 +190,11 @@ class ChatTagsModel: ObservableObject {
}
}
}
func changeGroupReportsTag(_ by: Int = 0) {
if by == 0 { return }
presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by
}
}
class NetworkModel: ObservableObject {
@@ -432,7 +441,7 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
ChatTagsModel.shared.addPresetChatTags(cInfo)
ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
}
}
@@ -873,6 +882,27 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
changeGroupReportsCounter(chatId, 1)
}
func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
changeGroupReportsCounter(chatId, -1)
}
private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
if by == 0 { return }
if let i = getChatIndex(chatId) {
let chat = chats[i]
let wasReportsCount = chat.chatStats.reportsCount
chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by)
let nowReportsCount = chat.chatStats.reportsCount
let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0
ChatTagsModel.shared.changeGroupReportsTag(by)
}
}
// this function analyses "connected" events and assumes that each member will be there only once
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
var count = 0
@@ -956,7 +986,7 @@ final class ChatModel: ObservableObject {
withAnimation {
if let i = getChatIndex(id) {
let removed = chats.remove(at: i)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
}
}
}

View File

@@ -1992,6 +1992,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
@@ -2055,6 +2058,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
if !active(user) {
do {
let users = try listUsers()
await MainActor.run {
m.users = users
}
} catch {
logger.error("Error loading users: \(error)")
}
return
}
let im = ItemsModel.shared
let cInfo = ChatInfo.group(groupInfo: groupInfo)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}
var notFound = chatItemIDs.count
for ci in im.reversedChatItems {
if chatItemIDs.contains(ci.id) {
let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId {
CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_)
} else {
CIDeleted.deleted(deletedTs: Date.now)
}
await MainActor.run {
var newItem = ci
newItem.meta.itemDeleted = deleted
_ = m.upsertChatItem(cInfo, newItem)
}
notFound -= 1
if notFound == 0 { break }
}
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {

View File

@@ -1825,6 +1825,10 @@ struct ChatView: View {
} else {
m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem)
}
let deletedItem = itemDeletion.deletedChatItem.chatItem
if deletedItem.isActiveReport {
m.decreaseGroupReportsCounter(chat.chatInfo.id)
}
}
}
}
@@ -1902,6 +1906,10 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
let deletedItem = di.deletedChatItem.chatItem
if deletedItem.isActiveReport {
ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
}
}
}
await onSuccess()

View File

@@ -32,13 +32,18 @@ enum UserPickerSheet: Identifiable {
}
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 0
case contacts = 1
case groups = 2
case business = 3
case notes = 4
case groupReports = 0
case favorites = 1
case contacts = 2
case groups = 3
case business = 4
case notes = 5
var id: Int { rawValue }
var сollapse: Bool {
self != .groupReports
}
}
enum ActiveFilter: Identifiable, Equatable {
@@ -473,7 +478,7 @@ struct ChatListView: View {
func filtered(_ chat: Chat) -> Bool {
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo)
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
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
@@ -685,6 +690,11 @@ struct ChatTagsView: View {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in
if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag)
}
}
}
}
let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
@@ -757,30 +767,34 @@ struct ChatTagsView: View {
}
.foregroundColor(.secondary)
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
@ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
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))
}
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
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))
}
expandedTagFilterView(tag)
}
}
}
@@ -804,7 +818,7 @@ struct ChatTagsView: View {
}
}
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse {
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
@@ -817,7 +831,7 @@ struct ChatTagsView: View {
}
}
} label: {
if let tag = selectedPresetTag {
if let tag = selectedPresetTag, tag.сollapse {
let (systemName, _) = presetTagLabel(tag: tag, active: true)
Image(systemName: systemName)
.foregroundColor(.accentColor)
@@ -831,6 +845,7 @@ struct ChatTagsView: View {
private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .groupReports: (active ? "flag.fill" : "flag", "Reports")
case .favorites: (active ? "star.fill" : "star", "Favorites")
case .contacts: (active ? "person.fill" : "person", "Contacts")
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
@@ -838,7 +853,7 @@ struct ChatTagsView: View {
case .notes: (active ? "folder.fill" : "folder", "Notes")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
@@ -859,8 +874,10 @@ func chatStoppedIcon() -> some View {
}
}
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool {
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
switch tag {
case .groupReports:
chatStats.reportsCount > 0
case .favorites:
chatInfo.chatSettings?.favorite == true
case .contacts:

View File

@@ -399,6 +399,8 @@ struct ChatPreviewView: View {
case .group:
if progressByTimeout {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
groupReportsIcon(size: size * 0.8)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -444,6 +446,14 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(.red)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)

View File

@@ -167,9 +167,9 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; };
649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */; };
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -517,9 +517,9 @@
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a"; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a"; sourceTree = "<group>"; };
649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -673,9 +673,9 @@
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */,
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -756,8 +756,8 @@
649B28D82CFE07CF00536B68 /* libffi.a */,
649B28DC2CFE07CF00536B68 /* libgmp.a */,
649B28DA2CFE07CF00536B68 /* libgmpxx.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-KdDs5Y0jFTrCUcNZHA8hN5.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */,
);
path = Libraries;
sourceTree = "<group>";

View File

@@ -650,6 +650,7 @@ public enum ChatResponse: Decodable, Error {
case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItems(user: UserRef, chatItems: [AChatItem])
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set<Int64>, byUser: Bool, member_: GroupMember?)
case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
@@ -829,6 +830,7 @@ public enum ChatResponse: Decodable, Error {
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItems: return "newChatItems"
case .groupChatItemsDeleted: return "groupChatItemsDeleted"
case .forwardPlan: return "forwardPlan"
case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated"
case .chatItemUpdated: return "chatItemUpdated"
@@ -1008,6 +1010,8 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_):
return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))")
case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
case let .chatItemsStatusesUpdated(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")

View File

@@ -1539,13 +1539,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
}
public struct ChatStats: Decodable, Hashable {
public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
self.unreadCount = unreadCount
self.reportsCount = reportsCount
self.minUnreadItemId = minUnreadItemId
self.unreadChat = unreadChat
}
public var unreadCount: Int = 0
// actual only via getChats() and getChat(.initial), otherwise, zero
public var reportsCount: Int = 0
public var minUnreadItemId: Int64 = 0
public var unreadChat: Bool = false
}
@@ -2611,6 +2614,10 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
}
}
public var isActiveReport: Bool {
isReport && !isDeletedContent && meta.itemDeleted == nil
}
public var canBeDeletedForSelf: Bool {
(content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete
}