From 0477b1aad37b939be89e171a98cc94b6c58286d3 Mon Sep 17 00:00:00 2001 From: Arturs Krumins Date: Sun, 25 Aug 2024 21:21:24 +0300 Subject: [PATCH] ios: time based message grouping (#4743) * ios: time based message grouping * cleanup * hide timestamp * fix chat item not getting updated * round to minute * separate by minute * chat dir * time separation struct * add date logic * cleanup * fix groups * simplify timestamp logic; remove shape * cleanup * cleanup * refactor, add type --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/ChatModel.swift | 26 +++++--- .../Chat/ChatItem/CIGroupInvitationView.swift | 5 +- .../Views/Chat/ChatItem/CIMetaView.swift | 22 ++++--- .../Chat/ChatItem/CIRcvDecryptionError.swift | 5 +- .../Views/Chat/ChatItem/MsgContentView.swift | 3 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 12 ++++ apps/ios/Shared/Views/Chat/ChatView.swift | 64 +++++++++++++------ apps/ios/SimpleXChat/ChatTypes.swift | 9 ++- 8 files changed, 100 insertions(+), 46 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 0ac1a9cacb..07a0d19a55 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -123,6 +123,14 @@ class NetworkModel: ObservableObject { } } +/// ChatItemWithMenu can depend on previous or next item for it's appearance +/// This dummy model is used to force an update of all chat items, +/// when they might have changed appearance. +class ChatItemDummyModel: ObservableObject { + static let shared = ChatItemDummyModel() + func sendUpdate() { objectWillChange.send() } +} + final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -428,19 +436,17 @@ final class ChatModel: ObservableObject { private func _upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { if let i = getChatItemIndex(cItem) { - withConditionalAnimation { - _updateChatItem(at: i, with: cItem) - } + _updateChatItem(at: i, with: cItem) + ChatItemDummyModel.shared.sendUpdate() return false } else { - withConditionalAnimation(itemAnimation()) { - var ci = cItem - if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { - ci.meta.itemStatus = status - } - im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.itemAdded = true + var ci = cItem + if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { + ci.meta.itemStatus = status } + im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) + im.itemAdded = true + ChatItemDummyModel.shared.sendUpdate() return true } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift index ef0fec5dfe..3c6da34ae5 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct CIGroupInvitationView: View { @EnvironmentObject var chatModel: ChatModel @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool @ObservedObject var chat: Chat var chatItem: ChatItem var groupInvitation: CIGroupInvitation @@ -45,7 +46,7 @@ struct CIGroupInvitationView: View { .foregroundColor(inProgress ? theme.colors.secondary : chatIncognito ? .indigo : theme.colors.primary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) .overlay(DetermineWidth()) } @@ -53,7 +54,7 @@ struct CIGroupInvitationView: View { ( groupInvitationText() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showStatus: false, showEdited: false, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) .overlay(DetermineWidth()) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index 66b810cf2f..3f2ec8f38e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -12,6 +12,7 @@ import SimpleXChat struct CIMetaView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool var chatItem: ChatItem var metaColor: Color var paleMetaColor = Color(UIColor.tertiaryLabel) @@ -30,24 +31,24 @@ struct CIMetaView: View { switch meta.itemStatus { case let .sndSent(sndProgress): switch sndProgress { - case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) - case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + case .complete: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + case .partial: ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .sent, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } case let .sndRcvd(_, sndProgress): switch sndProgress { case .complete: ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } case .partial: ZStack { - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd1, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: paleMetaColor, sent: .rcvd2, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } default: - ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy) + ciMetaText(meta, chatTTL: ttl, encrypted: encrypted, color: metaColor, showStatus: showStatus, showEdited: showEdited, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } } @@ -69,7 +70,8 @@ func ciMetaText( sent: SentCheckmark? = nil, showStatus: Bool = true, showEdited: Bool = true, - showViaProxy: Bool + showViaProxy: Bool, + showTimesamp: Bool ) -> Text { var r = Text("") if showEdited, meta.itemEdited { @@ -105,7 +107,9 @@ func ciMetaText( if let enc = encrypted { r = r + statusIconText(enc ? "lock" : "lock.open", color) + Text(" ") } - r = r + meta.timestampText.foregroundColor(color) + if showTimesamp { + r = r + meta.timestampText.foregroundColor(color) + } return r.font(.caption) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift index 1f2e16448d..915af3f479 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift @@ -15,6 +15,7 @@ struct CIRcvDecryptionError: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @ObservedObject var chat: Chat + @Environment(\.showTimestamp) var showTimestamp: Bool var msgDecryptError: MsgDecryptError var msgCount: UInt32 var chatItem: ChatItem @@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(syncSupported ? theme.colors.primary : theme.colors.secondary) .font(.callout) + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) ) } .padding(.horizontal, 12) @@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View { .foregroundColor(.red) .italic() + Text(" ") - + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } .padding(.horizontal, 12) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift index 999f99b294..9cc4179723 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MsgContentView.swift @@ -26,6 +26,7 @@ private func typing(_ w: Font.Weight = .light) -> Text { struct MsgContentView: View { @ObservedObject var chat: Chat + @Environment(\.showTimestamp) var showTimestamp: Bool @EnvironmentObject var theme: AppTheme var text: String var formattedText: [FormattedText]? = nil @@ -84,7 +85,7 @@ struct MsgContentView: View { } private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { - (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy) + (rightToLeft ? Text("\n") : Text(" ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, transparent: true, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) } } diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 870fe30108..d444ce0735 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -9,9 +9,21 @@ import SwiftUI import SimpleXChat +extension EnvironmentValues { + struct ShowTimestamp: EnvironmentKey { + static let defaultValue: Bool = true + } + + var showTimestamp: Bool { + get { self[ShowTimestamp.self] } + set { self[ShowTimestamp.self] = newValue } + } +} + struct ChatItemView: View { @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme + @Environment(\.showTimestamp) var showTimestamp: Bool var chatItem: ChatItem var maxWidth: CGFloat = .infinity @Binding var revealed: Bool diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 655dd8aaed..e9e86c31d7 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -696,6 +696,7 @@ struct ChatView: View { @EnvironmentObject var m: ChatModel @EnvironmentObject var theme: AppTheme @Binding @ObservedObject var chat: Chat + @ObservedObject var dummyModel: ChatItemDummyModel = .shared let chatItem: ChatItem let maxWidth: CGFloat @Binding var composeState: ComposeState @@ -716,31 +717,50 @@ struct ChatView: View { var revealed: Bool { chatItem == revealedChatItem } + typealias ItemSeparation = (timestamp: Bool, largeGap: Bool) + + func getItemSeparation(_ chatItem: ChatItem, at i: Int?) -> ItemSeparation { + let im = ItemsModel.shared + if let i, i > 0 && im.reversedChatItems.count >= i { + let nextItem = im.reversedChatItems[i - 1] + let largeGap = !nextItem.chatDir.sameDirection(chatItem.chatDir) || nextItem.meta.createdAt.timeIntervalSince(chatItem.meta.createdAt) > 60 + return ( + timestamp: largeGap || formatTimestampText(chatItem.meta.createdAt) != formatTimestampText(nextItem.meta.createdAt), + largeGap: largeGap + ) + } else { + return (timestamp: true, largeGap: true) + } + } + var body: some View { - let (currIndex, _) = m.getNextChatItem(chatItem) + let currIndex = m.getChatItemIndex(chatItem) let ciCategory = chatItem.mergeCategory let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let range = itemsRange(currIndex, prevHidden) + let timeSeparation = getItemSeparation(chatItem, at: currIndex) let im = ItemsModel.shared Group { if revealed, let range = range { let items = Array(zip(Array(range), im.reversedChatItems[range])) - ForEach(items.reversed(), id: \.1.viewId) { (i, ci) in - let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] - chatItemView(ci, nil, prev) - .overlay { - if let selected = selectedChatItems, ci.canBeDeletedForSelf { - Color.clear - .contentShape(Rectangle()) - .onTapGesture { - let checked = selected.contains(ci.id) - selectUnselectChatItem(select: !checked, ci) + VStack(spacing: 0) { + ForEach(items.reversed(), id: \.1.viewId) { (i: Int, ci: ChatItem) in + let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] + chatItemView(ci, nil, prev, getItemSeparation(ci, at: i)) + .overlay { + if let selected = selectedChatItems, ci.canBeDeletedForSelf { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + let checked = selected.contains(ci.id) + selectUnselectChatItem(select: !checked, ci) + } } - } + } } } } else { - chatItemView(chatItem, range, prevItem) + chatItemView(chatItem, range, prevItem, timeSeparation) .overlay { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { Color.clear @@ -791,7 +811,8 @@ struct ChatView: View { } } - @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?) -> some View { + @ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange?, _ prevItem: ChatItem?, _ itemSeparation: ItemSeparation) -> some View { + let bottomPadding: Double = itemSeparation.largeGap ? 10 : 2 if case let .groupRcv(member) = ci.chatDir, case let .group(groupInfo) = chat.chatInfo { let (prevMember, memCount): (GroupMember?, Int) = @@ -833,11 +854,11 @@ struct ChatView: View { } } } - chatItemWithMenu(ci, range, maxWidth) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) } } } - .padding(.bottom, 5) + .padding(.bottom, bottomPadding) .padding(.trailing) .padding(.leading, 12) } else { @@ -846,11 +867,11 @@ struct ChatView: View { SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems) .padding(.leading, 12) } - chatItemWithMenu(ci, range, maxWidth) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) .padding(.trailing) .padding(.leading, memberImageSize + 8 + 12) } - .padding(.bottom, 5) + .padding(.bottom, bottomPadding) } } else { HStack(alignment: .center, spacing: 0) { @@ -863,10 +884,10 @@ struct ChatView: View { .padding(.leading) } } - chatItemWithMenu(ci, range, maxWidth) + chatItemWithMenu(ci, range, maxWidth, itemSeparation) .padding(.horizontal) } - .padding(.bottom, 5) + .padding(.bottom, bottomPadding) } } @@ -881,7 +902,7 @@ struct ChatView: View { } } - @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat) -> some View { + @ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading VStack(alignment: alignment.horizontal, spacing: 3) { ChatItemView( @@ -891,6 +912,7 @@ struct ChatView: View { revealed: .constant(revealed), allowMenu: $allowMenu ) + .environment(\.showTimestamp, itemSeparation.timestamp) .modifier(ChatItemClipped(ci)) .contextMenu { menu(ci, range, live: composeState.liveMessage != nil) } .accessibilityLabel("") diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 1a9cf4a216..d51e5a7cc3 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2688,6 +2688,13 @@ public enum CIDirection: Decodable, Hashable { } } } + + public func sameDirection(_ dir: CIDirection) -> Bool { + switch (self, dir) { + case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId + default: sent == dir.sent + } + } } public struct CIMeta: Decodable, Hashable { @@ -2762,7 +2769,7 @@ let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute() let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits) public func formatTimestampText(_ date: Date) -> Text { - return Text(date, format: recent(date) ? msgTimeFormat : msgDateFormat) + Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat)) } private func recent(_ date: Date) -> Bool {